minor integartion and UI fixes

This commit is contained in:
Yared Yemane 2026-03-27 04:22:44 -07:00
parent e2c61385ae
commit 7c1687787b
45 changed files with 4364 additions and 1475 deletions

4
.env
View File

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

View File

@ -51,6 +51,7 @@ import type {
GetRatingsResponse, GetRatingsResponse,
GetRatingsParams, GetRatingsParams,
GetVimeoSampleResponse, GetVimeoSampleResponse,
CreateCourseVideoRequest,
} from "../types/course.types" } from "../types/course.types"
export const getCourseCategories = () => export const getCourseCategories = () =>
@ -65,6 +66,11 @@ export const getCoursesByCategory = (categoryId: number) =>
export const createCourse = (data: CreateCourseRequest) => export const createCourse = (data: CreateCourseRequest) =>
http.post("/course-management/courses", data) http.post("/course-management/courses", data)
export const updateCourseThumbnail = (courseId: number, thumbnailUrl: string) =>
http.post(`/course-management/courses/${courseId}/thumbnail`, {
thumbnail_url: thumbnailUrl,
})
export const deleteCourse = (courseId: number) => export const deleteCourse = (courseId: number) =>
http.delete(`/course-management/courses/${courseId}`) http.delete(`/course-management/courses/${courseId}`)
@ -81,6 +87,11 @@ export const getSubCoursesByCourse = (courseId: number) =>
export const createSubCourse = (data: CreateSubCourseRequest) => export const createSubCourse = (data: CreateSubCourseRequest) =>
http.post("/course-management/sub-courses", data) http.post("/course-management/sub-courses", data)
export const updateSubCourseThumbnail = (subCourseId: number, thumbnailUrl: string) =>
http.post(`/course-management/sub-courses/${subCourseId}/thumbnail`, {
thumbnail_url: thumbnailUrl,
})
export const updateSubCourse = (subCourseId: number, data: UpdateSubCourseRequest) => export const updateSubCourse = (subCourseId: number, data: UpdateSubCourseRequest) =>
http.patch(`/course-management/sub-courses/${subCourseId}`, data) http.patch(`/course-management/sub-courses/${subCourseId}`, data)
@ -97,6 +108,9 @@ export const getVideosBySubCourse = (subCourseId: number) =>
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) => export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
http.post("/course-management/sub-course-videos", data) http.post("/course-management/sub-course-videos", data)
export const createCourseVideo = (data: CreateCourseVideoRequest) =>
http.post("/course-management/videos", data)
export const updateSubCourseVideo = (videoId: number, data: UpdateSubCourseVideoRequest) => export const updateSubCourseVideo = (videoId: number, data: UpdateSubCourseVideoRequest) =>
http.put(`/course-management/sub-course-videos/${videoId}`, data) http.put(`/course-management/sub-course-videos/${videoId}`, data)
@ -227,6 +241,15 @@ export const deleteQuestion = (questionId: number) =>
export const updateQuestion = (questionId: number, data: CreateQuestionRequest) => export const updateQuestion = (questionId: number, data: CreateQuestionRequest) =>
http.put(`/questions/${questionId}`, data) http.put(`/questions/${questionId}`, data)
export interface SubmitAudioAnswerRequest {
question_id: number
question_set_id: number
object_key: string
}
export const submitAudioAnswer = (data: SubmitAudioAnswerRequest) =>
http.post("/questions/audio-answer", data)
export const deleteQuestionSet = (questionSetId: number) => export const deleteQuestionSet = (questionSetId: number) =>
http.delete(`/question-sets/${questionSetId}`) http.delete(`/question-sets/${questionSetId}`)

61
src/api/files.api.ts Normal file
View File

@ -0,0 +1,61 @@
import http from "./http"
export type UploadMediaType = "image" | "audio" | "video"
export type UploadProvider = "MINIO" | "VIMEO"
export interface UploadMediaResponse {
message: string
data?: {
object_key?: string
url?: string
content_type?: string
media_type?: UploadMediaType
provider?: UploadProvider
vimeo_id?: string
embed_url?: string
}
success?: boolean
}
export interface ResolveFileUrlResponse {
message: string
data?: {
url?: string
}
success?: boolean
}
export interface UploadMediaOptions {
title?: string
description?: string
}
export const uploadMediaFile = (
mediaType: UploadMediaType,
file: File,
options?: UploadMediaOptions,
) => {
const formData = new FormData()
formData.append("media_type", mediaType)
formData.append("file", file)
if (mediaType === "video") {
if (options?.title) formData.append("title", options.title)
if (options?.description) formData.append("description", options.description)
}
// Let Axios set the correct multipart boundary.
return http.post<UploadMediaResponse>("/files/upload", formData)
}
export const uploadAudioFile = (file: File) => uploadMediaFile("audio", file)
export const uploadImageFile = (file: File) => uploadMediaFile("image", file)
export const uploadVideoFile = (file: File, options?: UploadMediaOptions) =>
uploadMediaFile("video", file, options)
export const resolveFileUrl = (key: string) =>
http.get<ResolveFileUrlResponse>("/files/url", {
params: { key },
})

View File

@ -2,9 +2,9 @@ import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestCo
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,
headers: { // Do not force a Content-Type globally.
"Content-Type": "application/json", // Axios will set the correct header based on the request body (JSON vs multipart FormData).
}, headers: {},
}); });
let isRefreshing = false; let isRefreshing = false;

View File

@ -1,5 +1,10 @@
import http from "./http" import http from "./http"
import type { GetTeamMembersResponse, GetTeamMemberResponse, CreateTeamMemberRequest } from "../types/team.types" import type {
GetTeamMembersResponse,
GetTeamMemberResponse,
CreateTeamMemberRequest,
UpdateTeamMemberRequest,
} from "../types/team.types"
export const getTeamMembers = (page?: number, pageSize?: number) => export const getTeamMembers = (page?: number, pageSize?: number) =>
http.get<GetTeamMembersResponse>("/team/members", { http.get<GetTeamMembersResponse>("/team/members", {
@ -17,3 +22,6 @@ export const createTeamMember = (data: CreateTeamMemberRequest) =>
export const updateTeamMemberStatus = (id: number, status: string) => export const updateTeamMemberStatus = (id: number, status: string) =>
http.patch(`/team/members/${id}/status`, { status }) http.patch(`/team/members/${id}/status`, { status })
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
http.put(`/team/members/${id}`, data)

View File

@ -1,5 +1,12 @@
import http from "./http"; import http from "./http";
import { type UserProfileResponse, type GetUsersResponse, type UpdateProfileRequest, type UserSummaryResponse } from "../types/user.types"; import {
type UserProfileResponse,
type GetUsersResponse,
type UpdateProfileRequest,
type UserSummaryResponse,
type GetDeletionRequestsParams,
type GetDeletionRequestsResponse,
} from "../types/user.types";
export const getUsers = ( export const getUsers = (
page?: number, page?: number,
@ -53,3 +60,11 @@ export const updateProfile = (data: UpdateProfileRequest) =>
export const getUserSummary = () => export const getUserSummary = () =>
http.get<UserSummaryResponse>("/users/summary"); http.get<UserSummaryResponse>("/users/summary");
export const getDeletionRequests = (params: GetDeletionRequestsParams) =>
http.get<GetDeletionRequestsResponse>("/admin/users/deletion-requests", { params });
export const updateUserProfilePicture = (id: number, profilePictureUrl: string) =>
http.post(`/user/${id}/profile-picture`, {
profile_picture_url: profilePictureUrl,
});

View File

@ -22,7 +22,7 @@ import { UserManagementLayout } from "../pages/user-management/UserManagementLay
import { UsersListPage } from "../pages/user-management/UsersListPage" import { UsersListPage } from "../pages/user-management/UsersListPage"
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard" import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard"
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage" import { UserGroupsPage } from "../pages/user-management/UserGroupsPage"
import { RegisterUserPage } from "../pages/user-management/RegisterUserPage" import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage"
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout" import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout"
import { RolesListPage } from "../pages/role-management/RolesListPage" import { RolesListPage } from "../pages/role-management/RolesListPage"
import { AddRolePage } from "../pages/role-management/AddRolePage" import { AddRolePage } from "../pages/role-management/AddRolePage"
@ -40,6 +40,10 @@ 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"
import { AboutPage } from "../pages/AboutPage"
import { TermsPage } from "../pages/TermsPage"
import { PrivacyPage } from "../pages/PrivacyPage"
import { AccountDeletionPage } from "../pages/AccountDeletionPage"
export function AppRoutes() { export function AppRoutes() {
return ( return (
@ -47,13 +51,17 @@ export function AppRoutes() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/verification" element={<VerificationPage />} /> <Route path="/verification" element={<VerificationPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/account-deletion" element={<AccountDeletionPage />} />
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users" element={<UserManagementLayout />}> <Route path="/users" element={<UserManagementLayout />}>
<Route index element={<UserManagementDashboard />} /> <Route index element={<UserManagementDashboard />} />
<Route path="list" element={<UsersListPage />} /> <Route path="list" element={<UsersListPage />} />
<Route path="register" element={<RegisterUserPage />} /> <Route path="deletion-requests" element={<DeletionRequestsPage />} />
<Route path="groups" element={<UserGroupsPage />} /> <Route path="groups" element={<UserGroupsPage />} />
<Route path=":id" element={<UserDetailPage />} /> <Route path=":id" element={<UserDetailPage />} />
</Route> </Route>

View File

@ -12,13 +12,13 @@ import {
BookOpen, BookOpen,
Video, Video,
ShieldAlert, ShieldAlert,
Loader2,
MailOpen, MailOpen,
Mail, Mail,
CheckCheck, CheckCheck,
} from "lucide-react" } from "lucide-react"
import { Badge } from "../ui/badge" import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../ui/spinner-icon"
import { useNotifications } from "../../hooks/useNotifications" import { useNotifications } from "../../hooks/useNotifications"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types" import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
@ -213,7 +213,7 @@ export function NotificationDropdown() {
<div className="max-h-[480px] overflow-y-auto"> <div className="max-h-[480px] overflow-y-auto">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-grayScale-400" /> <SpinnerIcon className="h-6 w-6" />
</div> </div>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">

View File

@ -18,7 +18,7 @@ export const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none focus:bg-grayScale-100 data-[state=open]:bg-grayScale-100", "flex cursor-default select-none items-center rounded-lg px-2.5 py-2 text-sm text-grayScale-600 outline-none transition-colors focus:bg-brand-100/40 data-[state=open]:bg-brand-100/40",
inset && "pl-8", inset && "pl-8",
className, className,
)} )}
@ -37,7 +37,7 @@ export const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 text-grayScale-600 shadow-soft", "z-50 min-w-[9rem] overflow-hidden rounded-xl border border-grayScale-200 bg-white p-1.5 text-grayScale-600 shadow-soft",
className, className,
)} )}
{...props} {...props}
@ -54,7 +54,7 @@ export const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 min-w-[10rem] overflow-hidden rounded-md border bg-white p-1 text-grayScale-600 shadow-soft", "z-50 min-w-[11rem] overflow-hidden rounded-xl border border-grayScale-200 bg-white p-1.5 text-grayScale-600 shadow-soft",
className, className,
)} )}
{...props} {...props}
@ -70,7 +70,7 @@ export const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-grayScale-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-lg px-2.5 py-2 text-sm outline-none transition-colors focus:bg-brand-100/40 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
className, className,
)} )}
@ -86,7 +86,7 @@ export const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-grayScale-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-lg py-2 pl-8 pr-2.5 text-sm outline-none transition-colors focus:bg-brand-100/40 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className, className,
)} )}
checked={checked} checked={checked}
@ -109,7 +109,7 @@ export const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-grayScale-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center rounded-lg py-2 pl-8 pr-2.5 text-sm outline-none transition-colors focus:bg-brand-100/40 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className, className,
)} )}
{...props} {...props}
@ -130,7 +130,7 @@ export const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn("px-2 py-1.5 text-xs font-semibold text-grayScale-500", inset && "pl-8", className)} className={cn("px-2.5 py-1.5 text-xs font-semibold text-grayScale-500", inset && "pl-8", className)}
{...props} {...props}
/> />
)) ))

View File

@ -10,7 +10,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<div className="relative"> <div className="relative">
<select <select
className={cn( className={cn(
"flex h-10 w-full appearance-none rounded-lg border bg-white px-3 py-2 pr-8 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", "flex h-11 w-full appearance-none rounded-xl border border-grayScale-200 bg-white px-3 py-2 pr-8 text-sm text-grayScale-600 shadow-sm ring-offset-background transition hover:bg-grayScale-50 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
ref={ref} ref={ref}

View File

@ -0,0 +1,12 @@
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { cn } from "../../lib/utils"
interface SpinnerIconProps {
className?: string
alt?: string
}
export function SpinnerIcon({ className, alt = "Loading" }: SpinnerIconProps) {
return <img src={spinnerSrc} alt={alt} className={cn("animate-spin", className)} />
}

View File

@ -10,7 +10,7 @@ export interface StepperProps {
export function Stepper({ steps, currentStep, className }: StepperProps) { export function Stepper({ steps, currentStep, className }: StepperProps) {
return ( return (
<div className={cn("flex items-center justify-between", className)}> <div className={cn("flex w-full items-center", className)}>
{steps.map((step, index) => { {steps.map((step, index) => {
const stepNumber = index + 1 const stepNumber = index + 1
const isCompleted = stepNumber < currentStep const isCompleted = stepNumber < currentStep
@ -18,13 +18,14 @@ export function Stepper({ steps, currentStep, className }: StepperProps) {
return ( return (
<React.Fragment key={step}> <React.Fragment key={step}>
<div className="flex flex-1 items-center"> <div className="flex items-center">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={cn( className={cn(
"grid h-10 w-10 place-items-center rounded-full border-2 text-sm font-semibold transition-colors", "grid h-10 w-10 place-items-center rounded-full border-2 text-sm font-semibold transition-colors",
isCompleted && "border-brand-500 bg-brand-500 text-white", isCompleted && "border-brand-500 bg-brand-500 text-white",
isCurrent && "border-brand-500 bg-brand-50 text-brand-600", // Active step should be visually prominent.
isCurrent && "border-brand-500 bg-brand-500 text-white",
!isCompleted && !isCurrent && "border-grayScale-300 bg-white text-grayScale-400", !isCompleted && !isCurrent && "border-grayScale-300 bg-white text-grayScale-400",
)} )}
> >
@ -44,8 +45,10 @@ export function Stepper({ steps, currentStep, className }: StepperProps) {
{index < steps.length - 1 && ( {index < steps.length - 1 && (
<div <div
className={cn( className={cn(
"mx-4 h-0.5 flex-1", // Keep the connector visually continuous with the step circles.
isCompleted ? "bg-brand-500" : "bg-grayScale-200", "mx-2 h-0.5 flex-1",
// Color the track up to the current step.
isCompleted || isCurrent ? "bg-brand-500" : "bg-grayScale-200",
)} )}
/> />
)} )}

View File

@ -78,6 +78,10 @@ export function useNotifications() {
} }
} }
ws.onerror = () => {
ws.close()
}
ws.onclose = () => { ws.onclose = () => {
if (!mountedRef.current) return if (!mountedRef.current) return
reconnectTimer.current = setTimeout(() => { reconnectTimer.current = setTimeout(() => {

28
src/pages/AboutPage.tsx Normal file
View File

@ -0,0 +1,28 @@
import { InfoLayout } from "./legal/InfoLayout"
export function AboutPage() {
return (
<InfoLayout
title="About"
subtitle="Yimaru Academy provides modern, structured learning experiences and the administration tools to manage courses, question sets, speaking practices, and learner progress at scale."
>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">What is Yimaru Academy?</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Yimaru Academy is a digital learning platform focused on high-quality content delivery,
measurable learner outcomes, and efficient administration for training teams.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Core Platform Capabilities</h2>
<ul className="list-disc space-y-2 pl-5 text-sm leading-relaxed text-grayScale-600">
<li>Manage course categories, sub-categories, courses, videos, and assessments.</li>
<li>Create practice question sets including multiple choice, short answer, and audio types.</li>
<li>Track learner progress and completion metrics across course structures.</li>
<li>Operate teams, roles, notifications, and user lifecycle from one dashboard.</li>
</ul>
</section>
</InfoLayout>
)
}

View File

@ -0,0 +1,50 @@
import { InfoLayout } from "./legal/InfoLayout"
export function AccountDeletionPage() {
return (
<InfoLayout
title="Account Deletion"
subtitle="Guidance on requesting and processing account deletion within Yimaru Academy."
>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Before Deletion</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Confirm account ownership and review the potential impact on course history, progress
reporting, team assignments, and related records.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Deletion Steps</h2>
<ol className="list-decimal space-y-2 pl-5 text-sm leading-relaxed text-grayScale-600">
<li>
Launch the <strong>Yimaru mobile app</strong> and <strong>sign in</strong> to the account you want to remove.
</li>
<li>
Open your <strong>Profile</strong> area from the main menu or bottom navigation bar.
</li>
<li>
Go into <strong>Account Settings</strong> (or <strong>Account Management</strong>, depending on your app version).
</li>
<li>
Tap the option labeled <strong>Delete Account</strong> or <strong>Request Account Deletion</strong>.
</li>
<li>
Review the warning details and complete the <strong>confirmation prompts</strong> shown on screen.
</li>
<li>
Finish the process by verifying identity through your <strong>PIN</strong> or <strong>biometric authentication</strong> if requested.
</li>
</ol>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Post-Deletion Notes</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Some records may be retained where required for compliance, security, or legal obligations.
Deleted accounts typically cannot be restored.
</p>
</section>
</InfoLayout>
)
}

35
src/pages/PrivacyPage.tsx Normal file
View File

@ -0,0 +1,35 @@
import { InfoLayout } from "./legal/InfoLayout"
export function PrivacyPage() {
return (
<InfoLayout
title="Privacy Policy"
subtitle="This policy explains how Yimaru Academy handles personal, educational, and operational data across learning and administration workflows."
>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Data We Process</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
We process account information, course activity, assessment records, and administrative logs
to support platform functionality, progress reporting, and security monitoring.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">How Data Is Used</h2>
<ul className="list-disc space-y-2 pl-5 text-sm leading-relaxed text-grayScale-600">
<li>Deliver and personalize course experiences.</li>
<li>Track learner progress and learning outcomes.</li>
<li>Manage permissions, operational controls, and audit trails.</li>
</ul>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">Security and Retention</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Access controls, role-based restrictions, and logging are used to protect data integrity.
Data is retained according to organizational and legal requirements.
</p>
</section>
</InfoLayout>
)
}

View File

@ -1,35 +1,23 @@
import { useEffect, useState } from "react"; import { useEffect, useState, type ChangeEvent } from "react";
import { import { BadgeCheck, Briefcase, CalendarDays, Mail, Phone, Shield, User } from "lucide-react";
Calendar,
CheckCircle2,
Clock,
Globe,
Loader2,
Mail,
MapPin,
Pencil,
Phone,
Save,
Shield,
User,
X,
XCircle,
Briefcase,
BookOpen,
Target,
Languages,
Heart,
MessageCircle,
} from "lucide-react";
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 } from "../components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Select } from "../components/ui/select"; import { Textarea } from "../components/ui/textarea";
import { FileUpload } from "../components/ui/file-upload";
import { cn } from "../lib/utils"; import { getMyProfile } from "../api/users.api";
import { getMyProfile, updateProfile } from "../api/users.api"; import { updateTeamMember } from "../api/team.api";
import type { UserProfileData, UpdateProfileRequest } from "../types/user.types"; import { uploadImageFile } from "../api/files.api";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import type { UpdateTeamMemberRequest } from "../types/team.types";
import { toast } from "sonner"; import { toast } from "sonner";
function formatDate(dateStr: string | null | undefined): string { function formatDate(dateStr: string | null | undefined): string {
@ -52,6 +40,28 @@ function formatDateTime(dateStr: string | null | undefined): string {
}); });
} }
interface TeamMeProfile {
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
emergency_contact?: string
work_phone?: string
profile_picture_url?: string
}
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="w-full space-y-8 py-10"> <div className="w-full space-y-8 py-10">
@ -89,103 +99,26 @@ function LoadingSkeleton() {
); );
} }
function VerifiedIcon({ verified }: { verified: boolean }) {
return verified ? (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-mint-100">
<CheckCircle2 className="h-3.5 w-3.5 text-mint-500" />
</div>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-grayScale-100">
<XCircle className="h-3.5 w-3.5 text-grayScale-300" />
</div>
);
}
function ProgressRing({ percent }: { percent: number }) {
const radius = 18;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percent / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center">
<svg className="h-12 w-12 -rotate-90" viewBox="0 0 44 44">
<circle
cx="22"
cy="22"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className="text-grayScale-200"
/>
<circle
cx="22"
cy="22"
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className="text-brand-500 transition-all duration-700"
/>
</svg>
<span className="absolute text-[10px] font-bold text-brand-600">{percent}%</span>
</div>
);
}
function DetailItem({
icon: Icon,
label,
value,
extra,
editNode,
editing,
}: {
icon: typeof User;
label: string;
value: string;
extra?: React.ReactNode;
editNode?: React.ReactNode;
editing?: boolean;
}) {
return (
<div className="group flex items-start gap-3 rounded-xl px-3 py-3 transition-colors hover:bg-grayScale-50/80">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100/80 text-grayScale-400 transition-colors group-hover:bg-brand-50 group-hover:text-brand-500">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
{label}
</p>
{editing && editNode ? (
<div className="mt-1">{editNode}</div>
) : (
<div className="mt-0.5 flex items-center gap-2">
<p className="truncate text-sm font-medium text-grayScale-700">{value || "—"}</p>
{extra}
</div>
)}
</div>
</div>
);
}
export function ProfilePage() { export function ProfilePage() {
const [profile, setProfile] = useState<UserProfileData | null>(null); const [profile, setProfile] = useState<TeamMeProfile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [editForm, setEditForm] = useState<UpdateProfileRequest>({}); const [profilePictureFile, setProfilePictureFile] = useState<File | null>(null);
const [editForm, setEditForm] = useState<UpdateTeamMemberRequest>({
first_name: "",
last_name: "",
phone_number: "",
profile_picture_url: "",
bio: "",
});
useEffect(() => { useEffect(() => {
const fetchProfile = async () => { const fetchProfile = async () => {
try { try {
const res = await getMyProfile(); const res = await getMyProfile();
setProfile(res.data.data); setProfile((res.data?.data ?? null) as unknown as TeamMeProfile | null);
} catch (err) { } catch (err) {
console.error("Failed to fetch profile", err); console.error("Failed to fetch profile", err);
setError("Failed to load profile. Please try again later."); setError("Failed to load profile. Please try again later.");
@ -198,52 +131,68 @@ export function ProfilePage() {
const startEditing = () => { const startEditing = () => {
if (!profile) return; if (!profile) return;
setEditForm({ const nextForm: UpdateTeamMemberRequest = {
first_name: profile.first_name ?? "", first_name: profile.first_name ?? "",
last_name: profile.last_name ?? "", last_name: profile.last_name ?? "",
nick_name: profile.nick_name ?? "", phone_number: profile.phone_number ?? "",
gender: profile.gender ?? "", profile_picture_url: profile.profile_picture_url ?? "",
birth_day: profile.birth_day ?? "", bio: profile.bio ?? "",
age_group: profile.age_group ?? "", };
education_level: profile.education_level ?? "", setEditForm(nextForm);
country: profile.country ?? "", setProfilePictureFile(null);
region: profile.region ?? "",
occupation: profile.occupation ?? "",
learning_goal: profile.learning_goal ?? "",
language_goal: profile.language_goal ?? "",
language_challange: profile.language_challange ?? "",
favoutite_topic: profile.favoutite_topic ?? "",
preferred_language: profile.preferred_language ?? "",
});
setEditing(true); setEditing(true);
}; };
const cancelEditing = () => { const cancelEditing = () => {
setProfilePictureFile(null);
setEditing(false); setEditing(false);
setEditForm({}); };
const updateField = (field: keyof UpdateTeamMemberRequest, value: string) => {
setEditForm((prev) => ({ ...prev, [field]: value }));
}; };
const handleSave = async () => { const handleSave = async () => {
if (!profile) return;
let nextProfilePictureUrl = editForm.profile_picture_url ?? "";
if (profilePictureFile) {
try {
const uploadRes = await uploadImageFile(profilePictureFile);
const uploadedUrl = uploadRes.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Missing uploaded image url");
nextProfilePictureUrl = uploadedUrl;
} catch (err) {
console.error("Failed to upload profile picture:", err);
toast.error("Failed to upload profile picture");
return;
}
}
const payload: UpdateTeamMemberRequest = {
bio: editForm.bio ?? "",
first_name: editForm.first_name ?? "",
last_name: editForm.last_name ?? "",
phone_number: editForm.phone_number ?? "",
profile_picture_url: nextProfilePictureUrl,
};
setSaving(true); setSaving(true);
try { try {
await updateProfile(editForm); await updateTeamMember(profile.id, payload);
const res = await getMyProfile(); const refreshed = await getMyProfile();
setProfile(res.data.data); setProfile((refreshed.data?.data ?? null) as unknown as TeamMeProfile | null);
setEditing(false); setEditing(false);
setEditForm({}); setProfilePictureFile(null);
toast.success("Profile updated successfully"); toast.success("Profile updated successfully");
} catch (err) { } catch (err) {
console.error("Failed to update profile", err); console.error("Failed to update team member profile", err);
toast.error("Failed to update profile. Please try again."); toast.error("Failed to update profile");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const updateField = (field: keyof UpdateProfileRequest, value: string) => {
setEditForm((prev) => ({ ...prev, [field]: value }));
};
if (loading) return <LoadingSkeleton />; if (loading) return <LoadingSkeleton />;
if (error || !profile) { if (error || !profile) {
@ -269,502 +218,216 @@ export function ProfilePage() {
} }
const fullName = `${profile.first_name} ${profile.last_name}`; const fullName = `${profile.first_name} ${profile.last_name}`;
const completionPct = profile.profile_completion_percentage ?? 0; const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}`.toUpperCase();
return ( return (
<div className="mx-auto w-full max-w-7xl space-y-6 pb-8"> <div className="mx-auto w-full max-w-7xl rounded-2xl bg-[#f7f1f8] p-4 pb-8 sm:p-6">
{/* ─── Hero Card ─── */} <div className="overflow-hidden rounded-2xl border border-[#d9bddb] bg-white">
<div className="relative overflow-hidden rounded-3xl border border-grayScale-100 bg-white shadow-sm ring-1 ring-black/5"> <div className="h-40 w-full bg-gradient-to-r from-[#d6aed6] via-[#e4cce4] to-[#cba0cd]" />
{/* Tall dark gradient banner with content inside */}
<div className="relative flex min-h-[220px] flex-col justify-between bg-gradient-to-br from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-8 sm:px-8">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.08),transparent_60%)]" />
<div className="relative z-10 space-y-2"> <div className="grid gap-0 lg:grid-cols-[280px_minmax(0,1fr)]">
<h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl"> <aside className="border-r border-[#eadbea] bg-white px-5 pb-6">
Hello {profile.first_name} <div className="-mt-16">
</h2> <div className="flex h-28 w-28 items-center justify-center rounded-full border-4 border-white bg-[#d6aed6] text-2xl font-bold text-[#6f2aa8]">
<p className="max-w-2xl text-sm leading-relaxed text-white/70"> {initials}
Track your account status, keep profile details up to date, and manage your learning preferences from one place. </div>
</p> <h2 className="mt-3 text-2xl font-bold text-grayScale-700">{fullName}</h2>
<div className="mt-4 flex flex-wrap items-center gap-2"> <p className="text-sm text-grayScale-400">{profile.job_title || "Team Member"}</p>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
<Shield className="h-3.5 w-3.5" />
{profile.role}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
<Clock className="h-3.5 w-3.5" />
Last login {formatDate(profile.last_login)}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
<Target className="h-3.5 w-3.5" />
{completionPct}% complete
</span>
</div> </div>
</div>
<div className="relative z-10 mt-6"> <div className="mt-4">
{!editing ? ( <div className="flex w-full items-center justify-between rounded-lg border border-[#d9bddb] bg-[#f4e8f4] px-3 py-2">
<Button <span className="text-sm font-medium text-[#6f2aa8]">Account Status</span>
variant="outline" <span className="text-sm font-semibold uppercase text-[#5e2390]">{profile.status}</span>
size="sm" </div>
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs font-medium text-white shadow-sm backdrop-blur-sm hover:bg-white/20 hover:text-white" </div>
onClick={startEditing}
> <div className="mt-5 space-y-5 text-sm">
<Pencil className="h-3.5 w-3.5" /> <section>
Edit Profile <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">About</p>
</Button> <div className="space-y-2 text-grayScale-600">
) : ( <div className="flex items-center gap-2"><Briefcase className="h-4 w-4 text-[#6f2aa8]" />{profile.job_title || "Job title not set"}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2"><Shield className="h-4 w-4 text-[#6f2aa8]" />{profile.team_role || "Role not set"}</div>
<Button <div className="flex items-center gap-2"><CalendarDays className="h-4 w-4 text-[#6f2aa8]" />Hire date: {formatDate(profile.hire_date)}</div>
variant="outline" <div className="flex items-center gap-2"><BadgeCheck className="h-4 w-4 text-[#6f2aa8]" />{profile.department || "Department not set"}</div>
size="sm" </div>
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/20 hover:text-white" </section>
onClick={cancelEditing}
disabled={saving} <section>
> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Contact</p>
<X className="h-3.5 w-3.5" /> <div className="space-y-2 text-grayScale-600">
Cancel <div className="flex items-center gap-2"><Mail className="h-4 w-4 text-[#6f2aa8]" />{profile.email}</div>
</Button> <div className="flex items-center gap-2"><Phone className="h-4 w-4 text-[#6f2aa8]" />{profile.phone_number || "—"}</div>
<Button </div>
size="sm" </section>
className="h-8 gap-1.5 bg-white text-xs text-[#1a1f4e] hover:bg-white/90"
onClick={handleSave} <section>
disabled={saving} <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Access</p>
> <p className="text-xs text-grayScale-500">Permissions from `/team/me`</p>
{saving ? ( <div className="mt-2 flex flex-wrap gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {(profile.permissions ?? []).length === 0 ? (
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">No permissions listed</Badge>
) : ( ) : (
<Save className="h-3.5 w-3.5" /> profile.permissions.map((permission) => (
<Badge key={permission} className="bg-[#ecd9ec] text-[#6f2aa8]">
{permission}
</Badge>
))
)} )}
{saving ? "Saving…" : "Save Changes"} </div>
</Button> </section>
</div>
)}
</div>
</div>
{/* Identity info below banner */}
<div className="bg-gradient-to-b from-white to-grayScale-50/40 px-6 py-5 sm:px-8">
{editing ? (
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-9 w-40 text-sm font-semibold"
value={editForm.first_name ?? ""}
onChange={(e) => updateField("first_name", e.target.value)}
placeholder="First name"
/>
<Input
className="h-9 w-40 text-sm font-semibold"
value={editForm.last_name ?? ""}
onChange={(e) => updateField("last_name", e.target.value)}
placeholder="Last name"
/>
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
#{profile.id}
</span>
</div> </div>
) : ( </aside>
<div className="flex flex-wrap items-center gap-2.5">
<h2 className="text-xl font-bold tracking-tight text-grayScale-800 sm:text-2xl">
{fullName}
</h2>
{profile.nick_name && (
<span className="text-sm font-medium text-grayScale-400">
@{profile.nick_name}
</span>
)}
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
#{profile.id}
</span>
</div>
)}
{/* Badges row */} <main className="bg-[#fdf8fd] px-5 py-6 sm:px-7">
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="space-y-5">
<Badge <Card className="border-[#d9bddb] bg-white shadow-none">
className={cn( <CardContent className="p-5">
"px-2.5 py-0.5 text-xs font-semibold", <div className="mb-3 flex items-center justify-between">
profile.role === "ADMIN" <h3 className="text-base font-semibold text-grayScale-700">Summary</h3>
? "bg-brand-500/10 text-brand-600 border border-brand-500/20" <button
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200", type="button"
)} onClick={startEditing}
> className="inline-flex items-center rounded-md border border-[#d9bddb] bg-[#f4e8f4] px-3 py-1.5 text-sm font-medium text-[#6f2aa8] transition-colors hover:bg-[#ecd9ec] hover:text-[#5e2390] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#c39bd4]"
<Shield className="mr-1 h-3 w-3" /> >
{profile.role} Edit profile
</Badge> </button>
<span </div>
className={cn( <div className="space-y-2 text-sm text-grayScale-600">
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold", <div className="flex items-center gap-2"><Briefcase className="h-4 w-4 text-[#6f2aa8]" />{profile.job_title || "Role-focused work item"}</div>
profile.status === "ACTIVE" <div className="flex items-center gap-2"><Shield className="h-4 w-4 text-[#6f2aa8]" />{profile.team_role || "Team responsibility"}</div>
? "bg-mint-50 text-mint-600" <div className="flex items-center gap-2"><Mail className="h-4 w-4 text-[#6f2aa8]" />{profile.email}</div>
: "bg-destructive/10 text-destructive", <div className="flex items-center gap-2"><Phone className="h-4 w-4 text-[#6f2aa8]" />{profile.phone_number || "No phone number"}</div>
)} </div>
> </CardContent>
<span </Card>
className={cn(
"h-1.5 w-1.5 rounded-full", <Card className="border-[#d9bddb] bg-white shadow-none">
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive", <CardContent className="p-5">
)} <h3 className="mb-3 text-base font-semibold text-grayScale-700">Employment</h3>
/> <div className="rounded-lg border border-[#dcc3df] bg-[#f4e8f4] p-3">
{profile.status} <p className="text-sm font-medium text-grayScale-700">{profile.department || "Department not set"}</p>
</span> <p className="text-xs text-grayScale-500">Employment type: {profile.employment_type || "—"}</p>
<span className="inline-flex items-center gap-1.5 rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500"> </div>
<Calendar className="h-3 w-3" /> </CardContent>
Joined {formatDate(profile.created_at)} </Card>
</span>
</div> <Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="p-5">
<h3 className="mb-3 text-base font-semibold text-grayScale-700">More about me</h3>
<div className="flex flex-wrap gap-2">
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Status {profile.status}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Email {profile.email_verified ? "verified" : "not verified"}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Joined {formatDate(profile.created_at)}</Badge>
<Badge className="bg-[#ecd9ec] text-[#6f2aa8]">Last login {formatDateTime(profile.last_login)}</Badge>
</div>
</CardContent>
</Card>
<Card className="border-[#d9bddb] bg-white shadow-none">
<CardContent className="grid gap-3 p-5 sm:grid-cols-2">
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">First Name</p>
<p className="text-sm font-medium text-grayScale-700">{profile.first_name}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Last Name</p>
<p className="text-sm font-medium text-grayScale-700">{profile.last_name}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Department</p>
<p className="text-sm font-medium text-grayScale-700">{profile.department || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Team Role</p>
<p className="text-sm font-medium text-grayScale-700">{profile.team_role || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Job Title</p>
<p className="text-sm font-medium text-grayScale-700">{profile.job_title || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Hire Date</p>
<p className="text-sm font-medium text-grayScale-700">{formatDate(profile.hire_date) || "—"}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-grayScale-500">Phone Number</p>
<p className="text-sm font-medium text-grayScale-700">{profile.phone_number || "—"}</p>
</div>
</CardContent>
</Card>
</div>
</main>
</div> </div>
</div> </div>
<Dialog open={editing} onOpenChange={(open) => !saving && setEditing(open)}>
<DialogContent className="max-h-[88vh] overflow-y-auto border-[#d9bddb] bg-[#fdf8fd] sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="text-[#6f2aa8]">Edit profile</DialogTitle>
</DialogHeader>
{/* ─── Detail Cards Grid ─── */} <div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-6 lg:grid-cols-3"> <div>
{/* ── Contact & Personal ── */} <p className="mb-1 text-xs font-medium text-grayScale-500">First Name</p>
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md lg:col-span-2"> <Input value={editForm.first_name ?? ""} onChange={(e) => updateField("first_name", e.target.value)} />
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-400" /> </div>
<CardContent className="p-0"> <div>
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0"> <p className="mb-1 text-xs font-medium text-grayScale-500">Last Name</p>
{/* Contact */} <Input value={editForm.last_name ?? ""} onChange={(e) => updateField("last_name", e.target.value)} />
<div className="p-5"> </div>
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400"> <div>
Contact <p className="mb-1 text-xs font-medium text-grayScale-500">Phone Number</p>
</p> <Input value={editForm.phone_number ?? ""} onChange={(e) => updateField("phone_number", e.target.value)} />
<div className="space-y-1"> </div>
<DetailItem <div>
icon={Mail} <p className="mb-1 text-xs font-medium text-grayScale-500">Profile Picture</p>
label="Email" <div className="space-y-2">
value={profile.email} <FileUpload
extra={<VerifiedIcon verified={profile.email_verified} />} accept="image/*"
/> onFileSelect={setProfilePictureFile}
<DetailItem label="Upload profile picture"
icon={Phone} description="JPEG, PNG, WEBP"
label="Phone" className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-200 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
value={profile.phone_number} />
extra={<VerifiedIcon verified={profile.phone_verified} />} <Input
/> value={editForm.profile_picture_url ?? ""}
<DetailItem onChange={(e) => updateField("profile_picture_url", e.target.value)}
icon={MapPin} placeholder="Or paste image URL (https://...)"
label="Location" />
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
editing={editing}
editNode={
<div className="flex gap-2">
<Input
className="h-8 text-sm"
value={editForm.region ?? ""}
onChange={(e) => updateField("region", e.target.value)}
placeholder="Region"
/>
<Input
className="h-8 text-sm"
value={editForm.country ?? ""}
onChange={(e) => updateField("country", e.target.value)}
placeholder="Country"
/>
</div>
}
/>
<DetailItem
icon={Globe}
label="Preferred Language"
value={
{ en: "English", am: "Amharic", or: "Oromiffa", ti: "Tigrinya" }[
profile.preferred_language
] ?? profile.preferred_language ?? "—"
}
editing={editing}
editNode={
<Select
className="h-8 text-sm"
value={editForm.preferred_language ?? ""}
onChange={(e) => updateField("preferred_language", e.target.value)}
>
<option value="">Select</option>
<option value="en">English</option>
<option value="am">Amharic</option>
<option value="or">Oromiffa</option>
<option value="ti">Tigrinya</option>
</Select>
}
/>
</div>
</div>
{/* Personal */}
<div className="p-5">
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Personal
</p>
<div className="space-y-1">
<DetailItem
icon={Calendar}
label="Date of Birth"
value={formatDate(profile.birth_day)}
editing={editing}
editNode={
<Input
type="date"
className="h-8 text-sm"
value={editForm.birth_day ?? ""}
onChange={(e) => updateField("birth_day", e.target.value)}
/>
}
/>
<DetailItem
icon={User}
label="Gender"
value={profile.gender || "Not specified"}
editing={editing}
editNode={
<Select
className="h-8 text-sm"
value={editForm.gender ?? ""}
onChange={(e) => updateField("gender", e.target.value)}
>
<option value="">Select</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
</Select>
}
/>
<DetailItem
icon={User}
label="Age Group"
value={profile.age_group?.replace("_", "") || "—"}
editing={editing}
editNode={
<Select
className="h-8 text-sm"
value={editForm.age_group ?? ""}
onChange={(e) => updateField("age_group", e.target.value)}
>
<option value="">Select</option>
<option value="18_24">1824</option>
<option value="25_34">2534</option>
<option value="35_44">3544</option>
<option value="45_54">4554</option>
<option value="55_64">5564</option>
<option value="65+">65+</option>
</Select>
}
/>
<DetailItem
icon={Briefcase}
label="Occupation"
value={profile.occupation || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.occupation ?? ""}
onChange={(e) => updateField("occupation", e.target.value)}
placeholder="Occupation"
/>
}
/>
<DetailItem
icon={BookOpen}
label="Education"
value={profile.education_level || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.education_level ?? ""}
onChange={(e) => updateField("education_level", e.target.value)}
placeholder="Education level"
/>
}
/>
</div>
</div> </div>
</div> </div>
</CardContent> <div className="sm:col-span-2">
</Card> <p className="mb-1 text-xs font-medium text-grayScale-500">Bio</p>
<Textarea
{/* ── Right Sidebar ── */} value={editForm.bio ?? ""}
<div className="space-y-6 lg:sticky lg:top-24 lg:self-start"> onChange={(e: ChangeEvent<HTMLTextAreaElement>) => updateField("bio", e.target.value)}
{/* Profile Completion */} rows={4}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
<div className="h-1 bg-gradient-to-r from-brand-400 to-mint-400" />
<CardContent className="flex items-center gap-4 p-5">
<ProgressRing percent={completionPct} />
<div>
<p className="text-sm font-semibold text-grayScale-700">Profile Completion</p>
<p className="mt-0.5 text-xs text-grayScale-400">
{completionPct === 100 ? "All set!" : "Complete your profile for the best experience."}
</p>
</div>
</CardContent>
</Card>
{/* Activity */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
<div className="h-1 bg-gradient-to-r from-grayScale-300 to-grayScale-200" />
<CardContent className="space-y-4 p-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Activity
</p>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-50 text-brand-500">
<Clock className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-600">Last Login</p>
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.last_login)}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-grayScale-50 text-grayScale-400">
<User className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-600">Account Created</p>
<p className="text-[11px] text-grayScale-400">{formatDateTime(profile.created_at)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Quick Account Info */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-600" />
<CardContent className="space-y-3 p-5">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Account
</p>
<div className="space-y-2.5">
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Role</span>
<Badge
className={cn(
"text-[10px] font-semibold",
profile.role === "ADMIN"
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
)}
>
{profile.role}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Status</span>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs font-semibold",
profile.status === "ACTIVE" ? "text-mint-600" : "text-destructive",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
)}
/>
{profile.status}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Email</span>
<span className="flex items-center gap-1">
<span className="max-w-[130px] truncate text-xs text-grayScale-600">
{profile.email}
</span>
<VerifiedIcon verified={profile.email_verified} />
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-grayScale-400">Phone</span>
<span className="flex items-center gap-1">
<span className="max-w-[110px] truncate text-xs text-grayScale-600">
{profile.phone_number || "—"}
</span>
<VerifiedIcon verified={profile.phone_verified} />
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* ─── Learning & Goals Card ─── */}
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
<div className="h-1 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
<div className="border-b border-grayScale-100 px-5 py-3">
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
Learning & Preferences
</p>
</div>
<CardContent className="p-0">
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-4 lg:divide-x lg:divide-y-0">
<div className="p-5">
<DetailItem
icon={Target}
label="Learning Goal"
value={profile.learning_goal || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.learning_goal ?? ""}
onChange={(e) => updateField("learning_goal", e.target.value)}
placeholder="Your learning goal"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={Languages}
label="Language Goal"
value={profile.language_goal || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.language_goal ?? ""}
onChange={(e) => updateField("language_goal", e.target.value)}
placeholder="Language goal"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={MessageCircle}
label="Language Challenge"
value={profile.language_challange || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.language_challange ?? ""}
onChange={(e) => updateField("language_challange", e.target.value)}
placeholder="Language challenge"
/>
}
/>
</div>
<div className="p-5">
<DetailItem
icon={Heart}
label="Favourite Topic"
value={profile.favoutite_topic || "—"}
editing={editing}
editNode={
<Input
className="h-8 text-sm"
value={editForm.favoutite_topic ?? ""}
onChange={(e) => updateField("favoutite_topic", e.target.value)}
placeholder="Favourite topic"
/>
}
/> />
</div> </div>
</div> </div>
</CardContent>
</Card> <DialogFooter className="mt-2">
<Button
type="button"
variant="outline"
className="border-[#d9bddb] text-[#6f2aa8]"
onClick={cancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
type="button"
className="bg-[#6f2aa8] text-white hover:bg-[#5e2390]"
onClick={handleSave}
disabled={saving}
>
{saving ? <SpinnerIcon className="mr-1 h-4 w-4" /> : null}
{saving ? "Saving..." : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -6,7 +6,6 @@ import {
Globe, Globe,
KeyRound, KeyRound,
Languages, Languages,
Loader2,
Lock, Lock,
Moon, Moon,
Palette, Palette,
@ -21,6 +20,7 @@ import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select"; import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator"; import { Separator } from "../components/ui/separator";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import { getMyProfile, updateProfile } from "../api/users.api"; import { getMyProfile, updateProfile } from "../api/users.api";
import type { UserProfileData } from "../types/user.types"; import type { UserProfileData } from "../types/user.types";
import { toast } from "sonner"; import { toast } from "sonner";
@ -212,7 +212,7 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]"> <Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
{saving ? ( {saving ? (
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" />
) : ( ) : (
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
)} )}
@ -298,7 +298,7 @@ function SecurityTab() {
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]"> <Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
{saving ? ( {saving ? (
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" />
) : ( ) : (
<Lock className="h-4 w-4" /> <Lock className="h-4 w-4" />
)} )}

34
src/pages/TermsPage.tsx Normal file
View File

@ -0,0 +1,34 @@
import { InfoLayout } from "./legal/InfoLayout"
export function TermsPage() {
return (
<InfoLayout
title="Terms and Conditions"
subtitle="These terms define acceptable use of Yimaru Academy services, administration interfaces, and related learning operations."
>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">1. Acceptable Use</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
The platform may only be used for legitimate education and administration workflows. Users
must not misuse access, attempt unauthorized data access, or disrupt service availability.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">2. Account Responsibility</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Account owners are responsible for protecting credentials and all actions performed through
their account. Shared accounts are discouraged unless explicitly authorized by policy.
</p>
</section>
<section className="space-y-3 rounded-xl border border-[#ece4f7] bg-gradient-to-br from-white to-[#fcf8ff] p-5 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-700">3. Service and Policy Updates</h2>
<p className="text-sm leading-relaxed text-grayScale-600">
Yimaru Academy may update features, controls, and policies to improve reliability and
security. Continued platform use indicates acceptance of updated terms.
</p>
</section>
</InfoLayout>
)
}

View File

@ -5,6 +5,7 @@ import { Eye, EyeOff } from "lucide-react";
import { BrandLogo } from "../../components/brand/BrandLogo"; import { BrandLogo } from "../../components/brand/BrandLogo";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import { login, loginWithGoogle } from "../../api/auth.api"; import { login, loginWithGoogle } from "../../api/auth.api";
@ -282,25 +283,7 @@ export function LoginPage() {
className="group mb-6 flex w-full items-center justify-center gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm font-medium text-grayScale-600 transition-all duration-200 hover:border-grayScale-300 hover:bg-grayScale-100 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40 disabled:cursor-not-allowed disabled:opacity-60" className="group mb-6 flex w-full items-center justify-center gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm font-medium text-grayScale-600 transition-all duration-200 hover:border-grayScale-300 hover:bg-grayScale-100 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40 disabled:cursor-not-allowed disabled:opacity-60"
> >
{googleLoading ? ( {googleLoading ? (
<svg <SpinnerIcon className="h-5 w-5" />
className="h-5 w-5 animate-spin text-grayScale-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : ( ) : (
<GoogleIcon className="h-5 w-5 transition-transform duration-200 group-hover:scale-110" /> <GoogleIcon className="h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
)} )}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Search, Plus, RefreshCw, Edit2, ToggleLeft, ToggleRight } from "lucide-react" import { Search, Plus, RefreshCw, Edit2, ToggleLeft, ToggleRight, BookOpen, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -15,7 +15,8 @@ import {
} from "../../components/ui/table" } from "../../components/ui/table"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { FileUpload } from "../../components/ui/file-upload" import { FileUpload } from "../../components/ui/file-upload"
import { getCourseCategories, getCoursesByCategory, createCourse, updateCourseStatus, updateCourse } from "../../api/courses.api" import { getCourseCategories, getCoursesByCategory, createCourse, updateCourseStatus, updateCourse, updateCourseThumbnail } from "../../api/courses.api"
import { uploadImageFile } from "../../api/files.api"
import type { Course, CourseCategory } from "../../types/course.types" import type { Course, CourseCategory } from "../../types/course.types"
import { import {
Dialog, Dialog,
@ -26,6 +27,8 @@ import {
} from "../../components/ui/dialog" } from "../../components/ui/dialog"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
type CourseWithCategory = Course & { category_name: string } type CourseWithCategory = Course & { category_name: string }
@ -53,6 +56,8 @@ export function AllCoursesPage() {
const [editTitle, setEditTitle] = useState("") const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("") const [editDescription, setEditDescription] = useState("")
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const fetchAllCourses = async () => { const fetchAllCourses = async () => {
setLoading(true) setLoading(true)
@ -97,6 +102,26 @@ export function AllCoursesPage() {
} }
return true return true
}) })
const totalCount = filteredCourses.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages)
const paginatedCourses = filteredCourses.slice((safePage - 1) * pageSize, safePage * pageSize)
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
const handleCreateCourse = async () => { const handleCreateCourse = async () => {
const effectiveCategoryId = createSubCategoryId || createCategoryId const effectiveCategoryId = createSubCategoryId || createCategoryId
@ -110,12 +135,27 @@ export function AllCoursesPage() {
setCreating(true) setCreating(true)
try { try {
await createCourse({ const createdRes = await createCourse({
category_id: Number(effectiveCategoryId), category_id: Number(effectiveCategoryId),
title: createTitle.trim(), title: createTitle.trim(),
description: createDescription.trim(), description: createDescription.trim(),
}) })
const createdIdRaw =
(createdRes.data?.data as any)?.id ??
(createdRes.data?.data as any)?.course?.id ??
(createdRes.data?.data as any)?.data?.id
const createdId = Number(createdIdRaw)
if (createThumbnail && Number.isFinite(createdId)) {
const uploadRes = await uploadImageFile(createThumbnail)
const thumbnailUrl = uploadRes.data?.data?.url?.trim()
if (thumbnailUrl) {
await updateCourseThumbnail(createdId, thumbnailUrl)
}
}
toast.success("Sub-category created", { toast.success("Sub-category created", {
description: `"${createTitle.trim()}" has been created.`, description: `"${createTitle.trim()}" has been created.`,
}) })
@ -191,7 +231,7 @@ export function AllCoursesPage() {
return ( return (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<div className="rounded-2xl bg-white shadow-sm p-6"> <div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" /> <SpinnerIcon className="h-10 w-10" />
</div> </div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories</p> <p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories</p>
</div> </div>
@ -244,14 +284,20 @@ export function AllCoursesPage() {
<Input <Input
placeholder="Search by title, description, or category…" placeholder="Search by title, description, or category…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => {
setSearch(e.target.value)
setPage(1)
}}
className="pl-10 transition-colors focus:border-brand-300 focus:ring-brand-200" className="pl-10 transition-colors focus:border-brand-300 focus:ring-brand-200"
/> />
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select <Select
value={categoryFilter} value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as typeof categoryFilter)} onChange={(e) => {
setCategoryFilter(e.target.value as typeof categoryFilter)
setPage(1)
}}
> >
<option value="all">All Categories</option> <option value="all">All Categories</option>
{categories.map((cat) => ( {categories.map((cat) => (
@ -269,31 +315,21 @@ export function AllCoursesPage() {
{/* Courses Table */} {/* Courses Table */}
{filteredCourses.length > 0 ? ( {filteredCourses.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-grayScale-200"> <div className="rounded-xl border bg-white">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100"> <TableRow>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500"> <TableHead>Course</TableHead>
Course <TableHead>Category</TableHead>
</TableHead> <TableHead className="hidden md:table-cell">Status</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500"> <TableHead className="text-right">Actions</TableHead>
Category
</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredCourses.map((course, index) => ( {paginatedCourses.map((course) => (
<TableRow <TableRow
key={course.id} key={course.id}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${ className="group cursor-pointer"
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
}`}
onClick={() => onClick={() =>
navigate( navigate(
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`, `/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
@ -369,6 +405,79 @@ export function AllCoursesPage() {
))} ))}
</TableBody> </TableBody>
</Table> </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>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows 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"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<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={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center"> <div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link, useParams } from "react-router-dom" import { Link, useParams } from "react-router-dom"
import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft, ArrowRight, ChevronRight } from "lucide-react" import { BookOpen, Mic, Briefcase, HelpCircle, ArrowLeft, ArrowRight, ChevronRight, Search } 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 { Input } from "../../components/ui/input"
import { getCourseCategories } from "../../api/courses.api" import { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types" import type { CourseCategory } from "../../types/course.types"
@ -63,6 +64,7 @@ export function ContentOverviewPage() {
const { categoryId } = useParams<{ categoryId: string }>() const { categoryId } = useParams<{ categoryId: string }>()
const [category, setCategory] = useState<CourseCategory | null>(null) const [category, setCategory] = useState<CourseCategory | null>(null)
const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections]) const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections])
const [searchQuery, setSearchQuery] = useState("")
const [dragKey, setDragKey] = useState<string | null>(null) const [dragKey, setDragKey] = useState<string | null>(null)
const [flowSteps, setFlowSteps] = useState< const [flowSteps, setFlowSteps] = useState<
{ {
@ -158,6 +160,13 @@ export function ContentOverviewPage() {
setDragKey(null) setDragKey(null)
} }
const filteredSections = sections.filter((section) => {
const q = searchQuery.trim().toLowerCase()
if (!q) return true
const haystack = `${section.title} ${section.description} ${section.action}`.toLowerCase()
return haystack.includes(q)
})
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header & Breadcrumb */} {/* Header & Breadcrumb */}
@ -185,6 +194,15 @@ export function ContentOverviewPage() {
{category?.name ?? "Content Management"} {category?.name ?? "Content Management"}
</h1> </h1>
</div> </div>
<div className="relative w-full max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search overview sections..."
className="pl-9"
/>
</div>
{/* Gradient Divider */} {/* Gradient Divider */}
<div className="relative"> <div className="relative">
@ -203,7 +221,7 @@ export function ContentOverviewPage() {
{/* Cards Grid (course builder style draggable sections) */} {/* Cards Grid (course builder style draggable sections) */}
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{sections.map((section) => { {filteredSections.map((section) => {
const Icon = section.icon const Icon = section.icon
return ( return (
<div <div
@ -274,6 +292,12 @@ export function ContentOverviewPage() {
) )
})} })}
</div> </div>
{sections.length > 0 && filteredSections.length === 0 && (
<div className="rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-14 text-center">
<p className="text-sm font-semibold text-grayScale-500">No matching sections</p>
<p className="mt-1 text-xs text-grayScale-400">Try a different search term.</p>
</div>
)}
{/* Category flow sequence (if defined) */} {/* Category flow sequence (if defined) */}
{flowSteps.length > 0 && ( {flowSteps.length > 0 && (
<Card className="shadow-soft"> <Card className="shadow-soft">

View File

@ -28,6 +28,7 @@ export function CourseCategoryPage() {
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null) const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
const [newSubCategoryName, setNewSubCategoryName] = useState("") const [newSubCategoryName, setNewSubCategoryName] = useState("")
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([]) const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState("")
const fetchCategories = async () => { const fetchCategories = async () => {
setLoading(true) setLoading(true)
@ -47,6 +48,11 @@ export function CourseCategoryPage() {
fetchCategories() fetchCategories()
}, []) }, [])
const normalizedQuery = searchQuery.trim().toLowerCase()
const filteredCategories = normalizedQuery
? categories.filter((c) => c.name?.toLowerCase().includes(normalizedQuery))
: categories
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center gap-4 py-24"> <div className="flex flex-col items-center justify-center gap-4 py-24">
@ -89,6 +95,14 @@ export function CourseCategoryPage() {
Browse and manage your course categories below Browse and manage your course categories below
</p> </p>
</div> </div>
<div className="w-full max-w-sm">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search categories..."
aria-label="Search categories"
/>
</div>
<Button <Button
className="gap-2 bg-brand-500 text-white hover:bg-brand-600" className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
size="sm" size="sm"
@ -116,9 +130,21 @@ export function CourseCategoryPage() {
</p> </p>
</div> </div>
</div> </div>
) : filteredCategories.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
<div className="grid h-20 w-20 place-items-center rounded-2xl bg-grayScale-50 shadow-sm">
<FolderOpen className="h-9 w-9 text-grayScale-300" />
</div>
<div className="text-center">
<p className="text-sm font-semibold text-grayScale-500">No matching categories</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-grayScale-400">
Try a different search term.
</p>
</div>
</div>
) : ( ) : (
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categories.map((category) => ( {filteredCategories.map((category) => (
<Link <Link
key={category.id} key={category.id}
to={`/content/category/${category.id}/courses`} to={`/content/category/${category.id}/courses`}

View File

@ -4,7 +4,6 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
GripVertical, GripVertical,
Loader2,
RefreshCw, RefreshCw,
Sparkles, Sparkles,
} from "lucide-react" } from "lucide-react"
@ -53,6 +52,7 @@ import type {
} from "../../types/course.types" } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
type PracticeListItem = LearningPathPractice & { display_order: number } type PracticeListItem = LearningPathPractice & { display_order: number }
@ -504,7 +504,7 @@ export function CourseFlowBuilderPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-24"> <div className="flex flex-col items-center justify-center py-24">
<RefreshCw className="h-8 w-8 animate-spin text-brand-500" /> <SpinnerIcon className="h-8 w-8" />
<p className="mt-3 text-sm text-grayScale-400">Loading learning tree...</p> <p className="mt-3 text-sm text-grayScale-400">Loading learning tree...</p>
</div> </div>
) )
@ -582,7 +582,7 @@ export function CourseFlowBuilderPage() {
</CardTitle> </CardTitle>
{savingKey && ( {savingKey && (
<span className="inline-flex items-center gap-1 text-xs text-brand-500"> <span className="inline-flex items-center gap-1 text-xs text-brand-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" />
Saving... Saving...
</span> </span>
)} )}
@ -618,7 +618,7 @@ export function CourseFlowBuilderPage() {
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Course sub-categories</p> <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Course sub-categories</p>
{loadingCourses ? ( {loadingCourses ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-brand-500" /> <SpinnerIcon className="h-5 w-5" />
</div> </div>
) : activeCourses.length === 0 ? ( ) : activeCourses.length === 0 ? (
<p className="rounded-lg border border-dashed border-grayScale-200 px-3 py-6 text-center text-xs text-grayScale-400"> <p className="rounded-lg border border-dashed border-grayScale-200 px-3 py-6 text-center text-xs text-grayScale-400">
@ -655,7 +655,7 @@ export function CourseFlowBuilderPage() {
<CardTitle className="text-base font-semibold text-grayScale-600">Learning path detail</CardTitle> <CardTitle className="text-base font-semibold text-grayScale-600">Learning path detail</CardTitle>
{savingKey && ( {savingKey && (
<span className="inline-flex items-center gap-1 text-xs text-brand-500"> <span className="inline-flex items-center gap-1 text-xs text-brand-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" />
Saving... Saving...
</span> </span>
)} )}
@ -668,7 +668,7 @@ export function CourseFlowBuilderPage() {
</p> </p>
) : loadingPath ? ( ) : loadingPath ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" /> <SpinnerIcon className="h-6 w-6" />
</div> </div>
) : !learningPath || learningPath.sub_courses.length === 0 ? ( ) : !learningPath || learningPath.sub_courses.length === 0 ? (
<p className="rounded-lg border border-dashed border-grayScale-200 px-3 py-10 text-center text-xs text-grayScale-400"> <p className="rounded-lg border border-dashed border-grayScale-200 px-3 py-10 text-center text-xs text-grayScale-400">
@ -774,7 +774,7 @@ export function CourseFlowBuilderPage() {
</p> </p>
{loadingPracticesBySubCourse[subCourse.id] ? ( {loadingPracticesBySubCourse[subCourse.id] ? (
<div className="flex items-center gap-2 py-6 text-xs text-grayScale-400"> <div className="flex items-center gap-2 py-6 text-xs text-grayScale-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" />
Loading sets... Loading sets...
</div> </div>
) : ( ) : (

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" import { Link, useParams, useNavigate } from "react-router-dom"
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare } from "lucide-react" import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
import practiceSrc from "../../assets/Practice.svg" import practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
@ -8,6 +8,7 @@ import alertSrc from "../../assets/Alert.svg"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { FileUpload } from "../../components/ui/file-upload"
import { import {
Table, Table,
TableBody, TableBody,
@ -16,13 +17,26 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table" } from "../../components/ui/table"
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse, getRatings } from "../../api/courses.api" import {
getCoursesByCategory,
getCourseCategories,
createCourse,
deleteCourse,
updateCourseStatus,
updateCourse,
updateCourseThumbnail,
getRatings,
} from "../../api/courses.api"
import { uploadImageFile } from "../../api/files.api"
import type { Course, CourseCategory, Rating } from "../../types/course.types" import type { Course, CourseCategory, Rating } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function CoursesPage() { export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>() const { categoryId } = useParams<{ categoryId: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [courses, setCourses] = useState<Course[]>([]) const [courses, setCourses] = useState<Course[]>([])
const [searchQuery, setSearchQuery] = useState("")
const [category, setCategory] = useState<CourseCategory | null>(null) const [category, setCategory] = useState<CourseCategory | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -41,12 +55,15 @@ export function CoursesPage() {
const [editTitle, setEditTitle] = useState("") const [editTitle, setEditTitle] = useState("")
const [editDescription, setEditDescription] = useState("") const [editDescription, setEditDescription] = useState("")
const [editThumbnail, setEditThumbnail] = useState("") const [editThumbnail, setEditThumbnail] = useState("")
const [editThumbnailFile, setEditThumbnailFile] = useState<File | null>(null)
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null) const [updateError, setUpdateError] = useState<string | null>(null)
const [showRatingsModal, setShowRatingsModal] = useState(false) const [showRatingsModal, setShowRatingsModal] = useState(false)
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null) const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
const [courseRatings, setCourseRatings] = useState<Rating[]>([]) const [courseRatings, setCourseRatings] = useState<Rating[]>([])
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false) const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const fetchCourses = async () => { const fetchCourses = async () => {
if (!categoryId) return if (!categoryId) return
@ -86,6 +103,10 @@ export function CoursesPage() {
fetchData() fetchData()
}, [categoryId]) }, [categoryId])
useEffect(() => {
setPage(1)
}, [categoryId, searchQuery])
const handleOpenModal = () => { const handleOpenModal = () => {
setTitle("") setTitle("")
setDescription("") setDescription("")
@ -167,6 +188,7 @@ export function CoursesPage() {
setEditTitle(course.title || "") setEditTitle(course.title || "")
setEditDescription(course.description || "") setEditDescription(course.description || "")
setEditThumbnail(course.thumbnail || "") setEditThumbnail(course.thumbnail || "")
setEditThumbnailFile(null)
setUpdateError(null) setUpdateError(null)
setShowEditModal(true) setShowEditModal(true)
} }
@ -177,6 +199,7 @@ export function CoursesPage() {
setEditTitle("") setEditTitle("")
setEditDescription("") setEditDescription("")
setEditThumbnail("") setEditThumbnail("")
setEditThumbnailFile(null)
setUpdateError(null) setUpdateError(null)
} }
@ -199,9 +222,18 @@ export function CoursesPage() {
await updateCourse(courseToEdit.id, { await updateCourse(courseToEdit.id, {
title: editTitle.trim(), title: editTitle.trim(),
description: editDescription.trim(), description: editDescription.trim(),
thumbnail: editThumbnail.trim() || undefined,
is_active: courseToEdit.is_active, is_active: courseToEdit.is_active,
}) })
const thumbnailUrl =
editThumbnailFile
? (await uploadImageFile(editThumbnailFile)).data?.data?.url?.trim()
: editThumbnail.trim() || ""
if (thumbnailUrl) {
await updateCourseThumbnail(courseToEdit.id, thumbnailUrl)
}
handleCloseEditModal() handleCloseEditModal()
await fetchCourses() await fetchCourses()
} catch (err: any) { } catch (err: any) {
@ -230,14 +262,19 @@ export function CoursesPage() {
} }
} }
const filteredCourses = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return courses
return courses.filter((course) => {
const haystack = `${course.title} ${course.description ?? ""} ${course.id}`.toLowerCase()
return haystack.includes(q)
})
}, [courses, searchQuery])
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" /> <img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
{/* <div className="rounded-2xl bg-white shadow-sm p-6">
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading courses...</p> */}
</div> </div>
) )
} }
@ -253,6 +290,27 @@ export function CoursesPage() {
) )
} }
const totalCount = filteredCourses.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages)
const paginatedCourses = filteredCourses.slice((safePage - 1) * pageSize, safePage * pageSize)
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@ -284,9 +342,20 @@ export function CoursesPage() {
{/* Course table or empty state */} {/* Course table or empty state */}
<Card className="shadow-soft"> <Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-3"> <CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-base font-semibold text-grayScale-600"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
Sub-category Management <CardTitle className="text-base font-semibold text-grayScale-600">
</CardTitle> Sub-category Management
</CardTitle>
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search sub-categories..."
className="pl-9"
/>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent className="pt-4"> <CardContent className="pt-4">
{courses.length === 0 ? ( {courses.length === 0 ? (
@ -305,29 +374,28 @@ export function CoursesPage() {
Add your first sub-category Add your first sub-category
</Button> </Button>
</div> </div>
) : filteredCourses.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
<p className="text-base font-semibold text-grayScale-600">No matching sub-categories</p>
<p className="mt-1.5 text-sm text-grayScale-400">
Try a different search term.
</p>
</div>
) : ( ) : (
<div className="overflow-x-auto rounded-lg border border-grayScale-200"> <div className="rounded-xl border bg-white">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100"> <TableRow>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500"> <TableHead>Sub-category</TableHead>
Sub-category <TableHead className="hidden md:table-cell">Status</TableHead>
</TableHead> <TableHead className="text-right">Actions</TableHead>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
Status
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{courses.map((course, index) => ( {paginatedCourses.map((course) => (
<TableRow <TableRow
key={course.id} key={course.id}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${ className="group cursor-pointer"
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
}`}
onClick={() => handleCourseClick(course.id)} onClick={() => handleCourseClick(course.id)}
> >
<TableCell className="max-w-md py-3.5"> <TableCell className="max-w-md py-3.5">
@ -405,6 +473,79 @@ export function CoursesPage() {
))} ))}
</TableBody> </TableBody>
</Table> </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>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows 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"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<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={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div> </div>
)} )}
</CardContent> </CardContent>
@ -544,14 +685,23 @@ export function CoursesPage() {
htmlFor="edit-course-thumbnail" htmlFor="edit-course-thumbnail"
className="mb-2 block text-sm font-medium text-grayScale-600" className="mb-2 block text-sm font-medium text-grayScale-600"
> >
Thumbnail URL Thumbnail
</label> </label>
<Input <div className="space-y-2">
id="edit-course-thumbnail" <FileUpload
placeholder="Enter thumbnail URL (e.g., https://example.com/image.jpg)" accept="image/*"
value={editThumbnail} onFileSelect={(file) => setEditThumbnailFile(file)}
onChange={(e) => setEditThumbnail(e.target.value)} label="Upload thumbnail"
/> description="JPEG, PNG, WEBP"
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<Input
id="edit-course-thumbnail"
placeholder="Or paste thumbnail URL (https://...)"
value={editThumbnail}
onChange={(e) => setEditThumbnail(e.target.value)}
/>
</div>
</div> </div>
</div> </div>
@ -592,7 +742,7 @@ export function CoursesPage() {
<div className="max-h-[70vh] overflow-y-auto px-6 py-6"> <div className="max-h-[70vh] overflow-y-auto px-6 py-6">
{courseRatingsLoading ? ( {courseRatingsLoading ? (
<div className="flex flex-col items-center justify-center py-16"> <div className="flex flex-col items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" /> <SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p> <p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p>
</div> </div>
) : courseRatings.length === 0 ? ( ) : courseRatings.length === 0 ? (

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCw } from "lucide-react" import { ChevronDown, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -16,6 +17,8 @@ import { Badge } from "../../components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../components/ui/dialog"
import { getQuestionSetById, getQuestionSets } from "../../api/courses.api" import { getQuestionSetById, getQuestionSets } from "../../api/courses.api"
import type { QuestionSet, QuestionSetDetail } from "../../types/course.types" import type { QuestionSet, QuestionSetDetail } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
const statusColor: Record<string, string> = { const statusColor: Record<string, string> = {
PUBLISHED: "bg-green-100 text-green-700", PUBLISHED: "bg-green-100 text-green-700",
@ -33,6 +36,8 @@ export function PracticeDetailsPage() {
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all") const [statusFilter, setStatusFilter] = useState("all")
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all") const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const fetchPractices = useCallback(async () => { const fetchPractices = useCallback(async () => {
setLoadingList(true) setLoadingList(true)
@ -89,6 +94,10 @@ export function PracticeDetailsPage() {
} }
}, [selectedPracticeId, fetchPracticeDetail]) }, [selectedPracticeId, fetchPracticeDetail])
useEffect(() => {
setPage(1)
}, [searchQuery, statusFilter, ownerTypeFilter])
const filteredPractices = useMemo(() => { const filteredPractices = useMemo(() => {
return practices.filter((practice) => { return practices.filter((practice) => {
const matchesSearch = const matchesSearch =
@ -104,6 +113,28 @@ export function PracticeDetailsPage() {
}, [practices, searchQuery, statusFilter, ownerTypeFilter]) }, [practices, searchQuery, statusFilter, ownerTypeFilter])
const totalCount = useMemo(() => filteredPractices.length, [filteredPractices]) const totalCount = useMemo(() => filteredPractices.length, [filteredPractices])
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const safePage = Math.min(page, totalPages)
const paginatedPractices = useMemo(() => {
const start = (safePage - 1) * pageSize
return filteredPractices.slice(start, start + pageSize)
}, [filteredPractices, safePage, pageSize])
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -115,7 +146,7 @@ export function PracticeDetailsPage() {
</p> </p>
</div> </div>
<Button variant="outline" onClick={fetchPractices} disabled={loadingList}> <Button variant="outline" onClick={fetchPractices} disabled={loadingList}>
<RefreshCw className={`h-4 w-4 ${loadingList ? "animate-spin" : ""}`} /> {loadingList ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
Refresh Refresh
</Button> </Button>
</div> </div>
@ -153,6 +184,7 @@ export function PracticeDetailsPage() {
setSearchQuery("") setSearchQuery("")
setStatusFilter("all") setStatusFilter("all")
setOwnerTypeFilter("all") setOwnerTypeFilter("all")
setPage(1)
}} }}
> >
Clear Clear
@ -160,38 +192,44 @@ export function PracticeDetailsPage() {
</div> </div>
</div> </div>
{loadingList ? ( <div className="rounded-xl border bg-white">
<div className="py-16 text-center text-sm text-grayScale-500">Loading practices...</div> <Table>
) : filteredPractices.length === 0 ? ( <TableHeader>
<div className="rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center text-sm text-grayScale-500"> <TableRow>
No practice sets found. <TableHead>Title</TableHead>
</div> <TableHead className="hidden md:table-cell">Owner</TableHead>
) : ( <TableHead>Status</TableHead>
<div className="overflow-x-auto rounded-lg border border-grayScale-200"> <TableHead className="hidden md:table-cell">Created</TableHead>
<Table> </TableRow>
<TableHeader> </TableHeader>
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100"> <TableBody>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</TableHead> {loadingList ? (
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Owner</TableHead> <TableRow>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</TableHead> <TableCell colSpan={4} className="py-12 text-center">
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Created</TableHead> <div className="flex flex-col items-center gap-3">
<img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
<span className="text-sm text-grayScale-400">Loading practices...</span>
</div>
</TableCell>
</TableRow> </TableRow>
</TableHeader> ) : filteredPractices.length === 0 ? (
<TableBody> <TableRow>
{filteredPractices.map((practice, index) => ( <TableCell colSpan={4} className="py-12 text-center text-sm text-grayScale-500">
No practice sets found.
</TableCell>
</TableRow>
) : (
paginatedPractices.map((practice) => (
<TableRow <TableRow
key={practice.id} key={practice.id}
onClick={() => { onClick={() => {
setSelectedPracticeId(practice.id) setSelectedPracticeId(practice.id)
setDetailOpen(true) setDetailOpen(true)
}} }}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${ className={cn(
selectedPracticeId === practice.id "group cursor-pointer",
? "bg-brand-100/40" selectedPracticeId === practice.id && "bg-brand-100/30",
: index % 2 === 0 )}
? "bg-white"
: "bg-grayScale-100/50"
}`}
> >
<TableCell className="max-w-md py-3.5"> <TableCell className="max-w-md py-3.5">
<p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p> <p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p>
@ -209,11 +247,84 @@ export function PracticeDetailsPage() {
{practice.created_at} {practice.created_at}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
</TableBody> )}
</Table> </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>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows 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"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<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={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div> </div>
)} </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Plus, Search, Edit, Trash2, HelpCircle, X } from "lucide-react" import { Plus, Search, Edit, Trash2, HelpCircle, X, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -17,6 +18,7 @@ import {
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api" import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types" import type { QuestionDetail } from "../../types/course.types"
import { cn } from "../../lib/utils"
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD" type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
@ -300,8 +302,23 @@ export function QuestionsPage() {
const totalCount = filteredQuestions.length const totalCount = filteredQuestions.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)) const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const canGoPrev = page > 1 const safePage = Math.min(page, totalPages)
const canGoNext = page < totalPages const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@ -388,18 +405,6 @@ export function QuestionsPage() {
<option value="PUBLISHED">Published</option> <option value="PUBLISHED">Published</option>
<option value="INACTIVE">Inactive</option> <option value="INACTIVE">Inactive</option>
</Select> </Select>
<Select
value={String(pageSize)}
onChange={(e) => {
const next = Number(e.target.value)
setPageSize(next)
setPage(1)
}}
>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
</Select>
</div> </div>
</div> </div>
@ -408,52 +413,54 @@ export function QuestionsPage() {
Showing {paginatedQuestions.length} of {totalCount} questions Showing {paginatedQuestions.length} of {totalCount} questions
</div> </div>
{/* Questions Table */} <div className="rounded-xl border bg-white">
{loading ? ( <Table>
<div className="flex items-center justify-center rounded-lg border border-grayScale-200 py-16"> <TableHeader>
<p className="text-sm text-grayScale-500">Loading questions...</p> <TableRow>
</div> <TableHead className="w-10">
) : filteredQuestions.length > 0 ? ( <input
<div className="overflow-x-auto rounded-lg border border-grayScale-200"> type="checkbox"
<Table> checked={isAllCurrentPageSelected}
<TableHeader> onChange={toggleSelectAllCurrentPage}
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100"> aria-label="Select all questions on current page"
<TableHead className="w-10 py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500"> />
<input </TableHead>
type="checkbox" <TableHead>Question</TableHead>
checked={isAllCurrentPageSelected} <TableHead>Type</TableHead>
onChange={toggleSelectAllCurrentPage} <TableHead className="hidden md:table-cell">Difficulty</TableHead>
aria-label="Select all questions on current page" <TableHead className="hidden md:table-cell">Status</TableHead>
/> <TableHead>Points</TableHead>
</TableHead> <TableHead className="text-right">Actions</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500"> </TableRow>
Question </TableHeader>
</TableHead> <TableBody>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500"> {loading ? (
Type <TableRow>
</TableHead> <TableCell colSpan={7} className="py-12 text-center">
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell"> <div className="flex flex-col items-center gap-3">
Difficulty <img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
</TableHead> <span className="text-sm text-grayScale-400">Loading questions...</span>
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell"> </div>
Status </TableCell>
</TableHead>
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Points
</TableHead>
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Actions
</TableHead>
</TableRow> </TableRow>
</TableHeader> ) : filteredQuestions.length === 0 ? (
<TableBody> <TableRow>
{paginatedQuestions.map((question, index) => ( <TableCell colSpan={7} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<HelpCircle className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No questions found</p>
<p className="mt-1 text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
paginatedQuestions.map((question) => (
<TableRow <TableRow
key={question.id} key={question.id}
onClick={() => openDetails(question.id)} onClick={() => openDetails(question.id)}
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${ className="group cursor-pointer"
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
}`}
> >
<TableCell className="py-3.5"> <TableCell className="py-3.5">
<input <input
@ -527,46 +534,83 @@ export function QuestionsPage() {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
</TableBody> )}
</Table> </TableBody>
</div> </Table>
) : (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
<HelpCircle className="h-8 w-8 text-grayScale-400" />
</div>
<p className="text-base font-semibold text-grayScale-600">
No questions found
</p>
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-grayScale-400">
Try adjusting your search or filter criteria to find what you're
looking for.
</p>
</div>
)}
<div className="flex flex-col gap-3 border-t border-grayScale-200 pt-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<p className="text-xs text-grayScale-500"> <div className="flex items-center gap-2">
Page {page} of {totalPages} <span>Showing</span>
</p> <span className="font-medium text-grayScale-600">
<div className="flex items-center gap-2"> {startEntry}-{endEntry}
<Button </span>
variant="outline" <span>of</span>
size="sm" <span className="font-medium text-grayScale-600">{totalCount}</span>
disabled={!canGoPrev} <span className="mr-4">entries</span>
onClick={() => setPage((prev) => Math.max(1, prev - 1))} <span className="border-l pl-4">Rows per page</span>
> <div className="relative">
Previous <select
</Button> value={pageSize}
<Button onChange={(e) => {
variant="outline" setPageSize(Number(e.target.value))
size="sm" setPage(1)
disabled={!canGoNext} }}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 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"
> >
Next {[10, 20, 50].map((size) => (
</Button> <option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<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={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -629,11 +673,74 @@ export function QuestionsPage() {
<p className="mt-1 text-sm text-grayScale-700">{detailData.question_text}</p> <p className="mt-1 text-sm text-grayScale-700">{detailData.question_text}</p>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<p><span className="font-medium">ID:</span> {detailData.id}</p>
<p><span className="font-medium">Type:</span> {typeLabels[detailData.question_type] || detailData.question_type}</p> <p><span className="font-medium">Type:</span> {typeLabels[detailData.question_type] || detailData.question_type}</p>
<p><span className="font-medium">Difficulty:</span> {detailData.difficulty_level || "—"}</p> <p><span className="font-medium">Difficulty:</span> {detailData.difficulty_level || "—"}</p>
<p><span className="font-medium">Points:</span> {detailData.points ?? 0}</p> <p><span className="font-medium">Points:</span> {detailData.points ?? 0}</p>
<p><span className="font-medium">Status:</span> {detailData.status || "—"}</p> <p><span className="font-medium">Status:</span> {detailData.status || "—"}</p>
<p><span className="font-medium">Created:</span> {detailData.created_at || "—"}</p>
</div> </div>
{detailData.explanation ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Explanation</p>
<p className="mt-1 text-sm text-grayScale-700">{detailData.explanation}</p>
</div>
) : null}
{detailData.tips ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Tips</p>
<p className="mt-1 text-sm text-grayScale-700">{detailData.tips}</p>
</div>
) : null}
{detailData.audio_correct_answer_text ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Audio Correct Answer Text</p>
<p className="mt-1 text-sm text-grayScale-700">{detailData.audio_correct_answer_text}</p>
</div>
) : null}
{detailData.voice_prompt ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Voice Prompt</p>
<p className="mt-1 break-all text-xs text-grayScale-500">{detailData.voice_prompt}</p>
<audio controls src={detailData.voice_prompt} className="mt-2 h-10 w-full max-w-md" />
</div>
) : null}
{detailData.sample_answer_voice_prompt ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Sample Answer Voice Prompt</p>
<p className="mt-1 break-all text-xs text-grayScale-500">{detailData.sample_answer_voice_prompt}</p>
<audio controls src={detailData.sample_answer_voice_prompt} className="mt-2 h-10 w-full max-w-md" />
</div>
) : null}
{detailData.image_url ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Image</p>
<p className="mt-1 break-all text-xs text-grayScale-500">{detailData.image_url}</p>
<img
src={detailData.image_url}
alt="Question reference"
className="mt-2 h-28 w-28 rounded-md border border-grayScale-200 object-cover"
/>
</div>
) : null}
{Array.isArray(detailData.short_answers) && detailData.short_answers.length > 0 ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Short Answers</p>
<div className="mt-2 space-y-1 text-sm text-grayScale-700">
{detailData.short_answers.map((answer, index) => {
const value =
typeof answer === "string"
? answer
: (answer as { acceptable_answer?: string }).acceptable_answer || ""
return (
<p key={`${value}-${index}`} className="rounded-md border border-grayScale-200 bg-grayScale-50 px-2 py-1">
{value || "—"}
</p>
)
})}
</div>
</div>
) : null}
{(detailData.options ?? []).length > 0 && ( {(detailData.options ?? []).length > 0 && (
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Options</p> <p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Options</p>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" import { Link, useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Star, ChevronLeft, ChevronRight, MessageSquare, Play, Loader2 } from "lucide-react" import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Star, ChevronLeft, ChevronRight, MessageSquare, Play } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card } from "../../components/ui/card" import { Card } from "../../components/ui/card"
import alertSrc from "../../assets/Alert.svg" import alertSrc from "../../assets/Alert.svg"
@ -13,14 +13,23 @@ import {
getVideosBySubCourse, getVideosBySubCourse,
updatePractice, updatePractice,
deleteQuestionSet, deleteQuestionSet,
createVimeoVideo, createCourseVideo,
updateSubCourseVideo, updateSubCourseVideo,
deleteSubCourseVideo, deleteSubCourseVideo,
getRatings, getRatings,
getVimeoSample, getVimeoSample,
} from "../../api/courses.api" } from "../../api/courses.api"
import type { SubCourse, QuestionSet, SubCourseVideo, Rating, VimeoSampleVideo } from "../../types/course.types" import { uploadVideoFile } from "../../api/files.api"
import { Select } from "../../components/ui/select" import type {
SubCourse,
QuestionSet,
SubCourseVideo,
Rating,
VimeoSampleVideo,
VideoStatus,
VideoVisibility,
} from "../../types/course.types"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
type TabType = "video" | "practice" | "ratings" type TabType = "video" | "practice" | "ratings"
type StatusFilter = "all" | "published" | "draft" | "archived" type StatusFilter = "all" | "published" | "draft" | "archived"
@ -74,17 +83,19 @@ export function SubCourseContentPage() {
const [videoTitle, setVideoTitle] = useState("") const [videoTitle, setVideoTitle] = useState("")
const [videoDescription, setVideoDescription] = useState("") const [videoDescription, setVideoDescription] = useState("")
const [videoUrl, setVideoUrl] = useState("") const [videoUrl, setVideoUrl] = useState("")
const [videoFile, setVideoFile] = useState<File | null>(null)
const [videoFileSize, setVideoFileSize] = useState<number>(0) const [videoFileSize, setVideoFileSize] = useState<number>(0)
const [videoDuration, setVideoDuration] = useState<number>(0) const [videoDuration, setVideoDuration] = useState<number>(0)
const [videoResolution, setVideoResolution] = useState("1080p")
const [videoVisibility, setVideoVisibility] = useState<VideoVisibility>("PUBLISHED")
const [videoStatus, setVideoStatus] = useState<VideoStatus>("PUBLISHED")
const [videoDisplayOrder, setVideoDisplayOrder] = useState<number>(1)
// Vimeo preview state // Vimeo preview state
const [showPreviewModal, setShowPreviewModal] = useState(false) const [showPreviewModal, setShowPreviewModal] = useState(false)
const [previewIframe, setPreviewIframe] = useState("") const [previewIframe, setPreviewIframe] = useState("")
const [previewVideo, setPreviewVideo] = useState<VimeoSampleVideo | null>(null) const [previewVideo, setPreviewVideo] = useState<VimeoSampleVideo | null>(null)
const [previewLoading, setPreviewLoading] = useState(false) const [previewLoading, setPreviewLoading] = useState(false)
const [sampleVideoId, setSampleVideoId] = useState("")
const [modalPreviewIframe, setModalPreviewIframe] = useState("")
const [modalPreviewLoading, setModalPreviewLoading] = useState(false)
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@ -234,31 +245,83 @@ export function SubCourseContentPage() {
setVideoTitle("") setVideoTitle("")
setVideoDescription("") setVideoDescription("")
setVideoUrl("") setVideoUrl("")
setVideoFile(null)
setVideoFileSize(0) setVideoFileSize(0)
setVideoDuration(0) setVideoDuration(0)
setVideoResolution("1080p")
setVideoVisibility("PUBLISHED")
setVideoStatus("PUBLISHED")
setVideoDisplayOrder(1)
setSaveError(null) setSaveError(null)
setShowAddVideoModal(true) setShowAddVideoModal(true)
} }
const handleVideoFileSelect = (file: File | null) => {
setVideoFile(file)
if (!file) {
setVideoFileSize(0)
setVideoDuration(0)
return
}
setVideoFileSize(file.size)
const video = document.createElement("video")
const objectUrl = URL.createObjectURL(file)
video.preload = "metadata"
video.src = objectUrl
video.onloadedmetadata = () => {
setVideoDuration(Math.max(0, Math.round(video.duration || 0)))
URL.revokeObjectURL(objectUrl)
}
video.onerror = () => {
URL.revokeObjectURL(objectUrl)
}
}
const handleSaveNewVideo = async () => { const handleSaveNewVideo = async () => {
if (!subCourseId) return if (!subCourseId || !videoFile) return
setSaving(true) setSaving(true)
setSaveError(null) setSaveError(null)
try { try {
await createVimeoVideo({ const uploadRes = await uploadVideoFile(videoFile, {
title: videoTitle.trim(),
description: videoDescription.trim(),
})
// Per backend guide, use embed_url as the video_url reference.
const embedUrl = uploadRes.data?.data?.embed_url?.trim()
const vimeoUrl = uploadRes.data?.data?.url?.trim()
if (!embedUrl) throw new Error("Missing uploaded video embed_url")
// Backend requires: https://player.vimeo.com/video/<id>?h=<hash>
// where <hash> is the last path segment from `url` (e.g. https://vimeo.com/<id>/<hash>)
const hashFromUrl = vimeoUrl ? vimeoUrl.split("/").filter(Boolean).at(-1) : undefined
const finalVideoUrl = hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl
const finalTitle = videoTitle.trim() || videoFile.name
await createCourseVideo({
sub_course_id: Number(subCourseId), sub_course_id: Number(subCourseId),
title: videoTitle, title: finalTitle,
description: videoDescription, description: videoDescription.trim(),
source_url: videoUrl, video_url: finalVideoUrl,
file_size: videoFileSize,
duration: videoDuration, duration: videoDuration,
resolution: videoResolution.trim() || undefined,
visibility: videoVisibility,
display_order: Number.isFinite(videoDisplayOrder) ? videoDisplayOrder : undefined,
status: videoStatus,
}) })
setShowAddVideoModal(false) setShowAddVideoModal(false)
setVideoTitle("") setVideoTitle("")
setVideoDescription("") setVideoDescription("")
setVideoUrl("") setVideoUrl("")
setVideoFile(null)
setVideoFileSize(0) setVideoFileSize(0)
setVideoDuration(0) setVideoDuration(0)
setVideoResolution("1080p")
setVideoVisibility("PUBLISHED")
setVideoStatus("PUBLISHED")
setVideoDisplayOrder(1)
await fetchVideos() await fetchVideos()
} catch (err) { } catch (err) {
console.error("Failed to create video:", err) console.error("Failed to create video:", err)
@ -321,15 +384,26 @@ export function SubCourseContentPage() {
} }
} }
// Preview a video card via Vimeo sample API // Preview a video card.
// We prefer embedding directly from `video_url` because Vimeo embeds may require the `h=` hash.
const handlePreviewVideo = async (video: SubCourseVideo) => { const handlePreviewVideo = async (video: SubCourseVideo) => {
const idMatch = video.video_url?.match(/(\d{5,})/)
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
setShowPreviewModal(true) setShowPreviewModal(true)
setPreviewLoading(true) setPreviewLoading(true)
setPreviewIframe("") setPreviewIframe("")
setPreviewVideo(null) setPreviewVideo(null)
try { try {
const directUrl = video.video_url?.trim()
if (directUrl) {
setPreviewIframe(
`<iframe src="${directUrl}" style="width:100%;height:100%;" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`,
)
setPreviewVideo(null)
return
}
// Fallback to sample API when a direct URL is unavailable.
const idMatch = video.video_url?.match(/(\d{5,})/)
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
const res = await getVimeoSample(vimeoId) const res = await getVimeoSample(vimeoId)
setPreviewIframe(res.data.data.iframe) setPreviewIframe(res.data.data.iframe)
setPreviewVideo(res.data.data.video) setPreviewVideo(res.data.data.video)
@ -340,28 +414,6 @@ export function SubCourseContentPage() {
} }
} }
// Preview inside add/edit modal from a sample vimeo ID picker
const handleModalPreview = async (vimeoId: string) => {
if (!vimeoId) {
setModalPreviewIframe("")
return
}
setModalPreviewLoading(true)
try {
const res = await getVimeoSample(vimeoId)
setModalPreviewIframe(res.data.data.iframe)
// Auto-fill fields from vimeo metadata
const v = res.data.data.video
if (!videoTitle) setVideoTitle(v.name)
if (!videoDescription) setVideoDescription(v.description?.slice(0, 200) ?? "")
if (!videoDuration) setVideoDuration(v.duration)
} catch {
setModalPreviewIframe("")
} finally {
setModalPreviewLoading(false)
}
}
const filteredPractices = practices.filter((practice) => { const filteredPractices = practices.filter((practice) => {
if (statusFilter === "all") return true if (statusFilter === "all") return true
if (statusFilter === "published") return practice.status === "PUBLISHED" if (statusFilter === "published") return practice.status === "PUBLISHED"
@ -482,7 +534,7 @@ export function SubCourseContentPage() {
<> <>
{practicesLoading ? ( {practicesLoading ? (
<div className="flex flex-col items-center justify-center py-20"> <div className="flex flex-col items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" /> <SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading practices</p> <p className="mt-4 text-sm font-medium text-grayScale-500">Loading practices</p>
</div> </div>
) : filteredPractices.length === 0 ? ( ) : filteredPractices.length === 0 ? (
@ -525,7 +577,7 @@ export function SubCourseContentPage() {
<p className="text-sm leading-relaxed text-grayScale-500 line-clamp-2">{practice.description}</p> <p className="text-sm leading-relaxed text-grayScale-500 line-clamp-2">{practice.description}</p>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Badge className="rounded-full bg-brand-50 text-brand-600 text-[11px] font-medium px-2.5 py-0.5 ring-1 ring-inset ring-brand-200"> <Badge className="rounded-full bg-[#f3e8ff] text-[#6b21a8] text-[11px] font-medium px-2.5 py-0.5 ring-1 ring-inset ring-[#d8b4fe]">
{practice.set_type} {practice.set_type}
</Badge> </Badge>
{practice.persona && ( {practice.persona && (
@ -581,7 +633,7 @@ export function SubCourseContentPage() {
<> <>
{videosLoading ? ( {videosLoading ? (
<div className="flex flex-col items-center justify-center py-20"> <div className="flex flex-col items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" /> <SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading videos</p> <p className="mt-4 text-sm font-medium text-grayScale-500">Loading videos</p>
</div> </div>
) : videos.length === 0 ? ( ) : videos.length === 0 ? (
@ -711,7 +763,7 @@ export function SubCourseContentPage() {
<> <>
{ratingsLoading ? ( {ratingsLoading ? (
<div className="flex flex-col items-center justify-center py-20"> <div className="flex flex-col items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" /> <SpinnerIcon className="h-8 w-8" />
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p> <p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings</p>
</div> </div>
) : ratings.length === 0 ? ( ) : ratings.length === 0 ? (
@ -931,44 +983,13 @@ export function SubCourseContentPage() {
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2> <h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
<button <button
onClick={() => { setShowAddVideoModal(false); setSampleVideoId(""); setModalPreviewIframe("") }} onClick={() => { setShowAddVideoModal(false); setVideoFile(null) }}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-6"> <div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-6">
{/* Sample Vimeo picker */}
<div className="rounded-xl border border-brand-100 bg-brand-50/40 p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-brand-600">
Try a sample Vimeo video
</p>
<div className="flex items-center gap-2">
<Select
className="flex-1 text-sm"
value={sampleVideoId}
onChange={(e) => {
setSampleVideoId(e.target.value)
handleModalPreview(e.target.value)
}}
>
<option value="">Select a sample video</option>
<option value="76979871">Big Buck Bunny</option>
<option value="1084537">Big Buck Bunny (alt)</option>
<option value="253989945">Vimeo Staff Pick</option>
<option value="305727901">Big Buck Bunny (4K)</option>
<option value="148751763">GoPro Footage</option>
</Select>
{modalPreviewLoading && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-brand-500" />}
</div>
{modalPreviewIframe && (
<div
className="aspect-video w-full overflow-hidden rounded-lg"
dangerouslySetInnerHTML={{ __html: modalPreviewIframe }}
/>
)}
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Title</label> <label className="text-sm font-medium text-grayScale-700">Title</label>
<Input <Input
@ -988,12 +1009,17 @@ export function SubCourseContentPage() {
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Source URL</label> <label className="text-sm font-medium text-grayScale-700">Video File</label>
<Input <Input
value={videoUrl} type="file"
onChange={(e) => setVideoUrl(e.target.value)} accept="video/*"
placeholder="https://example-storage.com/video.mp4" onChange={(e) => handleVideoFileSelect(e.target.files?.[0] ?? null)}
/> />
{videoFile && (
<p className="text-xs text-grayScale-500">
Selected: {videoFile.name}
</p>
)}
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -1017,6 +1043,52 @@ export function SubCourseContentPage() {
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Resolution</label>
<Input
value={videoResolution}
onChange={(e) => setVideoResolution(e.target.value)}
placeholder="1080p"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Display Order</label>
<Input
type="number"
value={videoDisplayOrder}
onChange={(e) => setVideoDisplayOrder(Number(e.target.value))}
min={1}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Visibility</label>
<select
value={videoVisibility}
onChange={(e) => setVideoVisibility(e.target.value)}
className="h-11 w-full rounded-lg border border-grayScale-200 bg-white px-3 text-sm text-grayScale-700 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
>
<option value="PUBLISHED">PUBLISHED</option>
<option value="DRAFT">DRAFT</option>
<option value="PRIVATE">PRIVATE</option>
<option value="UNLISTED">UNLISTED</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Status</label>
<select
value={videoStatus}
onChange={(e) => setVideoStatus(e.target.value)}
className="h-11 w-full rounded-lg border border-grayScale-200 bg-white px-3 text-sm text-grayScale-700 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
>
<option value="PUBLISHED">PUBLISHED</option>
<option value="DRAFT">DRAFT</option>
<option value="ARCHIVED">ARCHIVED</option>
</select>
</div>
</div>
{saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>} {saveError && <p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{saveError}</p>}
</div> </div>
<div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3"> <div className="flex flex-col-reverse gap-2.5 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3">
@ -1026,9 +1098,9 @@ export function SubCourseContentPage() {
<Button <Button
className="bg-brand-500 shadow-sm hover:bg-brand-600" className="bg-brand-500 shadow-sm hover:bg-brand-600"
onClick={handleSaveNewVideo} onClick={handleSaveNewVideo}
disabled={saving || !videoTitle.trim() || !videoUrl.trim()} disabled={saving || !videoTitle.trim() || !videoFile}
> >
{saving ? "Uploading..." : "Upload to Vimeo"} {saving ? "Uploading..." : "Upload Video"}
</Button> </Button>
</div> </div>
</div> </div>
@ -1149,7 +1221,7 @@ export function SubCourseContentPage() {
<div className="p-6"> <div className="p-6">
{previewLoading ? ( {previewLoading ? (
<div className="flex aspect-video items-center justify-center rounded-xl bg-grayScale-50"> <div className="flex aspect-video items-center justify-center rounded-xl bg-grayScale-50">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" /> <SpinnerIcon className="h-8 w-8" />
</div> </div>
) : previewIframe ? ( ) : previewIframe ? (
<div <div

View File

@ -11,7 +11,6 @@ import {
Edit, Edit,
Link2, Link2,
Plus, Plus,
Loader2,
LayoutGrid, LayoutGrid,
GitBranch, GitBranch,
ChevronDown, ChevronDown,
@ -43,6 +42,7 @@ import type {
CourseCategory, CourseCategory,
SubCoursePrerequisite, SubCoursePrerequisite,
} from "../../types/course.types"; } from "../../types/course.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
export function SubCoursesPage() { export function SubCoursesPage() {
const { categoryId, courseId } = useParams<{ const { categoryId, courseId } = useParams<{
@ -403,10 +403,6 @@ export function SubCoursesPage() {
return ( return (
<div className="flex flex-col items-center justify-center py-24"> <div className="flex flex-col items-center justify-center py-24">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" /> <img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
{/* <div className="rounded-full bg-white shadow-sm p-4">
<RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
</div>
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p> */}
</div> </div>
); );
} }
@ -831,7 +827,7 @@ export function SubCoursesPage() {
disabled={prereqAdding || !selectedPrereqId} disabled={prereqAdding || !selectedPrereqId}
> >
{prereqAdding ? ( {prereqAdding ? (
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" />
) : ( ) : (
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
)} )}
@ -890,7 +886,7 @@ export function SubCoursesPage() {
className="ml-3 grid h-8 w-8 shrink-0 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50" className="ml-3 grid h-8 w-8 shrink-0 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
> >
{prereqRemoving === prereq.id ? ( {prereqRemoving === prereq.id ? (
<Loader2 className="h-4 w-4 animate-spin" /> <SpinnerIcon className="h-4 w-4" />
) : ( ) : (
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
)} )}

View File

@ -41,6 +41,7 @@ import {
DialogDescription, DialogDescription,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import { import {
getIssues, getIssues,
getIssueById, getIssueById,
@ -365,7 +366,7 @@ export function IssuesPage() {
fetchIssues(); fetchIssues();
}} }}
> >
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} /> {loading ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
Refresh Refresh
</Button> </Button>
<Button <Button
@ -500,7 +501,7 @@ export function IssuesPage() {
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center py-12"> <TableCell colSpan={6} className="text-center py-12">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" /> <SpinnerIcon className="h-6 w-6" />
<span className="text-sm text-grayScale-400">Loading issues...</span> <span className="text-sm text-grayScale-400">Loading issues...</span>
</div> </div>
</TableCell> </TableCell>
@ -720,7 +721,7 @@ export function IssuesPage() {
{detailLoading ? ( {detailLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" /> <SpinnerIcon className="h-6 w-6" />
</div> </div>
) : selectedIssue ? ( ) : selectedIssue ? (
<div className="space-y-4"> <div className="space-y-4">
@ -1030,7 +1031,7 @@ export function IssuesPage() {
onClick={handleDeleteConfirm} onClick={handleDeleteConfirm}
> >
{deleteLoading ? ( {deleteLoading ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" />
) : ( ) : (
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
)} )}

View File

@ -0,0 +1,79 @@
import type { ReactNode } from "react"
import { Link, useLocation } from "react-router-dom"
import logoSrc from "../../assets/logo.svg"
interface InfoLayoutProps {
title: string
subtitle: string
children: ReactNode
lastUpdated?: string
}
const footerLinks = [
{ to: "/about", label: "About" },
{ to: "/terms", label: "Terms and Conditions" },
{ to: "/privacy", label: "Privacy Policy" },
{ to: "/account-deletion", label: "Account Deletion" },
]
export function InfoLayout({
title,
subtitle,
children,
lastUpdated = "March 11, 2026",
}: InfoLayoutProps) {
const location = useLocation()
return (
<div className="relative min-h-screen overflow-hidden bg-[#fefcff] px-4 py-10 sm:px-6">
<div className="pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-[#f3e8ff]" />
<div className="pointer-events-none absolute -right-20 top-24 h-64 w-64 rounded-full bg-[#f5ecff]" />
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-[#eadff7] bg-white px-6 py-6 shadow-[0_20px_60px_rgba(83,33,120,0.08)] sm:px-10 sm:py-8">
<header>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Link to="/about" className="inline-flex items-center gap-3">
<span className="inline-flex rounded-xl bg-brand-500 p-2 shadow-sm ring-1 ring-brand-400/40">
<img src={logoSrc} alt="Yimaru Academy" className="h-8 w-auto" />
</span>
<p className="text-xl font-semibold text-grayScale-700">Yimaru Academy</p>
</Link>
<nav className="flex flex-wrap items-center text-sm font-medium">
{footerLinks.map((item, index) => (
<div key={item.to} className="flex items-center">
<Link
to={item.to}
className={`px-3 transition-colors ${
location.pathname === item.to
? "text-[#8f56b5]"
: "text-grayScale-600 hover:text-[#8f56b5]"
}`}
>
{item.label}
</Link>
{index < footerLinks.length - 1 && (
<span className="h-5 w-px bg-[#e0d8eb]" aria-hidden="true" />
)}
</div>
))}
</nav>
</div>
<div className="mt-8">
<h1 className="text-4xl font-bold tracking-tight text-grayScale-700">{title}</h1>
<p className="mt-4 text-sm text-grayScale-500">
<span className="font-semibold text-grayScale-600">Last Updated:</span> {lastUpdated}
</p>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-grayScale-500">{subtitle}</p>
</div>
<div className="mt-6 h-px bg-[#e8e1f2]" />
</header>
<main className="space-y-6 pt-6">{children}</main>
<footer className="mt-8 flex justify-center text-xs text-grayScale-400">
© Yimaru Academy. All rights reserved.
</footer>
</div>
</div>
)
}

View File

@ -14,7 +14,6 @@ import {
BookOpen, BookOpen,
Video, Video,
ShieldAlert, ShieldAlert,
Loader2,
MailOpen, MailOpen,
Mail, Mail,
CheckCheck, CheckCheck,
@ -51,6 +50,7 @@ import {
} from "../../components/ui/dropdown-menu" } from "../../components/ui/dropdown-menu"
import { FileUpload } from "../../components/ui/file-upload" import { FileUpload } from "../../components/ui/file-upload"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { import {
getNotifications, getNotifications,
getUnreadCount, getUnreadCount,
@ -222,7 +222,7 @@ function NotificationItem({
title={notification.is_read ? "Mark as unread" : "Mark as read"} title={notification.is_read ? "Mark as unread" : "Mark as read"}
> >
{toggling ? ( {toggling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" />
) : notification.is_read ? ( ) : notification.is_read ? (
<Mail className="h-3.5 w-3.5" /> <Mail className="h-3.5 w-3.5" />
) : ( ) : (
@ -506,6 +506,22 @@ export function NotificationsPage() {
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1 const currentPage = Math.floor(offset / PAGE_SIZE) + 1
const startEntry = totalCount === 0 ? 0 : offset + 1
const endEntry = Math.min(offset + PAGE_SIZE, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (currentPage > 4) pages.push("...")
if (currentPage > 3 && currentPage < totalPages - 2) pages.push(currentPage)
if (currentPage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
const filteredNotifications = notifications.filter((n) => { const filteredNotifications = notifications.filter((n) => {
if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false
@ -605,7 +621,7 @@ export function NotificationsPage() {
onClick={handleMarkAllRead} onClick={handleMarkAllRead}
> >
{bulkLoading ? ( {bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="mr-2 h-3.5 w-3.5" />
) : ( ) : (
<CheckCheck className="mr-2 h-3.5 w-3.5" /> <CheckCheck className="mr-2 h-3.5 w-3.5" />
)} )}
@ -619,7 +635,7 @@ export function NotificationsPage() {
onClick={handleMarkAllUnread} onClick={handleMarkAllUnread}
> >
{bulkLoading ? ( {bulkLoading ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="mr-2 h-3.5 w-3.5" />
) : ( ) : (
<MailX className="mr-2 h-3.5 w-3.5" /> <MailX className="mr-2 h-3.5 w-3.5" />
)} )}
@ -636,52 +652,44 @@ export function NotificationsPage() {
{/* Summary cards */} {/* Summary cards */}
{!loading && !error && ( {!loading && !error && (
<div className="mb-5 grid gap-4 sm:grid-cols-3"> <div className="mb-5 grid gap-4 sm:grid-cols-3">
<Card className="shadow-none border border-grayScale-100"> <div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<CardContent className="flex items-center justify-between gap-3 p-4"> <div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
<div> <Bell className="h-5 w-5" />
<p className="text-xs font-medium text-grayScale-500">Total notifications</p> </div>
<p className="mt-1 text-xl font-semibold text-grayScale-700"> <div>
{totalCount.toLocaleString()} <p className="text-2xl font-bold text-grayScale-600">{totalCount.toLocaleString()}</p>
</p> <p className="text-xs text-grayScale-400">Total notifications</p>
</div> </div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-brand-500 text-white"> </div>
<Bell className="h-5 w-5" />
</div> <div className="flex items-center gap-4 rounded-xl border bg-white p-4">
</CardContent> <div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-50 text-amber-600">
</Card> <BellOff className="h-5 w-5" />
<Card className="shadow-none border border-grayScale-100"> </div>
<CardContent className="flex items-center justify-between gap-3 p-4"> <div>
<div> <p className="text-2xl font-bold text-grayScale-600">{globalUnread.toLocaleString()}</p>
<p className="text-xs font-medium text-grayScale-500">Unread</p> <p className="text-xs text-grayScale-400">Unread</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700"> </div>
{globalUnread.toLocaleString()} </div>
</p>
</div> <div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-amber-50 text-amber-600"> <div className="grid h-10 w-10 place-items-center rounded-lg bg-grayScale-50 text-grayScale-500">
<BellOff className="h-5 w-5" /> <MailOpen className="h-5 w-5" />
</div> </div>
</CardContent> <div>
</Card> <p className="text-2xl font-bold text-grayScale-600">
<Card className="shadow-none border border-grayScale-100"> {Array.from(new Set(notifications.map((n) => n.delivery_channel))).length || "—"}
<CardContent className="flex items-center justify-between gap-3 p-4"> </p>
<div> <p className="text-xs text-grayScale-400">Channels used</p>
<p className="text-xs font-medium text-grayScale-500">Channels used</p> </div>
<p className="mt-1 text-xl font-semibold text-grayScale-700"> </div>
{Array.from(new Set(notifications.map((n) => n.delivery_channel))).length || "—"}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500">
<MailOpen className="h-5 w-5" />
</div>
</CardContent>
</Card>
</div> </div>
)} )}
{/* Loading */} {/* Loading */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" /> <SpinnerIcon className="h-6 w-6" />
</div> </div>
)} )}
@ -755,51 +763,83 @@ export function NotificationsPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Channel</span> <span className="text-xs font-medium text-grayScale-500">Channel</span>
<Select <DropdownMenu>
value={channelFilter} <DropdownMenuTrigger asChild>
onChange={(e) => setChannelFilter(e.target.value as typeof channelFilter)} <Button
className="h-8 w-[130px] text-xs" variant="outline"
> className="h-8 w-[130px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
<option value="all">All</option> >
<option value="push">Push</option> <span className="truncate">{channelFilter === "all" ? "All" : channelFilter.toUpperCase()}</span>
<option value="sms">SMS</option> <ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Select> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[130px]">
<DropdownMenuRadioGroup
value={channelFilter}
onValueChange={(value) => setChannelFilter(value as typeof channelFilter)}
>
<DropdownMenuRadioItem value="all">All</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="push">Push</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="sms">SMS</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Type</span> <span className="text-xs font-medium text-grayScale-500">Type</span>
<Select <DropdownMenu>
value={typeFilter} <DropdownMenuTrigger asChild>
onChange={(e) => setTypeFilter(e.target.value)} <Button
className="h-8 w-[150px] text-xs" variant="outline"
> className="h-8 w-[150px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
<option value="all">All types</option> >
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => ( <span className="truncate">
<option key={t} value={t}> {typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)}
{formatTypeLabel(t)} </span>
</option> <ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
))} </Button>
</Select> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[220px]">
<DropdownMenuRadioGroup value={typeFilter} onValueChange={setTypeFilter}>
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
<DropdownMenuRadioItem key={t} value={t}>
{formatTypeLabel(t)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Level</span> <span className="text-xs font-medium text-grayScale-500">Level</span>
<Select <DropdownMenu>
value={levelFilter} <DropdownMenuTrigger asChild>
onChange={(e) => setLevelFilter(e.target.value)} <Button
className="h-8 w-[130px] text-xs" variant="outline"
> className="h-8 w-[130px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
<option value="all">All levels</option> >
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => ( <span className="truncate">{levelFilter === "all" ? "All levels" : levelFilter}</span>
<option key={lvl} value={lvl}> <ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
{lvl} </Button>
</option> </DropdownMenuTrigger>
))} <DropdownMenuContent align="start" className="w-[150px]">
</Select> <DropdownMenuRadioGroup value={levelFilter} onValueChange={setLevelFilter}>
<DropdownMenuRadioItem value="all">All levels</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => (
<DropdownMenuRadioItem key={lvl} value={lvl}>
{lvl}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="shadow-none"> <Card className="overflow-hidden rounded-xl border bg-white shadow-none">
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
@ -816,8 +856,14 @@ export function NotificationsPage() {
<TableBody> <TableBody>
{filteredNotifications.length === 0 ? ( {filteredNotifications.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-10 text-center text-sm text-grayScale-400"> <TableCell colSpan={7} className="py-12 text-center">
No notifications match your filters. <div className="flex flex-col items-center gap-3">
<BellOff className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No notifications match your filters</p>
<p className="mt-1 text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@ -897,7 +943,7 @@ export function NotificationsPage() {
title={n.is_read ? "Mark as unread" : "Mark as read"} title={n.is_read ? "Mark as unread" : "Mark as read"}
> >
{isToggling ? ( {isToggling ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-grayScale-400" /> <SpinnerIcon className="h-3.5 w-3.5" />
) : n.is_read ? ( ) : n.is_read ? (
<Mail className="h-3.5 w-3.5 text-grayScale-400" /> <Mail className="h-3.5 w-3.5 text-grayScale-400" />
) : ( ) : (
@ -921,37 +967,72 @@ export function NotificationsPage() {
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
</Card> <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">
{/* Pagination */} <span>Showing</span>
{totalPages > 1 && ( <span className="font-medium text-grayScale-600">
<div className="mt-4 flex items-center justify-between"> {startEntry}-{endEntry}
<span className="text-xs text-grayScale-400"> </span>
Showing {offset + 1}{Math.min(offset + PAGE_SIZE, totalCount)} of {totalCount} <span>of</span>
</span> <span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={PAGE_SIZE}
disabled
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
<option value={PAGE_SIZE}>{PAGE_SIZE}</option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <button
variant="outline" onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))}
size="sm"
disabled={currentPage <= 1} disabled={currentPage <= 1}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))} className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
currentPage <= 1 && "cursor-not-allowed opacity-50",
)}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </button>
<span className="px-3 text-xs font-medium text-grayScale-600"> {getPageNumbers().map((n, idx) =>
{currentPage} / {totalPages} typeof n === "string" ? (
</span> <span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
<Button ...
variant="outline" </span>
size="sm" ) : (
<button
key={n}
type="button"
onClick={() => setOffset((n - 1) * PAGE_SIZE)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === currentPage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)}
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
onClick={() => setOffset(offset + PAGE_SIZE)} className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
currentPage >= totalPages && "cursor-not-allowed opacity-50",
)}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </button>
</div> </div>
</div> </div>
)} </Card>
</> </>
)} )}
@ -1161,7 +1242,7 @@ export function NotificationsPage() {
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2"> <div className="max-h-48 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && ( {recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400"> <div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <SpinnerIcon className="mr-2 h-4 w-4" />
Loading users Loading users
</div> </div>
)} )}
@ -1230,7 +1311,7 @@ export function NotificationsPage() {
> >
{sending ? ( {sending ? (
<> <>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="mr-2 h-3.5 w-3.5" />
Sending Sending
</> </>
) : ( ) : (
@ -1712,7 +1793,7 @@ export function NotificationsPage() {
<Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}> <Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}>
{bulkSending ? ( {bulkSending ? (
<> <>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="mr-2 h-3.5 w-3.5" />
Sending Sending
</> </>
) : ( ) : (

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { ArrowLeft, Loader2, Search, X, Check } from "lucide-react" import { ArrowLeft, Search, X, Check } from "lucide-react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
@ -10,6 +10,7 @@ import { createRole, setRolePermissions, getAllPermissions } from "../../api/rba
import type { RolePermission } from "../../types/rbac.types" import type { RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function AddRolePage() { export function AddRolePage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -186,7 +187,7 @@ export function AddRolePage() {
disabled={saving || !roleName.trim()} disabled={saving || !roleName.trim()}
className="w-full bg-brand-500 hover:bg-brand-600" className="w-full bg-brand-500 hover:bg-brand-600"
> >
{saving && <Loader2 className="h-4 w-4 animate-spin" />} {saving && <SpinnerIcon className="h-4 w-4" />}
{saving ? "Creating…" : "Create Role"} {saving ? "Creating…" : "Create Role"}
</Button> </Button>
</CardContent> </CardContent>
@ -246,7 +247,7 @@ export function AddRolePage() {
{/* Loading */} {/* Loading */}
{permLoading && ( {permLoading && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" /> <SpinnerIcon className="h-6 w-6" />
</div> </div>
)} )}

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { import {
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight, Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
Loader2, AlertCircle, Eye, X, Pencil, Check, AlertCircle, Eye, X, Pencil, Check,
} from "lucide-react" } from "lucide-react"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
@ -16,6 +16,7 @@ import { getRoles, getRoleDetail, getAllPermissions, setRolePermissions, updateR
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types" import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { toast } from "sonner" import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
export function RolesListPage() { export function RolesListPage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -277,7 +278,7 @@ export function RolesListPage() {
{/* Loading */} {/* Loading */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" /> <SpinnerIcon className="h-8 w-8" />
</div> </div>
)} )}
@ -473,7 +474,7 @@ export function RolesListPage() {
onClick={handleSaveRole} onClick={handleSaveRole}
disabled={savingRole || !editName.trim()} disabled={savingRole || !editName.trim()}
> >
{savingRole && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {savingRole && <SpinnerIcon className="h-3.5 w-3.5" />}
{savingRole ? "Saving…" : "Save"} {savingRole ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
@ -484,7 +485,7 @@ export function RolesListPage() {
{detailLoading && ( {detailLoading && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" /> <SpinnerIcon className="h-6 w-6" />
</div> </div>
)} )}
@ -580,7 +581,7 @@ export function RolesListPage() {
{permLoading && ( {permLoading && (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" /> <SpinnerIcon className="h-6 w-6" />
</div> </div>
)} )}
@ -677,7 +678,7 @@ export function RolesListPage() {
onClick={handleSavePermissions} onClick={handleSavePermissions}
disabled={savingPermissions} disabled={savingPermissions}
> >
{savingPermissions && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {savingPermissions && <SpinnerIcon className="h-3.5 w-3.5" />}
{savingPermissions ? "Saving…" : "Save Permissions"} {savingPermissions ? "Saving…" : "Save Permissions"}
</Button> </Button>
</div> </div>

View File

@ -1,10 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Search, Search,
Plus, Plus,
ChevronDown, ChevronDown,
SlidersHorizontal,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
X, X,
@ -80,44 +79,85 @@ function formatRoleLabel(role: string): string {
.join(" "); .join(" ");
} }
function normalizeFilterValue(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, "_");
}
export function TeamManagementPage() { export function TeamManagementPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [members, setMembers] = useState<TeamMember[]>([]); const [members, setMembers] = useState<TeamMember[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(5); const [pageSize, setPageSize] = useState(5);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState(""); const [roleFilter, setRoleFilter] = useState("");
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({}); const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null); const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
const fetchMembers = async () => { const fetchMembers = async () => {
setLoading(true);
try { try {
const res = await getTeamMembers(page, pageSize); const batchSize = 100;
const data = res.data.data; const firstRes = await getTeamMembers(1, batchSize);
setMembers(data); const firstBatch = firstRes.data.data ?? [];
setTotal(res.data.metadata.total); const totalPages = firstRes.data.metadata?.total_pages ?? 1;
let allMembers = firstBatch;
if (totalPages > 1) {
const restResponses = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, idx) => getTeamMembers(idx + 2, batchSize)),
);
const restBatches = restResponses.flatMap((res) => res.data.data ?? []);
allMembers = [...firstBatch, ...restBatches];
}
setMembers(allMembers);
const initialStatuses: Record<number, boolean> = {}; const initialStatuses: Record<number, boolean> = {};
data.forEach((m) => { allMembers.forEach((m) => {
initialStatuses[m.id] = m.status === "active"; initialStatuses[m.id] = m.status === "active";
}); });
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses })); setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }));
} catch (error) { } catch (error) {
console.error("Failed to fetch team members:", error); console.error("Failed to fetch team members:", error);
setMembers([]); setMembers([]);
setTotal(0); } finally {
setLoading(false);
} }
}; };
fetchMembers(); fetchMembers();
}, [page, pageSize]); }, []);
const filteredMembers = useMemo(() => {
return members.filter((member) => {
const q = search.trim().toLowerCase();
const matchesSearch =
!q ||
`${member.first_name} ${member.last_name}`.toLowerCase().includes(q) ||
member.email.toLowerCase().includes(q);
const roleValue = normalizeFilterValue(member.team_role || "");
const statusValue = normalizeFilterValue(member.status || "");
const matchesRole = !roleFilter || roleValue === normalizeFilterValue(roleFilter);
const matchesStatus = !statusFilter || statusValue === normalizeFilterValue(statusFilter);
return matchesSearch && matchesRole && matchesStatus;
});
}, [members, search, roleFilter, statusFilter]);
useEffect(() => {
setPage(1);
}, [search, roleFilter, statusFilter, pageSize]);
const total = filteredMembers.length;
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 startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
const endEntry = Math.min(safePage * pageSize, total);
const paginatedMembers = useMemo(() => {
const start = (safePage - 1) * pageSize;
return filteredMembers.slice(start, start + pageSize);
}, [filteredMembers, safePage, pageSize]);
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);
@ -152,6 +192,9 @@ export function TeamManagementPage() {
setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" })); setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" }));
try { try {
await updateTeamMemberStatus(id, newStatus); await updateTeamMemberStatus(id, newStatus);
setMembers((prev) =>
prev.map((member) => (member.id === id ? { ...member, status: newStatus } : member)),
);
toast.success( toast.success(
`${name || "Team member"} ${newStatus === "active" ? "activated" : "deactivated"} successfully`, `${name || "Team member"} ${newStatus === "active" ? "activated" : "deactivated"} successfully`,
); );
@ -230,13 +273,9 @@ export function TeamManagementPage() {
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" /> <ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div> </div>
<Button variant="outline" className="shrink-0">
<SlidersHorizontal className="h-4 w-4" />
More Filters
</Button>
</div> </div>
<div className="rounded-lg border bg-white"> <div className="rounded-xl border bg-white">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -250,21 +289,30 @@ export function TeamManagementPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{members.length === 0 ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-grayScale-400"> <TableCell colSpan={6} className="py-12 text-center text-sm text-grayScale-400">
No team members found Loading team members...
</TableCell>
</TableRow>
) : filteredMembers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center gap-2">
<p className="text-sm font-medium text-grayScale-500">No team members found</p>
<p className="text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
members.map((member) => { paginatedMembers.map((member) => {
const initials = `${member.first_name?.[0] ?? ""}${member.last_name?.[0] ?? ""}`.toUpperCase(); const initials = `${member.first_name?.[0] ?? ""}${member.last_name?.[0] ?? ""}`.toUpperCase();
const isActive = toggledStatuses[member.id] ?? false; const isActive = toggledStatuses[member.id] ?? false;
return ( return (
<TableRow <TableRow
key={member.id} key={member.id}
className="cursor-pointer hover:bg-grayScale-50" className="group cursor-pointer"
onClick={() => navigate(`/team/${member.id}`)} onClick={() => navigate(`/team/${member.id}`)}
> >
<TableCell> <TableCell>
@ -348,7 +396,14 @@ export function TeamManagementPage() {
<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-sm text-grayScale-500">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>Row Per Page</span> <span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{total}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative"> <div className="relative">
<select <select
value={pageSize} value={pageSize}
@ -366,7 +421,6 @@ export function TeamManagementPage() {
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" /> <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>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@ -38,6 +38,7 @@ import {
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api"; import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types"; import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
// ── Action type configuration ────────────────────────────────────── // ── Action type configuration ──────────────────────────────────────
const ACTION_TYPES = [ const ACTION_TYPES = [
@ -250,7 +251,7 @@ export function UserLogPage() {
fetchLogs(); fetchLogs();
}} }}
> >
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} /> {loading ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
Refresh Refresh
</Button> </Button>
</div> </div>
@ -566,7 +567,7 @@ export function UserLogPage() {
{detailLoading ? ( {detailLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-grayScale-300" /> <SpinnerIcon className="h-6 w-6" />
</div> </div>
) : selectedLog ? ( ) : selectedLog ? (
<div className="space-y-4"> <div className="space-y-4">

View File

@ -0,0 +1,642 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Search, SlidersHorizontal } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import { getDeletionRequests } from "../../api/users.api"
import { getRoles } from "../../api/rbac.api"
import { cn } from "../../lib/utils"
import type {
DeletionRequest,
DeletionState,
DeletionUserStatus,
GetDeletionRequestsParams,
} from "../../types/user.types"
import type { Role } from "../../types/rbac.types"
import { mapDeletionRequestApiItem } from "../../types/user.types"
const stateBadge: Record<string, string> = {
PENDING: "bg-amber-100 text-amber-700",
DUE: "bg-brand-100 text-brand-600",
CANCELLED: "bg-grayScale-200 text-grayScale-600",
}
const DATE_PLACEHOLDER = "DD-MM-YYYY HH:mm"
const STATUS_OPTIONS: DeletionUserStatus[] = ["ACTIVE", "PENDING", "SUSPENDED", "DEACTIVATED"]
const STATE_OPTIONS: DeletionState[] = ["PENDING", "DUE", "CANCELLED"]
type DateTimeFilterInputProps = {
value: string
onChange: (value: string) => void
placeholder: string
}
function DateTimeFilterInput({ value, onChange, placeholder }: DateTimeFilterInputProps) {
return (
<div className="relative">
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
type="datetime-local"
value={value}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => {
// Opens native date-time picker immediately on supported browsers.
(e.currentTarget as HTMLInputElement).showPicker?.()
}}
aria-label={placeholder}
className="h-11 rounded-xl border-grayScale-200 bg-white pl-10 pr-3 text-sm shadow-sm transition focus-visible:border-brand-400 focus-visible:ring-brand-200 [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-70 hover:[&::-webkit-calendar-picker-indicator]:opacity-100"
/>
</div>
)
}
export function DeletionRequestsPage() {
const [items, setItems] = useState<DeletionRequest[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState("")
const [role, setRole] = useState("")
const [roleOptions, setRoleOptions] = useState<string[]>([])
const [rolesLoading, setRolesLoading] = useState(false)
const [roleMenuOpen, setRoleMenuOpen] = useState(false)
const [roleSearch, setRoleSearch] = useState("")
const [statusMenuOpen, setStatusMenuOpen] = useState(false)
const [stateMenuOpen, setStateMenuOpen] = useState(false)
const [status, setStatus] = useState<DeletionUserStatus | "">("")
const [state, setState] = useState<DeletionState | "">("")
const [requestedAfter, setRequestedAfter] = useState("")
const [requestedBefore, setRequestedBefore] = useState("")
const [scheduledAfter, setScheduledAfter] = useState("")
const [scheduledBefore, setScheduledBefore] = useState("")
const roleMenuRef = useRef<HTMLDivElement | null>(null)
const statusMenuRef = useRef<HTMLDivElement | null>(null)
const stateMenuRef = useRef<HTMLDivElement | null>(null)
const toRfc3339 = (value: string) => {
if (!value) return undefined
const normalized = value.includes("T") ? value : value.replace(" ", "T")
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params: GetDeletionRequestsParams = {
query: query || undefined,
role: role || undefined,
status: status || undefined,
state: state || undefined,
requested_after: toRfc3339(requestedAfter),
requested_before: toRfc3339(requestedBefore),
scheduled_after: toRfc3339(scheduledAfter),
scheduled_before: toRfc3339(scheduledBefore),
page,
page_size: pageSize,
}
const res = await getDeletionRequests(params)
const rows = res.data?.data?.items ?? []
setItems(rows.map(mapDeletionRequestApiItem))
setTotal(res.data?.data?.total ?? 0)
} catch (error) {
console.error("Failed to fetch deletion requests:", error)
setItems([])
setTotal(0)
} finally {
setLoading(false)
}
}, [
query,
role,
status,
state,
requestedAfter,
requestedBefore,
scheduledAfter,
scheduledBefore,
page,
pageSize,
])
useEffect(() => {
fetchData()
}, [fetchData])
useEffect(() => {
const fetchRoles = async () => {
setRolesLoading(true)
try {
const pageSize = 50
const firstRes = await getRoles({ page: 1, page_size: pageSize })
const firstBatch = firstRes.data?.data?.roles ?? []
const total = firstRes.data?.data?.total ?? firstBatch.length
const totalPages = Math.max(1, Math.ceil(total / pageSize))
let allRoles: Role[] = firstBatch
if (totalPages > 1) {
const requests: Array<ReturnType<typeof getRoles>> = []
for (let currentPage = 2; currentPage <= totalPages; currentPage += 1) {
requests.push(getRoles({ page: currentPage, page_size: pageSize }))
}
const remaining = await Promise.all(requests)
allRoles = [...firstBatch, ...remaining.flatMap((r) => r.data?.data?.roles ?? [])]
}
const uniqueNames = Array.from(
new Set(
allRoles
.map((r) => r.name?.trim())
.filter((name): name is string => Boolean(name)),
),
).sort((a, b) => a.localeCompare(b))
setRoleOptions(uniqueNames)
} catch (error) {
console.error("Failed to fetch role options:", error)
setRoleOptions([])
} finally {
setRolesLoading(false)
}
}
fetchRoles()
}, [])
useEffect(() => {
if (!roleMenuOpen && !statusMenuOpen && !stateMenuOpen) return
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as Node
if (roleMenuRef.current && !roleMenuRef.current.contains(target)) {
setRoleMenuOpen(false)
}
if (statusMenuRef.current && !statusMenuRef.current.contains(target)) {
setStatusMenuOpen(false)
}
if (stateMenuRef.current && !stateMenuRef.current.contains(target)) {
setStateMenuOpen(false)
}
}
document.addEventListener("mousedown", handleOutsideClick)
return () => {
document.removeEventListener("mousedown", handleOutsideClick)
}
}, [roleMenuOpen, statusMenuOpen, stateMenuOpen])
const filteredRoleOptions = useMemo(() => {
const q = roleSearch.trim().toLowerCase()
if (!q) return roleOptions
return roleOptions.filter((option) => option.toLowerCase().includes(q))
}, [roleOptions, roleSearch])
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / pageSize)), [total, pageSize])
const safePage = Math.min(page, totalPages)
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, total)
const getPageNumbers = () => {
const pages: (number | string)[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1, 2, 3)
if (safePage > 4) pages.push("...")
if (safePage > 3 && safePage < totalPages - 2) pages.push(safePage)
if (safePage < totalPages - 3) pages.push("...")
pages.push(totalPages)
}
return pages
}
const resetFilters = () => {
setQuery("")
setRole("")
setStatus("")
setState("")
setRequestedAfter("")
setRequestedBefore("")
setScheduledAfter("")
setScheduledBefore("")
setRoleSearch("")
setRoleMenuOpen(false)
setStatusMenuOpen(false)
setStateMenuOpen(false)
setPage(1)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-grayScale-600">Deletion Requests</h1>
<p className="mt-1 text-sm text-grayScale-400">
Review and monitor account deletion requests from users.
</p>
</div>
<Card className="overflow-visible rounded-2xl border border-brand-100/70 bg-white shadow-soft">
<CardHeader className="border-b border-brand-100/70 bg-gradient-to-r from-brand-100/50 via-white to-brand-100/25 pb-4">
<CardTitle className="flex items-center gap-2 text-base font-semibold text-grayScale-700">
<SlidersHorizontal className="h-4 w-4 text-brand-600" />
Filters
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 bg-gradient-to-b from-white to-brand-100/10 pt-5">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<div className="relative lg:col-span-2">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(1)
}}
placeholder="Search name, email, phone..."
className="h-11 rounded-xl border-grayScale-200 bg-white pl-10 shadow-sm transition focus-visible:border-brand-400 focus-visible:ring-brand-200"
/>
</div>
<div className="relative" ref={roleMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setRoleMenuOpen((prev) => !prev)
setStatusMenuOpen(false)
setStateMenuOpen(false)
if (!roleMenuOpen) setRoleSearch("")
}}
>
<span>{rolesLoading ? "Loading roles..." : role || "Role"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{roleMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-2 shadow-lg">
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-grayScale-300" />
<Input
value={roleSearch}
onChange={(e) => setRoleSearch(e.target.value)}
placeholder="Search role..."
className="h-9 rounded-lg border-grayScale-200 pl-8 text-sm"
/>
</div>
<div className="max-h-56 space-y-1 overflow-auto">
<button
type="button"
className={cn(
"w-full rounded-md px-2 py-2 text-left text-sm transition",
role === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setRole("")
setPage(1)
setRoleMenuOpen(false)
}}
>
All Roles
</button>
{filteredRoleOptions.map((roleName) => (
<button
key={roleName}
type="button"
className={cn(
"w-full rounded-md px-2 py-2 text-left text-sm transition",
role === roleName
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setRole(roleName)
setPage(1)
setRoleMenuOpen(false)
}}
>
{roleName}
</button>
))}
{!rolesLoading && filteredRoleOptions.length === 0 ? (
<p className="px-2 py-1.5 text-sm text-grayScale-400">No roles found</p>
) : null}
</div>
</div>
) : null}
</div>
<div className="relative" ref={statusMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setStatusMenuOpen((prev) => !prev)
setStateMenuOpen(false)
setRoleMenuOpen(false)
}}
>
<span>{status || "All Statuses"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{statusMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-1.5 shadow-lg">
<button
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
status === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setStatus("")
setPage(1)
setStatusMenuOpen(false)
}}
>
All Statuses
</button>
{STATUS_OPTIONS.map((statusOption) => (
<button
key={statusOption}
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
status === statusOption
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setStatus(statusOption)
setPage(1)
setStatusMenuOpen(false)
}}
>
{statusOption}
</button>
))}
</div>
) : null}
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<div className="relative" ref={stateMenuRef}>
<button
type="button"
className="flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600 shadow-sm transition hover:bg-grayScale-50 focus:outline-none focus-visible:border-brand-400 focus-visible:ring-2 focus-visible:ring-brand-200"
onClick={() => {
setStateMenuOpen((prev) => !prev)
setStatusMenuOpen(false)
setRoleMenuOpen(false)
}}
>
<span>{state || "All States"}</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</button>
{stateMenuOpen ? (
<div className="absolute z-30 mt-1 w-full rounded-xl border border-grayScale-200 bg-white p-1.5 shadow-lg">
<button
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
state === ""
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setState("")
setPage(1)
setStateMenuOpen(false)
}}
>
All States
</button>
{STATE_OPTIONS.map((stateOption) => (
<button
key={stateOption}
type="button"
className={cn(
"w-full rounded-lg px-2.5 py-2 text-left text-sm transition",
state === stateOption
? "bg-brand-100/50 text-brand-700"
: "text-grayScale-600 hover:bg-grayScale-100",
)}
onClick={() => {
setState(stateOption)
setPage(1)
setStateMenuOpen(false)
}}
>
{stateOption}
</button>
))}
</div>
) : null}
</div>
<DateTimeFilterInput
value={requestedAfter}
onChange={(value) => {
setRequestedAfter(value)
setPage(1)
}}
placeholder={`Requested after (${DATE_PLACEHOLDER})`}
/>
<DateTimeFilterInput
value={requestedBefore}
onChange={(value) => {
setRequestedBefore(value)
setPage(1)
}}
placeholder={`Requested before (${DATE_PLACEHOLDER})`}
/>
<DateTimeFilterInput
value={scheduledAfter}
onChange={(value) => {
setScheduledAfter(value)
setPage(1)
}}
placeholder={`Scheduled after (${DATE_PLACEHOLDER})`}
/>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<DateTimeFilterInput
value={scheduledBefore}
onChange={(value) => {
setScheduledBefore(value)
setPage(1)
}}
placeholder={`Scheduled before (${DATE_PLACEHOLDER})`}
/>
<div className="flex justify-end lg:col-start-4 lg:col-span-1">
<Button
variant="outline"
className="h-11 rounded-xl border-brand-200 px-5 text-brand-700 hover:bg-brand-100/40"
onClick={resetFilters}
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">
Requests ({total})
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
<div className="rounded-xl border bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role / Status</TableHead>
<TableHead className="hidden md:table-cell">Requested At</TableHead>
<TableHead className="hidden md:table-cell">Scheduled At</TableHead>
<TableHead>Deletion State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<img src={spinnerSrc} alt="" className="h-6 w-6 animate-spin" />
<span className="text-sm text-grayScale-400">Loading requests...</span>
</div>
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<Search className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No deletion requests found</p>
<p className="mt-1 text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={`${item.user_id}-${index}`} className="group">
<TableCell className="py-3.5">
<p className="text-sm font-medium text-grayScale-700">
{item.first_name} {item.last_name}
</p>
<p className="text-xs text-grayScale-500">{item.email}</p>
<p className="text-xs text-grayScale-500">{item.phone_number || "—"}</p>
</TableCell>
<TableCell className="py-3.5">
<p className="text-sm text-grayScale-700">{item.role || "—"}</p>
<p className="text-xs text-grayScale-500">{item.status || "—"}</p>
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-600 md:table-cell">
{item.deletion_requested_at || "—"}
</TableCell>
<TableCell className="hidden py-3.5 text-sm text-grayScale-600 md:table-cell">
{item.deletion_scheduled_at || "—"}
</TableCell>
<TableCell className="py-3.5">
<Badge className={stateBadge[item.deletion_state] || "bg-grayScale-200 text-grayScale-600"}>
{item.deletion_state || "—"}
</Badge>
</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>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{total}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows 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"
>
{[10, 20, 50, 100].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => safePage > 1 && setPage(safePage - 1)}
disabled={safePage === 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === 1 && "cursor-not-allowed opacity-50",
)}
>
<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={() => safePage < totalPages && setPage(safePage + 1)}
disabled={safePage === totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
safePage === totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,180 +0,0 @@
import { useState } from "react"
import { ArrowLeft, FileText, Mail, Phone, Shield, User } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Card } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { createUser } from "../../api/users.api"
export function RegisterUserPage() {
const navigate = useNavigate()
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [role, setRole] = useState("")
const [notes, setNotes] = useState("")
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !role) {
toast.error("Missing required fields", {
description: "Please fill in first name, last name, email, phone, and role.",
})
return
}
setSubmitting(true)
try {
await createUser({
first_name: firstName.trim(),
last_name: lastName.trim(),
email: email.trim(),
phone_number: phone.trim(),
role: role.toUpperCase(),
notes: notes.trim() || undefined,
})
toast.success("User registered", {
description: `${firstName} ${lastName} has been created successfully.`,
})
navigate("/users")
} catch (error: any) {
const message =
error?.response?.data?.message ||
"Failed to register user. Please check the details and try again."
toast.error("Registration failed", {
description: message,
})
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/users")} className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold text-grayScale-600">Register New User</h1>
<p className="text-sm text-grayScale-400">Add a new user to the system</p>
</div>
</div>
<Card className="mx-auto max-w-2xl p-6">
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
First Name
</label>
<Input
placeholder="Enter first name"
required
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<User className="h-4 w-4" />
Last Name
</label>
<Input
placeholder="Enter last name"
required
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Mail className="h-4 w-4" />
Email
</label>
<Input
type="email"
placeholder="Enter email address"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Phone className="h-4 w-4" />
Phone
</label>
<Input
type="tel"
placeholder="Enter phone number"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<Shield className="h-4 w-4" />
Role
</label>
<Select
required
value={role}
onChange={(e) => setRole(e.target.value)}
>
<option value="">Select role</option>
<option value="ADMIN">Admin</option>
<option value="USER">User</option>
</Select>
</div>
<div>
<label className="mb-2 flex items-center gap-1.5 text-sm font-medium text-grayScale-600">
<FileText className="h-4 w-4" />
Notes
</label>
<Textarea
placeholder="Enter any additional notes"
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => navigate("/users")}
disabled={submitting}
>
Cancel
</Button>
<Button
type="submit"
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto"
disabled={submitting}
>
{submitting ? "Registering..." : "Register User"}
</Button>
</div>
</form>
</Card>
</div>
)
}

View File

@ -39,6 +39,7 @@ import {
TableRow, TableRow,
} from "../../components/ui/table"; } from "../../components/ui/table";
import { Select } from "../../components/ui/select"; import { Select } from "../../components/ui/select";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types"; import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
import type { Course } from "../../types/course.types"; import type { Course } from "../../types/course.types";
@ -475,7 +476,7 @@ export function UserDetailPage() {
{!progressError && loadingProgress && ( {!progressError && loadingProgress && (
<div className="flex items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-100 px-3 py-2 text-xs text-grayScale-500"> <div className="flex items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-100 px-3 py-2 text-xs text-grayScale-500">
<RefreshCw className="h-3.5 w-3.5 animate-spin" /> <SpinnerIcon className="h-3.5 w-3.5" />
Loading learner progress... Loading learner progress...
</div> </div>
)} )}
@ -487,10 +488,10 @@ export function UserDetailPage() {
)} )}
{!progressError && !loadingProgress && progressItems.length > 0 && ( {!progressError && !loadingProgress && progressItems.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-grayScale-200"> <div className="overflow-x-auto rounded-xl border bg-white">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-grayScale-100/70"> <TableRow>
<TableHead>Course</TableHead> <TableHead>Course</TableHead>
<TableHead>Level</TableHead> <TableHead>Level</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>

View File

@ -2,15 +2,15 @@ import { useEffect, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { import {
Users, Users,
UserPlus, UserX,
UserCheck, UserCheck,
TrendingUp, TrendingUp,
ArrowRight, ArrowRight,
List, List,
UsersRound, UsersRound,
Loader2,
} from "lucide-react" } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { getDashboard } from "../../api/analytics.api" import { getDashboard } from "../../api/analytics.api"
import type { DashboardUsers } from "../../types/analytics.types" import type { DashboardUsers } from "../../types/analytics.types"
@ -57,7 +57,7 @@ export function UserManagementDashboard() {
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-white/80">Total Users</p> <p className="text-sm font-medium text-white/80">Total Users</p>
<p className="text-2xl font-bold text-white"> <p className="text-2xl font-bold text-white">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.total_users) : "—"} {statsLoading ? <SpinnerIcon className="h-5 w-5" /> : stats ? formatNum(stats.total_users) : "—"}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -72,7 +72,7 @@ export function UserManagementDashboard() {
<p className="text-sm font-medium text-white/80">Active Users</p> <p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white"> <p className="text-2xl font-bold text-white">
{statsLoading ? ( {statsLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" /> <SpinnerIcon className="h-5 w-5" />
) : activeUsers !== null ? ( ) : activeUsers !== null ? (
formatNum(activeUsers) formatNum(activeUsers)
) : ( ) : (
@ -92,7 +92,7 @@ export function UserManagementDashboard() {
<p className="text-sm font-medium text-white/80">New This Month</p> <p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white"> <p className="text-2xl font-bold text-white">
{statsLoading ? ( {statsLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" /> <SpinnerIcon className="h-5 w-5" />
) : stats ? ( ) : stats ? (
formatNum(stats.new_month) formatNum(stats.new_month)
) : ( ) : (
@ -108,22 +108,22 @@ export function UserManagementDashboard() {
<div> <div>
<h2 className="mb-4 text-lg font-semibold text-grayScale-600">Quick Actions</h2> <h2 className="mb-4 text-lg font-semibold text-grayScale-600">Quick Actions</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link to="/users/register" className="group"> <Link to="/users/deletion-requests" className="group">
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md"> <Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white"> <div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
<UserPlus className="h-5 w-5" /> <UserX className="h-5 w-5" />
</div> </div>
<CardTitle className="text-base font-semibold text-grayScale-600"> <CardTitle className="text-base font-semibold text-grayScale-600">
Register User Deletion Requests
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-grayScale-400"> <CardDescription className="text-sm text-grayScale-400">
Add new users to the system with role assignment. Review account deletion requests and user deletion states.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600"> <span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
Get started View requests
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" /> <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span> </span>
</CardContent> </CardContent>

View File

@ -1,4 +1,4 @@
import { ChevronDown, ChevronLeft, ChevronRight, Search, Users, X } from "lucide-react" import { ChevronDown, ChevronLeft, ChevronRight, Search, UserCheck, Users, X } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
@ -36,9 +36,11 @@ export function UsersListPage() {
} | null>(null) } | null>(null)
const [roleFilter, setRoleFilter] = useState("") const [roleFilter, setRoleFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("") const [statusFilter, setStatusFilter] = useState("")
const [loading, setLoading] = useState(false)
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true)
try { try {
const res = await getUsers( const res = await getUsers(
page, page,
@ -62,6 +64,8 @@ export function UsersListPage() {
console.error("Failed to fetch users:", error) console.error("Failed to fetch users:", error)
setUsers([]) setUsers([])
setTotal(0) setTotal(0)
} finally {
setLoading(false)
} }
} }
@ -93,6 +97,9 @@ export function UsersListPage() {
} }
const allSelected = users.length > 0 && selectedIds.size === users.length const allSelected = users.length > 0 && selectedIds.size === users.length
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, total)
const activeUsersOnPage = users.filter((u) => (u.status || "").toUpperCase() === "ACTIVE").length
const getPageNumbers = () => { const getPageNumbers = () => {
const pages: (number | string)[] = [] const pages: (number | string)[] = []
@ -169,6 +176,37 @@ export function UsersListPage() {
<p className="text-sm text-grayScale-400">View and manage all registered users.</p> <p className="text-sm text-grayScale-400">View and manage all registered users.</p>
</div> </div>
{/* Stats cards (match UserLogPage approach) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
<Users className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{total}</p>
<p className="text-xs text-grayScale-400">Total Users</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
<UserCheck className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{activeUsersOnPage}</p>
<p className="text-xs text-grayScale-400">Active In Current Page</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
<Search className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-grayScale-600">{users.length}</p>
<p className="text-xs text-grayScale-400">Showing Results</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl border"> <div className="bg-white rounded-xl border">
{/* Search & Filters */} {/* Search & Filters */}
<div className="p-4 border-b"> <div className="p-4 border-b">
@ -238,7 +276,13 @@ export function UsersListPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.length === 0 ? ( {loading ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center">
<p className="text-sm text-grayScale-400">Loading users...</p>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-16 text-center"> <TableCell colSpan={7} className="py-16 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
@ -259,7 +303,7 @@ export function UsersListPage() {
return ( return (
<TableRow <TableRow
key={u.id} key={u.id}
className="cursor-pointer hover:bg-grayScale-50" className="group cursor-pointer"
onClick={() => handleRowClick(u.id)} onClick={() => handleRowClick(u.id)}
> >
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell onClick={(e) => e.stopPropagation()}>
@ -318,9 +362,16 @@ export function UsersListPage() {
</Table> </Table>
{/* Pagination */} {/* Pagination */}
<div className="flex flex-col items-center gap-3 border-t px-4 py-3 text-sm text-grayScale-500 sm:flex-row sm:justify-between"> <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"> <div className="flex items-center gap-2">
<span>Row Per Page</span> <span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{total}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative"> <div className="relative">
<select <select
value={pageSize} value={pageSize}
@ -338,7 +389,6 @@ export function UsersListPage() {
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" /> <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>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@ -260,6 +260,21 @@ export interface CreateSubCourseVideoRequest {
video_url: string video_url: string
} }
export type VideoVisibility = "PUBLISHED" | "DRAFT" | "PRIVATE" | "UNLISTED" | string
export type VideoStatus = "PUBLISHED" | "DRAFT" | "ARCHIVED" | string
export interface CreateCourseVideoRequest {
sub_course_id: number
title: string
description: string
video_url: string
duration: number
resolution?: string
visibility?: VideoVisibility
display_order?: number
status?: VideoStatus
}
export interface CreateVimeoVideoRequest { export interface CreateVimeoVideoRequest {
sub_course_id: number sub_course_id: number
title: string title: string
@ -484,6 +499,7 @@ export interface CreateQuestionRequest {
tips?: string tips?: string
explanation?: string explanation?: string
status?: string status?: string
image_url?: string
options?: QuestionOption[] options?: QuestionOption[]
voice_prompt?: string voice_prompt?: string
sample_answer_voice_prompt?: string sample_answer_voice_prompt?: string
@ -518,6 +534,7 @@ export interface QuestionDetail {
short_answers?: string[] | QuestionShortAnswer[] short_answers?: string[] | QuestionShortAnswer[]
tips?: string | null tips?: string | null
explanation?: string | null explanation?: string | null
image_url?: string | null
voice_prompt?: string | null voice_prompt?: string | null
sample_answer_voice_prompt?: string | null sample_answer_voice_prompt?: string | null
audio_correct_answer_text?: string | null audio_correct_answer_text?: string | null

View File

@ -30,6 +30,22 @@ export interface CreateTeamMemberRequest {
bio?: string bio?: string
} }
export interface UpdateTeamMemberRequest {
bio?: string
department?: string
emergency_contact?: string
employment_type?: string
first_name?: string
hire_date?: string
job_title?: string
last_name?: string
permissions?: string[]
phone_number?: string
profile_picture_url?: string
team_role?: string
work_phone?: string
}
export interface TeamMembersMetadata { export interface TeamMembersMetadata {
total: number total: number
total_pages: number total_pages: number

View File

@ -146,3 +146,85 @@ export interface UpdateProfileRequest {
profile_picture_url?: string profile_picture_url?: string
preferred_language?: string preferred_language?: string
} }
export type DeletionUserStatus = "ACTIVE" | "PENDING" | "SUSPENDED" | "DEACTIVATED"
export type DeletionState = "PENDING" | "DUE" | "CANCELLED"
export interface DeletionRequestApiItem {
UserID?: number
FirstName?: string
LastName?: string
Email?: string
PhoneNumber?: string
Role?: string
Status?: DeletionUserStatus | string
DeletionRequestedAt?: string | null
DeletionScheduledAt?: string | null
DeletionCancelledAt?: string | null
DeletionState?: DeletionState | string
user_id?: number
first_name?: string
last_name?: string
email?: string
phone_number?: string
role?: string
status?: DeletionUserStatus | string
deletion_requested_at?: string | null
deletion_scheduled_at?: string | null
deletion_cancelled_at?: string | null
deletion_state?: DeletionState | string
}
export interface DeletionRequest {
user_id: number
first_name: string
last_name: string
email: string
phone_number: string
role: string
status: string
deletion_requested_at: string | null
deletion_scheduled_at: string | null
deletion_cancelled_at: string | null
deletion_state: string
}
export interface GetDeletionRequestsParams {
query?: string
role?: string
status?: DeletionUserStatus
state?: DeletionState
requested_before?: string
requested_after?: string
scheduled_before?: string
scheduled_after?: string
page?: number
page_size?: number
}
export interface GetDeletionRequestsResponse {
status: string
message: string
data: {
items: DeletionRequestApiItem[]
total: number
page: number
page_size: number
}
timestamp: string
}
export const mapDeletionRequestApiItem = (item: DeletionRequestApiItem): DeletionRequest => ({
user_id: item.user_id ?? item.UserID ?? 0,
first_name: item.first_name ?? item.FirstName ?? "",
last_name: item.last_name ?? item.LastName ?? "",
email: item.email ?? item.Email ?? "",
phone_number: item.phone_number ?? item.PhoneNumber ?? "",
role: item.role ?? item.Role ?? "",
status: item.status ?? item.Status ?? "",
deletion_requested_at: item.deletion_requested_at ?? item.DeletionRequestedAt ?? null,
deletion_scheduled_at: item.deletion_scheduled_at ?? item.DeletionScheduledAt ?? null,
deletion_cancelled_at: item.deletion_cancelled_at ?? item.DeletionCancelledAt ?? null,
deletion_state: item.deletion_state ?? item.DeletionState ?? "",
})