Compare commits
2 Commits
8180e64f59
...
d99142f70e
| Author | SHA1 | Date | |
|---|---|---|---|
| d99142f70e | |||
| 7c1687787b |
4
.env
4
.env
|
|
@ -1,3 +1,3 @@
|
|||
# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1
|
||||
VITE_API_BASE_URL=http://localhost:8432/api/v1
|
||||
VITE_API_BASE_URL=https://api.yimaruacademy.com/api/v1
|
||||
# VITE_API_BASE_URL=http://localhost:8432/api/v1
|
||||
VITE_GOOGLE_CLIENT_ID=
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import type {
|
|||
GetRatingsResponse,
|
||||
GetRatingsParams,
|
||||
GetVimeoSampleResponse,
|
||||
CreateCourseVideoRequest,
|
||||
} from "../types/course.types"
|
||||
|
||||
export const getCourseCategories = () =>
|
||||
|
|
@ -65,6 +66,11 @@ export const getCoursesByCategory = (categoryId: number) =>
|
|||
export const createCourse = (data: CreateCourseRequest) =>
|
||||
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) =>
|
||||
http.delete(`/course-management/courses/${courseId}`)
|
||||
|
||||
|
|
@ -81,6 +87,11 @@ export const getSubCoursesByCourse = (courseId: number) =>
|
|||
export const createSubCourse = (data: CreateSubCourseRequest) =>
|
||||
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) =>
|
||||
http.patch(`/course-management/sub-courses/${subCourseId}`, data)
|
||||
|
||||
|
|
@ -97,6 +108,9 @@ export const getVideosBySubCourse = (subCourseId: number) =>
|
|||
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
http.delete(`/question-sets/${questionSetId}`)
|
||||
|
||||
|
|
|
|||
61
src/api/files.api.ts
Normal file
61
src/api/files.api.ts
Normal 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 },
|
||||
})
|
||||
|
||||
|
|
@ -2,9 +2,9 @@ import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestCo
|
|||
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
// Do not force a Content-Type globally.
|
||||
// Axios will set the correct header based on the request body (JSON vs multipart FormData).
|
||||
headers: {},
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
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) =>
|
||||
http.get<GetTeamMembersResponse>("/team/members", {
|
||||
|
|
@ -17,3 +22,6 @@ export const createTeamMember = (data: CreateTeamMemberRequest) =>
|
|||
|
||||
export const updateTeamMemberStatus = (id: number, status: string) =>
|
||||
http.patch(`/team/members/${id}/status`, { status })
|
||||
|
||||
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
|
||||
http.put(`/team/members/${id}`, data)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
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 = (
|
||||
page?: number,
|
||||
|
|
@ -53,3 +60,11 @@ export const updateProfile = (data: UpdateProfileRequest) =>
|
|||
|
||||
export const getUserSummary = () =>
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { UserManagementLayout } from "../pages/user-management/UserManagementLay
|
|||
import { UsersListPage } from "../pages/user-management/UsersListPage"
|
||||
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard"
|
||||
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 { RolesListPage } from "../pages/role-management/RolesListPage"
|
||||
import { AddRolePage } from "../pages/role-management/AddRolePage"
|
||||
|
|
@ -40,6 +40,10 @@ import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
|||
import { LoginPage } from "../pages/auth/LoginPage"
|
||||
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
|
||||
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() {
|
||||
return (
|
||||
|
|
@ -47,13 +51,17 @@ export function AppRoutes() {
|
|||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<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 path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UserManagementLayout />}>
|
||||
<Route index element={<UserManagementDashboard />} />
|
||||
<Route path="list" element={<UsersListPage />} />
|
||||
<Route path="register" element={<RegisterUserPage />} />
|
||||
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
|
||||
<Route path="groups" element={<UserGroupsPage />} />
|
||||
<Route path=":id" element={<UserDetailPage />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@ import {
|
|||
BookOpen,
|
||||
Video,
|
||||
ShieldAlert,
|
||||
Loader2,
|
||||
MailOpen,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
} from "lucide-react"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||
import { useNotifications } from "../../hooks/useNotifications"
|
||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
||||
|
||||
|
|
@ -216,7 +216,7 @@ export function NotificationDropdown() {
|
|||
<div className="max-h-[480px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<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>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const DropdownMenuSubTrigger = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
|
|
@ -37,7 +37,7 @@ export const DropdownMenuSubContent = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -54,7 +54,7 @@ export const DropdownMenuContent = React.forwardRef<
|
|||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -70,7 +70,7 @@ export const DropdownMenuItem = React.forwardRef<
|
|||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
|
|
@ -86,7 +86,7 @@ export const DropdownMenuCheckboxItem = React.forwardRef<
|
|||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
checked={checked}
|
||||
|
|
@ -109,7 +109,7 @@ export const DropdownMenuRadioItem = React.forwardRef<
|
|||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -130,7 +130,7 @@ export const DropdownMenuLabel = React.forwardRef<
|
|||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
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}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|||
<div className="relative">
|
||||
<select
|
||||
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,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
12
src/components/ui/spinner-icon.tsx
Normal file
12
src/components/ui/spinner-icon.tsx
Normal 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)} />
|
||||
}
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ export interface StepperProps {
|
|||
|
||||
export function Stepper({ steps, currentStep, className }: StepperProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between", className)}>
|
||||
<div className={cn("flex w-full items-center", className)}>
|
||||
{steps.map((step, index) => {
|
||||
const stepNumber = index + 1
|
||||
const isCompleted = stepNumber < currentStep
|
||||
|
|
@ -18,13 +18,14 @@ export function Stepper({ steps, currentStep, className }: StepperProps) {
|
|||
|
||||
return (
|
||||
<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={cn(
|
||||
"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",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
|
@ -44,8 +45,10 @@ export function Stepper({ steps, currentStep, className }: StepperProps) {
|
|||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 h-0.5 flex-1",
|
||||
isCompleted ? "bg-brand-500" : "bg-grayScale-200",
|
||||
// Keep the connector visually continuous with the step circles.
|
||||
"mx-2 h-0.5 flex-1",
|
||||
// Color the track up to the current step.
|
||||
isCompleted || isCurrent ? "bg-brand-500" : "bg-grayScale-200",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ export function useNotifications() {
|
|||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
|
|
|
|||
28
src/pages/AboutPage.tsx
Normal file
28
src/pages/AboutPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/pages/AccountDeletionPage.tsx
Normal file
50
src/pages/AccountDeletionPage.tsx
Normal 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
35
src/pages/PrivacyPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,35 +1,23 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Globe,
|
||||
Loader2,
|
||||
Mail,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Phone,
|
||||
Save,
|
||||
Shield,
|
||||
User,
|
||||
X,
|
||||
XCircle,
|
||||
Briefcase,
|
||||
BookOpen,
|
||||
Target,
|
||||
Languages,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ChangeEvent } from "react";
|
||||
import { BadgeCheck, Briefcase, CalendarDays, Mail, Phone, Shield, User } from "lucide-react";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Select } from "../components/ui/select";
|
||||
|
||||
import { cn } from "../lib/utils";
|
||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
||||
import type { UserProfileData, UpdateProfileRequest } from "../types/user.types";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { FileUpload } from "../components/ui/file-upload";
|
||||
import { getMyProfile } from "../api/users.api";
|
||||
import { updateTeamMember } from "../api/team.api";
|
||||
import { uploadImageFile } from "../api/files.api";
|
||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
||||
import type { UpdateTeamMemberRequest } from "../types/team.types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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() {
|
||||
return (
|
||||
<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-500/90 group-hover:text-white">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-grayScale-400">
|
||||
{label}
|
||||
</p>
|
||||
{editing && editNode ? (
|
||||
<div className="mt-1">{editNode}</div>
|
||||
) : (
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-grayScale-700">{value || "—"}</p>
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
||||
const [profile, setProfile] = useState<TeamMeProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editForm, setEditForm] = useState<UpdateProfileRequest>({});
|
||||
const [profilePictureFile, setProfilePictureFile] = useState<File | null>(null);
|
||||
const [editForm, setEditForm] = useState<UpdateTeamMemberRequest>({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
phone_number: "",
|
||||
profile_picture_url: "",
|
||||
bio: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await getMyProfile();
|
||||
setProfile(res.data.data);
|
||||
setProfile((res.data?.data ?? null) as unknown as TeamMeProfile | null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch profile", err);
|
||||
setError("Failed to load profile. Please try again later.");
|
||||
|
|
@ -198,52 +131,68 @@ export function ProfilePage() {
|
|||
|
||||
const startEditing = () => {
|
||||
if (!profile) return;
|
||||
setEditForm({
|
||||
const nextForm: UpdateTeamMemberRequest = {
|
||||
first_name: profile.first_name ?? "",
|
||||
last_name: profile.last_name ?? "",
|
||||
nick_name: profile.nick_name ?? "",
|
||||
gender: profile.gender ?? "",
|
||||
birth_day: profile.birth_day ?? "",
|
||||
age_group: profile.age_group ?? "",
|
||||
education_level: profile.education_level ?? "",
|
||||
country: profile.country ?? "",
|
||||
region: profile.region ?? "",
|
||||
occupation: profile.occupation ?? "",
|
||||
learning_goal: profile.learning_goal ?? "",
|
||||
language_goal: profile.language_goal ?? "",
|
||||
language_challange: profile.language_challange ?? "",
|
||||
favoutite_topic: profile.favoutite_topic ?? "",
|
||||
preferred_language: profile.preferred_language ?? "",
|
||||
});
|
||||
phone_number: profile.phone_number ?? "",
|
||||
profile_picture_url: profile.profile_picture_url ?? "",
|
||||
bio: profile.bio ?? "",
|
||||
};
|
||||
setEditForm(nextForm);
|
||||
setProfilePictureFile(null);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setProfilePictureFile(null);
|
||||
setEditing(false);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
const updateField = (field: keyof UpdateTeamMemberRequest, value: string) => {
|
||||
setEditForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
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);
|
||||
try {
|
||||
await updateProfile(editForm);
|
||||
const res = await getMyProfile();
|
||||
setProfile(res.data.data);
|
||||
await updateTeamMember(profile.id, payload);
|
||||
const refreshed = await getMyProfile();
|
||||
setProfile((refreshed.data?.data ?? null) as unknown as TeamMeProfile | null);
|
||||
setEditing(false);
|
||||
setEditForm({});
|
||||
setProfilePictureFile(null);
|
||||
toast.success("Profile updated successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to update profile", err);
|
||||
toast.error("Failed to update profile. Please try again.");
|
||||
console.error("Failed to update team member profile", err);
|
||||
toast.error("Failed to update profile");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof UpdateProfileRequest, value: string) => {
|
||||
setEditForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSkeleton />;
|
||||
|
||||
if (error || !profile) {
|
||||
|
|
@ -269,502 +218,216 @@ export function ProfilePage() {
|
|||
}
|
||||
|
||||
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 (
|
||||
<div className="mx-auto w-full max-w-7xl space-y-6 pb-8">
|
||||
{/* ─── Hero Card ─── */}
|
||||
<div className="relative overflow-hidden rounded-3xl border border-grayScale-100 bg-white shadow-sm ring-1 ring-black/5">
|
||||
{/* Tall dark gradient banner with content inside */}
|
||||
<div className="relative flex min-h-[220px] flex-col justify-between bg-gradient-to-br from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-8 sm:px-8">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.08),transparent_60%)]" />
|
||||
<div className="mx-auto w-full max-w-7xl rounded-2xl bg-[#f7f1f8] p-4 pb-8 sm:p-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-[#d9bddb] bg-white">
|
||||
<div className="h-40 w-full bg-gradient-to-r from-[#d6aed6] via-[#e4cce4] to-[#cba0cd]" />
|
||||
|
||||
<div className="relative z-10 space-y-2">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
|
||||
Hello {profile.first_name}
|
||||
</h2>
|
||||
<p className="max-w-2xl text-sm leading-relaxed text-white/70">
|
||||
Track your account status, keep profile details up to date, and manage your learning preferences from one place.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
{profile.role}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Last login {formatDate(profile.last_login)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
{completionPct}% complete
|
||||
</span>
|
||||
<div className="grid gap-0 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="border-r border-[#eadbea] bg-white px-5 pb-6">
|
||||
<div className="-mt-16">
|
||||
<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]">
|
||||
{initials}
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-bold text-grayScale-700">{fullName}</h2>
|
||||
<p className="text-sm text-grayScale-400">{profile.job_title || "Team Member"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-6">
|
||||
{!editing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs font-medium text-white shadow-sm backdrop-blur-sm hover:bg-white/20 hover:text-white"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Profile
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/20 hover:text-white"
|
||||
onClick={cancelEditing}
|
||||
disabled={saving}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 bg-white text-xs text-[#1a1f4e] hover:bg-white/90"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<div className="mt-4">
|
||||
<div className="flex w-full items-center justify-between rounded-lg border border-[#d9bddb] bg-[#f4e8f4] px-3 py-2">
|
||||
<span className="text-sm font-medium text-[#6f2aa8]">Account Status</span>
|
||||
<span className="text-sm font-semibold uppercase text-[#5e2390]">{profile.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-5 text-sm">
|
||||
<section>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">About</p>
|
||||
<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"><Shield className="h-4 w-4 text-[#6f2aa8]" />{profile.team_role || "Role not set"}</div>
|
||||
<div className="flex items-center gap-2"><CalendarDays className="h-4 w-4 text-[#6f2aa8]" />Hire date: {formatDate(profile.hire_date)}</div>
|
||||
<div className="flex items-center gap-2"><BadgeCheck className="h-4 w-4 text-[#6f2aa8]" />{profile.department || "Department not set"}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Contact</p>
|
||||
<div className="space-y-2 text-grayScale-600">
|
||||
<div className="flex items-center gap-2"><Mail className="h-4 w-4 text-[#6f2aa8]" />{profile.email}</div>
|
||||
<div className="flex items-center gap-2"><Phone className="h-4 w-4 text-[#6f2aa8]" />{profile.phone_number || "—"}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<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>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{(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"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity info below banner */}
|
||||
<div className="bg-gradient-to-b from-white to-grayScale-50/40 px-6 py-5 sm:px-8">
|
||||
{editing ? (
|
||||
<div className="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>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<h2 className="text-xl font-bold tracking-tight text-grayScale-800 sm:text-2xl">
|
||||
{fullName}
|
||||
</h2>
|
||||
{profile.nick_name && (
|
||||
<span className="text-sm font-medium text-grayScale-400">
|
||||
@{profile.nick_name}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
|
||||
#{profile.id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Badges row */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
className={cn(
|
||||
"px-2.5 py-0.5 text-xs font-semibold",
|
||||
profile.role === "ADMIN"
|
||||
? "bg-brand-500/10 text-brand-600 border border-brand-500/20"
|
||||
: "bg-grayScale-50 text-grayScale-600 border border-grayScale-200",
|
||||
)}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
{profile.role}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold",
|
||||
profile.status === "ACTIVE"
|
||||
? "bg-mint-50 text-mint-600"
|
||||
: "bg-destructive/10 text-destructive",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
profile.status === "ACTIVE" ? "bg-mint-500" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
{profile.status}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-grayScale-50 px-2.5 py-0.5 text-xs font-medium text-grayScale-500">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Joined {formatDate(profile.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<main className="bg-[#fdf8fd] px-5 py-6 sm:px-7">
|
||||
<div className="space-y-5">
|
||||
<Card className="border-[#d9bddb] bg-white shadow-none">
|
||||
<CardContent className="p-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-grayScale-700">Summary</h3>
|
||||
<button
|
||||
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]"
|
||||
>
|
||||
Edit profile
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-grayScale-600">
|
||||
<div className="flex items-center gap-2"><Briefcase className="h-4 w-4 text-[#6f2aa8]" />{profile.job_title || "Role-focused work item"}</div>
|
||||
<div className="flex items-center gap-2"><Shield className="h-4 w-4 text-[#6f2aa8]" />{profile.team_role || "Team responsibility"}</div>
|
||||
<div className="flex items-center gap-2"><Mail className="h-4 w-4 text-[#6f2aa8]" />{profile.email}</div>
|
||||
<div className="flex items-center gap-2"><Phone className="h-4 w-4 text-[#6f2aa8]" />{profile.phone_number || "No phone number"}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-[#d9bddb] bg-white shadow-none">
|
||||
<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">
|
||||
<p className="text-sm font-medium text-grayScale-700">{profile.department || "Department not set"}</p>
|
||||
<p className="text-xs text-grayScale-500">Employment type: {profile.employment_type || "—"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<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-6 lg:grid-cols-3">
|
||||
{/* ── Contact & Personal ── */}
|
||||
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md lg:col-span-2">
|
||||
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-400" />
|
||||
<CardContent className="p-0">
|
||||
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
|
||||
{/* Contact */}
|
||||
<div className="p-5">
|
||||
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
||||
Contact
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<DetailItem
|
||||
icon={Mail}
|
||||
label="Email"
|
||||
value={profile.email}
|
||||
extra={<VerifiedIcon verified={profile.email_verified} />}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={Phone}
|
||||
label="Phone"
|
||||
value={profile.phone_number}
|
||||
extra={<VerifiedIcon verified={profile.phone_verified} />}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={MapPin}
|
||||
label="Location"
|
||||
value={[profile.region, profile.country].filter(Boolean).join(", ") || "—"}
|
||||
editing={editing}
|
||||
editNode={
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-8 text-sm"
|
||||
value={editForm.region ?? ""}
|
||||
onChange={(e) => updateField("region", e.target.value)}
|
||||
placeholder="Region"
|
||||
/>
|
||||
<Input
|
||||
className="h-8 text-sm"
|
||||
value={editForm.country ?? ""}
|
||||
onChange={(e) => updateField("country", e.target.value)}
|
||||
placeholder="Country"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={Globe}
|
||||
label="Preferred Language"
|
||||
value={
|
||||
{ en: "English", am: "Amharic", or: "Oromiffa", ti: "Tigrinya" }[
|
||||
profile.preferred_language
|
||||
] ?? profile.preferred_language ?? "—"
|
||||
}
|
||||
editing={editing}
|
||||
editNode={
|
||||
<Select
|
||||
className="h-8 text-sm"
|
||||
value={editForm.preferred_language ?? ""}
|
||||
onChange={(e) => updateField("preferred_language", e.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="en">English</option>
|
||||
<option value="am">Amharic</option>
|
||||
<option value="or">Oromiffa</option>
|
||||
<option value="ti">Tigrinya</option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personal */}
|
||||
<div className="p-5">
|
||||
<p className="mb-3 text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
||||
Personal
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<DetailItem
|
||||
icon={Calendar}
|
||||
label="Date of Birth"
|
||||
value={formatDate(profile.birth_day)}
|
||||
editing={editing}
|
||||
editNode={
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 text-sm"
|
||||
value={editForm.birth_day ?? ""}
|
||||
onChange={(e) => updateField("birth_day", e.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={User}
|
||||
label="Gender"
|
||||
value={profile.gender || "Not specified"}
|
||||
editing={editing}
|
||||
editNode={
|
||||
<Select
|
||||
className="h-8 text-sm"
|
||||
value={editForm.gender ?? ""}
|
||||
onChange={(e) => updateField("gender", e.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="Male">Male</option>
|
||||
<option value="Female">Female</option>
|
||||
<option value="Other">Other</option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
<DetailItem
|
||||
icon={User}
|
||||
label="Age Group"
|
||||
value={profile.age_group?.replace("_", "–") || "—"}
|
||||
editing={editing}
|
||||
editNode={
|
||||
<Select
|
||||
className="h-8 text-sm"
|
||||
value={editForm.age_group ?? ""}
|
||||
onChange={(e) => updateField("age_group", e.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="18_24">18–24</option>
|
||||
<option value="25_34">25–34</option>
|
||||
<option value="35_44">35–44</option>
|
||||
<option value="45_54">45–54</option>
|
||||
<option value="55_64">55–64</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 className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">First Name</p>
|
||||
<Input value={editForm.first_name ?? ""} onChange={(e) => updateField("first_name", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Last Name</p>
|
||||
<Input value={editForm.last_name ?? ""} onChange={(e) => updateField("last_name", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Phone Number</p>
|
||||
<Input value={editForm.phone_number ?? ""} onChange={(e) => updateField("phone_number", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Profile Picture</p>
|
||||
<div className="space-y-2">
|
||||
<FileUpload
|
||||
accept="image/*"
|
||||
onFileSelect={setProfilePictureFile}
|
||||
label="Upload profile picture"
|
||||
description="JPEG, PNG, WEBP"
|
||||
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-200 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||
/>
|
||||
<Input
|
||||
value={editForm.profile_picture_url ?? ""}
|
||||
onChange={(e) => updateField("profile_picture_url", e.target.value)}
|
||||
placeholder="Or paste image URL (https://...)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Right Sidebar ── */}
|
||||
<div className="space-y-6 lg:sticky lg:top-24 lg:self-start">
|
||||
{/* Profile Completion */}
|
||||
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div className="h-1 bg-gradient-to-r from-brand-400 to-mint-400" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<ProgressRing percent={completionPct} />
|
||||
<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 className="sm:col-span-2">
|
||||
<p className="mb-1 text-xs font-medium text-grayScale-500">Bio</p>
|
||||
<Textarea
|
||||
value={editForm.bio ?? ""}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => updateField("bio", e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
Globe,
|
||||
KeyRound,
|
||||
Languages,
|
||||
Loader2,
|
||||
Lock,
|
||||
Moon,
|
||||
Palette,
|
||||
|
|
@ -21,6 +20,7 @@ import { Button } from "../components/ui/button";
|
|||
import { Select } from "../components/ui/select";
|
||||
import { Separator } from "../components/ui/separator";
|
||||
import { cn } from "../lib/utils";
|
||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
||||
import type { UserProfileData } from "../types/user.types";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -212,7 +212,7 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
|
|||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
|
|
@ -298,7 +298,7 @@ function SecurityTab() {
|
|||
<div className="flex justify-end">
|
||||
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
|
|
|
|||
34
src/pages/TermsPage.tsx
Normal file
34
src/pages/TermsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { Eye, EyeOff } from "lucide-react";
|
|||
import { BrandLogo } from "../../components/brand/BrandLogo";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||
|
||||
|
||||
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"
|
||||
>
|
||||
{googleLoading ? (
|
||||
<svg
|
||||
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>
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<GoogleIcon className="h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react"
|
||||
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 { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
|
|
@ -15,7 +15,8 @@ import {
|
|||
} from "../../components/ui/table"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
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 {
|
||||
Dialog,
|
||||
|
|
@ -26,6 +27,8 @@ import {
|
|||
} from "../../components/ui/dialog"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
|
||||
type CourseWithCategory = Course & { category_name: string }
|
||||
|
||||
|
|
@ -53,6 +56,8 @@ export function AllCoursesPage() {
|
|||
const [editTitle, setEditTitle] = useState("")
|
||||
const [editDescription, setEditDescription] = useState("")
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const fetchAllCourses = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -97,6 +102,26 @@ export function AllCoursesPage() {
|
|||
}
|
||||
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 effectiveCategoryId = createSubCategoryId || createCategoryId
|
||||
|
|
@ -110,12 +135,27 @@ export function AllCoursesPage() {
|
|||
|
||||
setCreating(true)
|
||||
try {
|
||||
await createCourse({
|
||||
const createdRes = await createCourse({
|
||||
category_id: Number(effectiveCategoryId),
|
||||
title: createTitle.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", {
|
||||
description: `"${createTitle.trim()}" has been created.`,
|
||||
})
|
||||
|
|
@ -191,7 +231,7 @@ export function AllCoursesPage() {
|
|||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<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>
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories…</p>
|
||||
</div>
|
||||
|
|
@ -244,14 +284,20 @@ export function AllCoursesPage() {
|
|||
<Input
|
||||
placeholder="Search by title, description, or category…"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
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>
|
||||
{categories.map((cat) => (
|
||||
|
|
@ -269,31 +315,21 @@ export function AllCoursesPage() {
|
|||
|
||||
{/* Courses Table */}
|
||||
{filteredCourses.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||
<div className="rounded-xl border bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Course
|
||||
</TableHead>
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
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>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCourses.map((course, index) => (
|
||||
{paginatedCourses.map((course) => (
|
||||
<TableRow
|
||||
key={course.id}
|
||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
|
||||
}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
|
||||
|
|
@ -369,6 +405,79 @@ export function AllCoursesPage() {
|
|||
))}
|
||||
</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 className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useState } from "react"
|
||||
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 { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { getCourseCategories } from "../../api/courses.api"
|
||||
import type { CourseCategory } from "../../types/course.types"
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ export function ContentOverviewPage() {
|
|||
const { categoryId } = useParams<{ categoryId: string }>()
|
||||
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||
const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [dragKey, setDragKey] = useState<string | null>(null)
|
||||
const [flowSteps, setFlowSteps] = useState<
|
||||
{
|
||||
|
|
@ -158,6 +160,13 @@ export function ContentOverviewPage() {
|
|||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Header & Breadcrumb */}
|
||||
|
|
@ -185,6 +194,15 @@ export function ContentOverviewPage() {
|
|||
{category?.name ?? "Content Management"}
|
||||
</h1>
|
||||
</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 */}
|
||||
<div className="relative">
|
||||
|
|
@ -203,7 +221,7 @@ export function ContentOverviewPage() {
|
|||
|
||||
{/* Cards Grid (course builder style – draggable sections) */}
|
||||
<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
|
||||
return (
|
||||
<div
|
||||
|
|
@ -274,6 +292,12 @@ export function ContentOverviewPage() {
|
|||
)
|
||||
})}
|
||||
</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) */}
|
||||
{flowSteps.length > 0 && (
|
||||
<Card className="shadow-soft">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export function CourseCategoryPage() {
|
|||
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
|
||||
const [newSubCategoryName, setNewSubCategoryName] = useState("")
|
||||
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -47,6 +48,11 @@ export function CourseCategoryPage() {
|
|||
fetchCategories()
|
||||
}, [])
|
||||
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase()
|
||||
const filteredCategories = normalizedQuery
|
||||
? categories.filter((c) => c.name?.toLowerCase().includes(normalizedQuery))
|
||||
: categories
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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
|
||||
</p>
|
||||
</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
|
||||
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
|
||||
size="sm"
|
||||
|
|
@ -116,9 +130,21 @@ export function CourseCategoryPage() {
|
|||
</p>
|
||||
</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">
|
||||
{categories.map((category) => (
|
||||
{filteredCategories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
to={`/content/category/${category.id}/courses`}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react"
|
||||
|
|
@ -53,6 +52,7 @@ import type {
|
|||
} from "../../types/course.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
|
||||
type PracticeListItem = LearningPathPractice & { display_order: number }
|
||||
|
||||
|
|
@ -504,7 +504,7 @@ export function CourseFlowBuilderPage() {
|
|||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -582,7 +582,7 @@ export function CourseFlowBuilderPage() {
|
|||
</CardTitle>
|
||||
{savingKey && (
|
||||
<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...
|
||||
</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>
|
||||
{loadingCourses ? (
|
||||
<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>
|
||||
) : activeCourses.length === 0 ? (
|
||||
<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>
|
||||
{savingKey && (
|
||||
<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...
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -668,7 +668,7 @@ export function CourseFlowBuilderPage() {
|
|||
</p>
|
||||
) : loadingPath ? (
|
||||
<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>
|
||||
) : !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">
|
||||
|
|
@ -774,7 +774,7 @@ export function CourseFlowBuilderPage() {
|
|||
</p>
|
||||
{loadingPracticesBySubCourse[subCourse.id] ? (
|
||||
<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...
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare } from "lucide-react"
|
||||
import { Plus, ArrowLeft, ToggleLeft, ToggleRight, X, Trash2, Edit, AlertCircle, Star, MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react"
|
||||
import practiceSrc from "../../assets/Practice.svg"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
|
|
@ -8,6 +8,7 @@ import alertSrc from "../../assets/Alert.svg"
|
|||
import { Button } from "../../components/ui/button"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -16,13 +17,26 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} 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 { cn } from "../../lib/utils"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
|
||||
export function CoursesPage() {
|
||||
const { categoryId } = useParams<{ categoryId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [courses, setCourses] = useState<Course[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -41,12 +55,15 @@ export function CoursesPage() {
|
|||
const [editTitle, setEditTitle] = useState("")
|
||||
const [editDescription, setEditDescription] = useState("")
|
||||
const [editThumbnail, setEditThumbnail] = useState("")
|
||||
const [editThumbnailFile, setEditThumbnailFile] = useState<File | null>(null)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateError, setUpdateError] = useState<string | null>(null)
|
||||
const [showRatingsModal, setShowRatingsModal] = useState(false)
|
||||
const [ratingsCourseId, setRatingsCourseId] = useState<number | null>(null)
|
||||
const [courseRatings, setCourseRatings] = useState<Rating[]>([])
|
||||
const [courseRatingsLoading, setCourseRatingsLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const fetchCourses = async () => {
|
||||
if (!categoryId) return
|
||||
|
|
@ -86,6 +103,10 @@ export function CoursesPage() {
|
|||
fetchData()
|
||||
}, [categoryId])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [categoryId, searchQuery])
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
|
|
@ -167,6 +188,7 @@ export function CoursesPage() {
|
|||
setEditTitle(course.title || "")
|
||||
setEditDescription(course.description || "")
|
||||
setEditThumbnail(course.thumbnail || "")
|
||||
setEditThumbnailFile(null)
|
||||
setUpdateError(null)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
|
@ -177,6 +199,7 @@ export function CoursesPage() {
|
|||
setEditTitle("")
|
||||
setEditDescription("")
|
||||
setEditThumbnail("")
|
||||
setEditThumbnailFile(null)
|
||||
setUpdateError(null)
|
||||
}
|
||||
|
||||
|
|
@ -199,9 +222,18 @@ export function CoursesPage() {
|
|||
await updateCourse(courseToEdit.id, {
|
||||
title: editTitle.trim(),
|
||||
description: editDescription.trim(),
|
||||
thumbnail: editThumbnail.trim() || undefined,
|
||||
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()
|
||||
await fetchCourses()
|
||||
} 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) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
{/* <div className="rounded-2xl bg-white shadow-sm p-6">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
|
|
@ -284,9 +342,20 @@ export function CoursesPage() {
|
|||
{/* Course table or empty state */}
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Sub-category Management
|
||||
</CardTitle>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
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>
|
||||
<CardContent className="pt-4">
|
||||
{courses.length === 0 ? (
|
||||
|
|
@ -305,29 +374,28 @@ export function CoursesPage() {
|
|||
Add your first sub-category
|
||||
</Button>
|
||||
</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>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Sub-category
|
||||
</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Actions
|
||||
</TableHead>
|
||||
<TableRow>
|
||||
<TableHead>Sub-category</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{courses.map((course, index) => (
|
||||
{paginatedCourses.map((course) => (
|
||||
<TableRow
|
||||
key={course.id}
|
||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
|
||||
}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => handleCourseClick(course.id)}
|
||||
>
|
||||
<TableCell className="max-w-md py-3.5">
|
||||
|
|
@ -405,6 +473,79 @@ export function CoursesPage() {
|
|||
))}
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -544,14 +685,23 @@ export function CoursesPage() {
|
|||
htmlFor="edit-course-thumbnail"
|
||||
className="mb-2 block text-sm font-medium text-grayScale-600"
|
||||
>
|
||||
Thumbnail URL
|
||||
Thumbnail
|
||||
</label>
|
||||
<Input
|
||||
id="edit-course-thumbnail"
|
||||
placeholder="Enter thumbnail URL (e.g., https://example.com/image.jpg)"
|
||||
value={editThumbnail}
|
||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FileUpload
|
||||
accept="image/*"
|
||||
onFileSelect={(file) => setEditThumbnailFile(file)}
|
||||
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>
|
||||
|
||||
|
|
@ -592,7 +742,7 @@ export function CoursesPage() {
|
|||
<div className="max-h-[70vh] overflow-y-auto px-6 py-6">
|
||||
{courseRatingsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
<SpinnerIcon className="h-8 w-8" />
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings…</p>
|
||||
</div>
|
||||
) : courseRatings.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 { Button } from "../../components/ui/button"
|
||||
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 { getQuestionSetById, getQuestionSets } from "../../api/courses.api"
|
||||
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> = {
|
||||
PUBLISHED: "bg-green-100 text-green-700",
|
||||
|
|
@ -33,6 +36,8 @@ export function PracticeDetailsPage() {
|
|||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [ownerTypeFilter, setOwnerTypeFilter] = useState("all")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const fetchPractices = useCallback(async () => {
|
||||
setLoadingList(true)
|
||||
|
|
@ -89,6 +94,10 @@ export function PracticeDetailsPage() {
|
|||
}
|
||||
}, [selectedPracticeId, fetchPracticeDetail])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [searchQuery, statusFilter, ownerTypeFilter])
|
||||
|
||||
const filteredPractices = useMemo(() => {
|
||||
return practices.filter((practice) => {
|
||||
const matchesSearch =
|
||||
|
|
@ -104,6 +113,28 @@ export function PracticeDetailsPage() {
|
|||
}, [practices, searchQuery, statusFilter, ownerTypeFilter])
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -115,7 +146,7 @@ export function PracticeDetailsPage() {
|
|||
</p>
|
||||
</div>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -153,6 +184,7 @@ export function PracticeDetailsPage() {
|
|||
setSearchQuery("")
|
||||
setStatusFilter("all")
|
||||
setOwnerTypeFilter("all")
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
|
|
@ -160,38 +192,44 @@ export function PracticeDetailsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{loadingList ? (
|
||||
<div className="py-16 text-center text-sm text-grayScale-500">Loading practices...</div>
|
||||
) : filteredPractices.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center text-sm text-grayScale-500">
|
||||
No practice sets found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Title</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Owner</TableHead>
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">Status</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">Created</TableHead>
|
||||
<div className="rounded-xl border bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Owner</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingList ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} 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 practices...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPractices.map((practice, index) => (
|
||||
) : filteredPractices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-12 text-center text-sm text-grayScale-500">
|
||||
No practice sets found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedPractices.map((practice) => (
|
||||
<TableRow
|
||||
key={practice.id}
|
||||
onClick={() => {
|
||||
setSelectedPracticeId(practice.id)
|
||||
setDetailOpen(true)
|
||||
}}
|
||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||
selectedPracticeId === practice.id
|
||||
? "bg-brand-100/40"
|
||||
: index % 2 === 0
|
||||
? "bg-white"
|
||||
: "bg-grayScale-100/50"
|
||||
}`}
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
selectedPracticeId === practice.id && "bg-brand-100/30",
|
||||
)}
|
||||
>
|
||||
<TableCell className="max-w-md py-3.5">
|
||||
<p className="truncate text-sm font-medium text-grayScale-700">{practice.title}</p>
|
||||
|
|
@ -209,11 +247,84 @@ export function PracticeDetailsPage() {
|
|||
{practice.created_at}
|
||||
</TableCell>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Plus, Search, Edit, Trash2, HelpCircle, X } from "lucide-react"
|
||||
import { Plus, Search, Edit, Trash2, HelpCircle, X, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Input } from "../../components/ui/input"
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
import { Badge } from "../../components/ui/badge"
|
||||
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
|
||||
import type { QuestionDetail } from "../../types/course.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
||||
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
|
||||
|
|
@ -300,8 +302,23 @@ export function QuestionsPage() {
|
|||
|
||||
const totalCount = filteredQuestions.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
|
||||
const canGoPrev = page > 1
|
||||
const canGoNext = page < totalPages
|
||||
const safePage = Math.min(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 (
|
||||
<div className="space-y-8">
|
||||
|
|
@ -388,18 +405,6 @@ export function QuestionsPage() {
|
|||
<option value="PUBLISHED">Published</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value)
|
||||
setPageSize(next)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<option value="10">10 / page</option>
|
||||
<option value="20">20 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -408,52 +413,54 @@ export function QuestionsPage() {
|
|||
Showing {paginatedQuestions.length} of {totalCount} questions
|
||||
</div>
|
||||
|
||||
{/* Questions Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-grayScale-200 py-16">
|
||||
<p className="text-sm text-grayScale-500">Loading questions...</p>
|
||||
</div>
|
||||
) : filteredQuestions.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||
<TableHead className="w-10 py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllCurrentPageSelected}
|
||||
onChange={toggleSelectAllCurrentPage}
|
||||
aria-label="Select all questions on current page"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Question
|
||||
</TableHead>
|
||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||
Difficulty
|
||||
</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-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>
|
||||
<div className="rounded-xl border bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllCurrentPageSelected}
|
||||
onChange={toggleSelectAllCurrentPage}
|
||||
aria-label="Select all questions on current page"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Difficulty</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||
<TableHead>Points</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} 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 questions...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedQuestions.map((question, index) => (
|
||||
) : filteredQuestions.length === 0 ? (
|
||||
<TableRow>
|
||||
<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
|
||||
key={question.id}
|
||||
onClick={() => openDetails(question.id)}
|
||||
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/50"
|
||||
}`}
|
||||
className="group cursor-pointer"
|
||||
>
|
||||
<TableCell className="py-3.5">
|
||||
<input
|
||||
|
|
@ -527,46 +534,83 @@ export function QuestionsPage() {
|
|||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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="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>
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-grayScale-200 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canGoPrev}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canGoNext}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<div 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>
|
||||
</CardContent>
|
||||
|
|
@ -629,11 +673,74 @@ export function QuestionsPage() {
|
|||
<p className="mt-1 text-sm text-grayScale-700">{detailData.question_text}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<p><span className="font-medium">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">Difficulty:</span> {detailData.difficulty_level || "—"}</p>
|
||||
<p><span className="font-medium">Points:</span> {detailData.points ?? 0}</p>
|
||||
<p><span className="font-medium">Status:</span> {detailData.status || "—"}</p>
|
||||
<p><span className="font-medium">Created:</span> {detailData.created_at || "—"}</p>
|
||||
</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 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Options</p>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||
import { ArrowLeft, Plus, FileText, Layers, Edit, Trash2, X, Video, MoreVertical, Star, ChevronLeft, ChevronRight, MessageSquare, Play, Loader2 } from "lucide-react"
|
||||
import { 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 { Card } from "../../components/ui/card"
|
||||
import alertSrc from "../../assets/Alert.svg"
|
||||
|
|
@ -13,14 +13,23 @@ import {
|
|||
getVideosBySubCourse,
|
||||
updatePractice,
|
||||
deleteQuestionSet,
|
||||
createVimeoVideo,
|
||||
createCourseVideo,
|
||||
updateSubCourseVideo,
|
||||
deleteSubCourseVideo,
|
||||
getRatings,
|
||||
getVimeoSample,
|
||||
} from "../../api/courses.api"
|
||||
import type { SubCourse, QuestionSet, SubCourseVideo, Rating, VimeoSampleVideo } from "../../types/course.types"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import { uploadVideoFile } from "../../api/files.api"
|
||||
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 StatusFilter = "all" | "published" | "draft" | "archived"
|
||||
|
|
@ -74,17 +83,19 @@ export function SubCourseContentPage() {
|
|||
const [videoTitle, setVideoTitle] = useState("")
|
||||
const [videoDescription, setVideoDescription] = useState("")
|
||||
const [videoUrl, setVideoUrl] = useState("")
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null)
|
||||
const [videoFileSize, setVideoFileSize] = 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
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false)
|
||||
const [previewIframe, setPreviewIframe] = useState("")
|
||||
const [previewVideo, setPreviewVideo] = useState<VimeoSampleVideo | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [sampleVideoId, setSampleVideoId] = useState("")
|
||||
const [modalPreviewIframe, setModalPreviewIframe] = useState("")
|
||||
const [modalPreviewLoading, setModalPreviewLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
|
|
@ -234,31 +245,83 @@ export function SubCourseContentPage() {
|
|||
setVideoTitle("")
|
||||
setVideoDescription("")
|
||||
setVideoUrl("")
|
||||
setVideoFile(null)
|
||||
setVideoFileSize(0)
|
||||
setVideoDuration(0)
|
||||
setVideoResolution("1080p")
|
||||
setVideoVisibility("PUBLISHED")
|
||||
setVideoStatus("PUBLISHED")
|
||||
setVideoDisplayOrder(1)
|
||||
setSaveError(null)
|
||||
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 () => {
|
||||
if (!subCourseId) return
|
||||
if (!subCourseId || !videoFile) return
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
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),
|
||||
title: videoTitle,
|
||||
description: videoDescription,
|
||||
source_url: videoUrl,
|
||||
file_size: videoFileSize,
|
||||
title: finalTitle,
|
||||
description: videoDescription.trim(),
|
||||
video_url: finalVideoUrl,
|
||||
duration: videoDuration,
|
||||
resolution: videoResolution.trim() || undefined,
|
||||
visibility: videoVisibility,
|
||||
display_order: Number.isFinite(videoDisplayOrder) ? videoDisplayOrder : undefined,
|
||||
status: videoStatus,
|
||||
})
|
||||
setShowAddVideoModal(false)
|
||||
setVideoTitle("")
|
||||
setVideoDescription("")
|
||||
setVideoUrl("")
|
||||
setVideoFile(null)
|
||||
setVideoFileSize(0)
|
||||
setVideoDuration(0)
|
||||
setVideoResolution("1080p")
|
||||
setVideoVisibility("PUBLISHED")
|
||||
setVideoStatus("PUBLISHED")
|
||||
setVideoDisplayOrder(1)
|
||||
await fetchVideos()
|
||||
} catch (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 idMatch = video.video_url?.match(/(\d{5,})/)
|
||||
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
|
||||
setShowPreviewModal(true)
|
||||
setPreviewLoading(true)
|
||||
setPreviewIframe("")
|
||||
setPreviewVideo(null)
|
||||
try {
|
||||
const 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)
|
||||
setPreviewIframe(res.data.data.iframe)
|
||||
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) => {
|
||||
if (statusFilter === "all") return true
|
||||
if (statusFilter === "published") return practice.status === "PUBLISHED"
|
||||
|
|
@ -482,7 +534,7 @@ export function SubCourseContentPage() {
|
|||
<>
|
||||
{practicesLoading ? (
|
||||
<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>
|
||||
</div>
|
||||
) : 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>
|
||||
|
||||
<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}
|
||||
</Badge>
|
||||
{practice.persona && (
|
||||
|
|
@ -581,7 +633,7 @@ export function SubCourseContentPage() {
|
|||
<>
|
||||
{videosLoading ? (
|
||||
<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>
|
||||
</div>
|
||||
) : videos.length === 0 ? (
|
||||
|
|
@ -711,7 +763,7 @@ export function SubCourseContentPage() {
|
|||
<>
|
||||
{ratingsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
<SpinnerIcon className="h-8 w-8" />
|
||||
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading ratings…</p>
|
||||
</div>
|
||||
) : 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">
|
||||
<h2 className="text-lg font-semibold text-grayScale-900">Add Video</h2>
|
||||
<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"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-6">
|
||||
{/* Sample Vimeo picker */}
|
||||
<div className="rounded-xl border border-brand-100 bg-brand-50/40 p-4 space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-brand-600">
|
||||
Try a sample Vimeo video
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
className="flex-1 text-sm"
|
||||
value={sampleVideoId}
|
||||
onChange={(e) => {
|
||||
setSampleVideoId(e.target.value)
|
||||
handleModalPreview(e.target.value)
|
||||
}}
|
||||
>
|
||||
<option value="">Select a sample video…</option>
|
||||
<option value="76979871">Big Buck Bunny</option>
|
||||
<option value="1084537">Big Buck Bunny (alt)</option>
|
||||
<option value="253989945">Vimeo Staff Pick</option>
|
||||
<option value="305727901">Big Buck Bunny (4K)</option>
|
||||
<option value="148751763">GoPro Footage</option>
|
||||
</Select>
|
||||
{modalPreviewLoading && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-brand-500" />}
|
||||
</div>
|
||||
{modalPreviewIframe && (
|
||||
<div
|
||||
className="aspect-video w-full overflow-hidden rounded-lg"
|
||||
dangerouslySetInnerHTML={{ __html: modalPreviewIframe }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-700">Title</label>
|
||||
<Input
|
||||
|
|
@ -988,12 +1009,17 @@ export function SubCourseContentPage() {
|
|||
/>
|
||||
</div>
|
||||
<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
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
placeholder="https://example-storage.com/video.mp4"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={(e) => handleVideoFileSelect(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{videoFile && (
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Selected: {videoFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -1017,6 +1043,52 @@ export function SubCourseContentPage() {
|
|||
/>
|
||||
</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>}
|
||||
</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">
|
||||
|
|
@ -1026,9 +1098,9 @@ export function SubCourseContentPage() {
|
|||
<Button
|
||||
className="bg-brand-500 shadow-sm hover:bg-brand-600"
|
||||
onClick={handleSaveNewVideo}
|
||||
disabled={saving || !videoTitle.trim() || !videoUrl.trim()}
|
||||
disabled={saving || !videoTitle.trim() || !videoFile}
|
||||
>
|
||||
{saving ? "Uploading..." : "Upload to Vimeo"}
|
||||
{saving ? "Uploading..." : "Upload Video"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1149,7 +1221,7 @@ export function SubCourseContentPage() {
|
|||
<div className="p-6">
|
||||
{previewLoading ? (
|
||||
<div className="flex aspect-video items-center justify-center rounded-xl bg-grayScale-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
|
||||
<SpinnerIcon className="h-8 w-8" />
|
||||
</div>
|
||||
) : previewIframe ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
Edit,
|
||||
Link2,
|
||||
Plus,
|
||||
Loader2,
|
||||
LayoutGrid,
|
||||
GitBranch,
|
||||
ChevronDown,
|
||||
|
|
@ -43,6 +42,7 @@ import type {
|
|||
CourseCategory,
|
||||
SubCoursePrerequisite,
|
||||
} from "../../types/course.types";
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||
|
||||
export function SubCoursesPage() {
|
||||
const { categoryId, courseId } = useParams<{
|
||||
|
|
@ -403,10 +403,6 @@ export function SubCoursesPage() {
|
|||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||
{/* <div className="rounded-full bg-white shadow-sm p-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -831,7 +827,7 @@ export function SubCoursesPage() {
|
|||
disabled={prereqAdding || !selectedPrereqId}
|
||||
>
|
||||
{prereqAdding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<SpinnerIcon 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"
|
||||
>
|
||||
{prereqRemoving === prereq.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
DialogDescription,
|
||||
} from "../../components/ui/dialog";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||
import {
|
||||
getIssues,
|
||||
getIssueById,
|
||||
|
|
@ -365,7 +366,7 @@ export function IssuesPage() {
|
|||
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
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -500,7 +501,7 @@ export function IssuesPage() {
|
|||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<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>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
@ -720,7 +721,7 @@ export function IssuesPage() {
|
|||
|
||||
{detailLoading ? (
|
||||
<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>
|
||||
) : selectedIssue ? (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -1030,7 +1031,7 @@ export function IssuesPage() {
|
|||
onClick={handleDeleteConfirm}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
|
|
|
|||
79
src/pages/legal/InfoLayout.tsx
Normal file
79
src/pages/legal/InfoLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ import {
|
|||
BookOpen,
|
||||
Video,
|
||||
ShieldAlert,
|
||||
Loader2,
|
||||
MailOpen,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
|
|
@ -51,6 +50,7 @@ import {
|
|||
} from "../../components/ui/dropdown-menu"
|
||||
import { FileUpload } from "../../components/ui/file-upload"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
|
|
@ -222,7 +222,7 @@ function NotificationItem({
|
|||
title={notification.is_read ? "Mark as unread" : "Mark as read"}
|
||||
>
|
||||
{toggling ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : notification.is_read ? (
|
||||
<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 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) => {
|
||||
if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false
|
||||
|
|
@ -605,7 +621,7 @@ export function NotificationsPage() {
|
|||
onClick={handleMarkAllRead}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
|
|
@ -619,7 +635,7 @@ export function NotificationsPage() {
|
|||
onClick={handleMarkAllUnread}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
|
|
@ -681,7 +697,7 @@ export function NotificationsPage() {
|
|||
{/* Loading */}
|
||||
{loading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -755,51 +771,83 @@ export function NotificationsPage() {
|
|||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-grayScale-500">Channel</span>
|
||||
<Select
|
||||
value={channelFilter}
|
||||
onChange={(e) => setChannelFilter(e.target.value as typeof channelFilter)}
|
||||
className="h-8 w-[130px] text-xs"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="sms">SMS</option>
|
||||
</Select>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-[130px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
|
||||
>
|
||||
<span className="truncate">{channelFilter === "all" ? "All" : channelFilter.toUpperCase()}</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
|
||||
</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 className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-grayScale-500">Type</span>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="h-8 w-[150px] text-xs"
|
||||
>
|
||||
<option value="all">All types</option>
|
||||
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{formatTypeLabel(t)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-[150px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
|
||||
>
|
||||
<span className="truncate">
|
||||
{typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
|
||||
</Button>
|
||||
</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 className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-grayScale-500">Level</span>
|
||||
<Select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
className="h-8 w-[130px] text-xs"
|
||||
>
|
||||
<option value="all">All levels</option>
|
||||
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => (
|
||||
<option key={lvl} value={lvl}>
|
||||
{lvl}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-[130px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
|
||||
>
|
||||
<span className="truncate">{levelFilter === "all" ? "All levels" : levelFilter}</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[150px]">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<Card className="overflow-hidden rounded-xl border bg-white shadow-none">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
|
@ -816,8 +864,14 @@ export function NotificationsPage() {
|
|||
<TableBody>
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-sm text-grayScale-400">
|
||||
No notifications match your filters.
|
||||
<TableCell colSpan={7} className="py-12 text-center">
|
||||
<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>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -897,7 +951,7 @@ export function NotificationsPage() {
|
|||
title={n.is_read ? "Mark as unread" : "Mark as read"}
|
||||
>
|
||||
{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 ? (
|
||||
<Mail className="h-3.5 w-3.5 text-grayScale-400" />
|
||||
) : (
|
||||
|
|
@ -921,37 +975,72 @@ export function NotificationsPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-xs text-grayScale-400">
|
||||
Showing {offset + 1}–{Math.min(offset + PAGE_SIZE, totalCount)} of {totalCount}
|
||||
</span>
|
||||
<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={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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<button
|
||||
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
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" />
|
||||
</Button>
|
||||
<span className="px-3 text-xs font-medium text-grayScale-600">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
</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={() => 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}
|
||||
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" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -1161,7 +1250,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">
|
||||
{recipientsLoading && (
|
||||
<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…
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1230,7 +1319,7 @@ export function NotificationsPage() {
|
|||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -1712,7 +1801,7 @@ export function NotificationsPage() {
|
|||
<Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}>
|
||||
{bulkSending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Button } from "../../components/ui/button"
|
||||
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 { cn } from "../../lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
|
||||
export function AddRolePage() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -186,7 +187,7 @@ export function AddRolePage() {
|
|||
disabled={saving || !roleName.trim()}
|
||||
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"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
@ -246,7 +247,7 @@ export function AddRolePage() {
|
|||
{/* Loading */}
|
||||
{permLoading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"
|
|||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
Plus, Search, Shield, ShieldCheck, ChevronLeft, ChevronRight,
|
||||
Loader2, AlertCircle, Eye, X, Pencil, Check,
|
||||
AlertCircle, Eye, X, Pencil, Check,
|
||||
} from "lucide-react"
|
||||
import { Button } from "../../components/ui/button"
|
||||
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 { cn } from "../../lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
|
||||
export function RolesListPage() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -277,7 +278,7 @@ export function RolesListPage() {
|
|||
{/* Loading */}
|
||||
{loading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -473,7 +474,7 @@ export function RolesListPage() {
|
|||
onClick={handleSaveRole}
|
||||
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"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -484,7 +485,7 @@ export function RolesListPage() {
|
|||
|
||||
{detailLoading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -580,7 +581,7 @@ export function RolesListPage() {
|
|||
|
||||
{permLoading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -677,7 +678,7 @@ export function RolesListPage() {
|
|||
onClick={handleSavePermissions}
|
||||
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"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
SlidersHorizontal,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
|
|
@ -80,44 +79,85 @@ function formatRoleLabel(role: string): string {
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
function normalizeFilterValue(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
export function TeamManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
const [members, setMembers] = useState<TeamMember[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(5);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
|
||||
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMembers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getTeamMembers(page, pageSize);
|
||||
const data = res.data.data;
|
||||
setMembers(data);
|
||||
setTotal(res.data.metadata.total);
|
||||
const batchSize = 100;
|
||||
const firstRes = await getTeamMembers(1, batchSize);
|
||||
const firstBatch = firstRes.data.data ?? [];
|
||||
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> = {};
|
||||
data.forEach((m) => {
|
||||
allMembers.forEach((m) => {
|
||||
initialStatuses[m.id] = m.status === "active";
|
||||
});
|
||||
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch team members:", error);
|
||||
setMembers([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 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 handleNext = () => safePage < pageCount && setPage(safePage + 1);
|
||||
|
|
@ -152,6 +192,9 @@ export function TeamManagementPage() {
|
|||
setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" }));
|
||||
try {
|
||||
await updateTeamMemberStatus(id, newStatus);
|
||||
setMembers((prev) =>
|
||||
prev.map((member) => (member.id === id ? { ...member, status: newStatus } : member)),
|
||||
);
|
||||
toast.success(
|
||||
`${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" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="shrink-0">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
More Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-white">
|
||||
<div className="rounded-xl border bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -250,21 +289,30 @@ export function TeamManagementPage() {
|
|||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{members.length === 0 ? (
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-grayScale-400">
|
||||
No team members found
|
||||
<TableCell colSpan={6} className="py-12 text-center text-sm text-grayScale-400">
|
||||
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>
|
||||
</TableRow>
|
||||
) : (
|
||||
members.map((member) => {
|
||||
paginatedMembers.map((member) => {
|
||||
const initials = `${member.first_name?.[0] ?? ""}${member.last_name?.[0] ?? ""}`.toUpperCase();
|
||||
const isActive = toggledStatuses[member.id] ?? false;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
className="cursor-pointer hover:bg-grayScale-50"
|
||||
className="group cursor-pointer"
|
||||
onClick={() => navigate(`/team/${member.id}`)}
|
||||
>
|
||||
<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 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">
|
||||
<select
|
||||
value={pageSize}
|
||||
|
|
@ -366,7 +421,6 @@ export function TeamManagementPage() {
|
|||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||
</div>
|
||||
<span>Entries</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
import { cn } from "../../lib/utils";
|
||||
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
|
||||
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||
|
||||
// ── Action type configuration ──────────────────────────────────────
|
||||
const ACTION_TYPES = [
|
||||
|
|
@ -250,7 +251,7 @@ export function UserLogPage() {
|
|||
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
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -566,7 +567,7 @@ export function UserLogPage() {
|
|||
|
||||
{detailLoading ? (
|
||||
<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>
|
||||
) : selectedLog ? (
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
642
src/pages/user-management/DeletionRequestsPage.tsx
Normal file
642
src/pages/user-management/DeletionRequestsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import {
|
|||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
|
||||
import type { Course } from "../../types/course.types";
|
||||
|
||||
|
|
@ -475,7 +476,7 @@ export function UserDetailPage() {
|
|||
|
||||
{!progressError && loadingProgress && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-100 px-3 py-2 text-xs text-grayScale-500">
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
Loading learner progress...
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -487,10 +488,10 @@ export function UserDetailPage() {
|
|||
)}
|
||||
|
||||
{!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>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-grayScale-100/70">
|
||||
<TableRow>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Level</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ import { useEffect, useState } from "react"
|
|||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
UserX,
|
||||
UserCheck,
|
||||
TrendingUp,
|
||||
ArrowRight,
|
||||
List,
|
||||
UsersRound,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { getDashboard } from "../../api/analytics.api"
|
||||
import type { DashboardUsers } from "../../types/analytics.types"
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ export function UserManagementDashboard() {
|
|||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white/80">Total Users</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.total_users) : "—"}
|
||||
{statsLoading ? <SpinnerIcon className="h-5 w-5" /> : stats ? formatNum(stats.total_users) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -72,7 +72,7 @@ export function UserManagementDashboard() {
|
|||
<p className="text-sm font-medium text-white/80">Active Users</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{statsLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
) : activeUsers !== null ? (
|
||||
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-2xl font-bold text-white">
|
||||
{statsLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
) : stats ? (
|
||||
formatNum(stats.new_month)
|
||||
) : (
|
||||
|
|
@ -108,22 +108,22 @@ export function UserManagementDashboard() {
|
|||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
<UserX className="h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Register User
|
||||
Deletion Requests
|
||||
</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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" />
|
||||
</span>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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 { useNavigate } from "react-router-dom"
|
||||
import { Input } from "../../components/ui/input"
|
||||
|
|
@ -36,9 +36,11 @@ export function UsersListPage() {
|
|||
} | null>(null)
|
||||
const [roleFilter, setRoleFilter] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getUsers(
|
||||
page,
|
||||
|
|
@ -62,6 +64,8 @@ export function UsersListPage() {
|
|||
console.error("Failed to fetch users:", error)
|
||||
setUsers([])
|
||||
setTotal(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +97,9 @@ export function UsersListPage() {
|
|||
}
|
||||
|
||||
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 pages: (number | string)[] = []
|
||||
|
|
@ -169,6 +176,37 @@ export function UsersListPage() {
|
|||
<p className="text-sm text-grayScale-400">View and manage all registered users.</p>
|
||||
</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">
|
||||
{/* Search & Filters */}
|
||||
<div className="p-4 border-b">
|
||||
|
|
@ -238,7 +276,13 @@ export function UsersListPage() {
|
|||
</TableHeader>
|
||||
|
||||
<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>
|
||||
<TableCell colSpan={7} className="py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
|
|
@ -259,7 +303,7 @@ export function UsersListPage() {
|
|||
return (
|
||||
<TableRow
|
||||
key={u.id}
|
||||
className="cursor-pointer hover:bg-grayScale-50"
|
||||
className="group cursor-pointer"
|
||||
onClick={() => handleRowClick(u.id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
|
|
@ -318,9 +362,16 @@ export function UsersListPage() {
|
|||
</Table>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
<select
|
||||
value={pageSize}
|
||||
|
|
@ -338,7 +389,6 @@ export function UsersListPage() {
|
|||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||
</div>
|
||||
<span>Entries</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -260,6 +260,21 @@ export interface CreateSubCourseVideoRequest {
|
|||
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 {
|
||||
sub_course_id: number
|
||||
title: string
|
||||
|
|
@ -484,6 +499,7 @@ export interface CreateQuestionRequest {
|
|||
tips?: string
|
||||
explanation?: string
|
||||
status?: string
|
||||
image_url?: string
|
||||
options?: QuestionOption[]
|
||||
voice_prompt?: string
|
||||
sample_answer_voice_prompt?: string
|
||||
|
|
@ -518,6 +534,7 @@ export interface QuestionDetail {
|
|||
short_answers?: string[] | QuestionShortAnswer[]
|
||||
tips?: string | null
|
||||
explanation?: string | null
|
||||
image_url?: string | null
|
||||
voice_prompt?: string | null
|
||||
sample_answer_voice_prompt?: string | null
|
||||
audio_correct_answer_text?: string | null
|
||||
|
|
|
|||
|
|
@ -30,6 +30,22 @@ export interface CreateTeamMemberRequest {
|
|||
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 {
|
||||
total: number
|
||||
total_pages: number
|
||||
|
|
|
|||
|
|
@ -146,3 +146,85 @@ export interface UpdateProfileRequest {
|
|||
profile_picture_url?: 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 ?? "",
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user