Compare commits

..

2 Commits

18 changed files with 1546 additions and 485 deletions

2
.env
View File

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

View File

@ -41,6 +41,7 @@ import type {
GetSubCoursePrerequisitesResponse, GetSubCoursePrerequisitesResponse,
AddSubCoursePrerequisiteRequest, AddSubCoursePrerequisiteRequest,
GetLearningPathResponse, GetLearningPathResponse,
GetSubCourseEntryAssessmentResponse,
ReorderItem, ReorderItem,
GetRatingsResponse, GetRatingsResponse,
GetRatingsParams, GetRatingsParams,
@ -98,8 +99,11 @@ export const deleteSubCourseVideo = (videoId: number) =>
http.delete(`/course-management/sub-course-videos/${videoId}`) http.delete(`/course-management/sub-course-videos/${videoId}`)
// Practice APIs - for SubCourse practices (New Hierarchy) // Practice APIs - for SubCourse practices (New Hierarchy)
// Practices are sourced from question sets by owner_type=SUB_COURSE.
export const getPracticesBySubCourse = (subCourseId: number) => export const getPracticesBySubCourse = (subCourseId: number) =>
http.get<GetPracticesResponse>(`/course-management/sub-courses/${subCourseId}/practices`) http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
params: { owner_type: "SUB_COURSE", owner_id: subCourseId },
})
export const createPractice = (data: CreatePracticeRequest) => export const createPractice = (data: CreatePracticeRequest) =>
http.post("/course-management/practices", data) http.post("/course-management/practices", data)
@ -217,6 +221,11 @@ export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId:
export const getLearningPath = (courseId: number) => export const getLearningPath = (courseId: number) =>
http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`) http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`)
export const getSubCourseEntryAssessment = (subCourseId: number) =>
http.get<GetSubCourseEntryAssessmentResponse>(
`/question-sets/sub-courses/${subCourseId}/entry-assessment`,
)
const buildReorderPayload = (items: ReorderItem[]) => { const buildReorderPayload = (items: ReorderItem[]) => {
const normalized = items.map((item, idx) => ({ const normalized = items.map((item, idx) => ({
id: Number(item.id), id: Number(item.id),

5
src/api/progress.api.ts Normal file
View File

@ -0,0 +1,5 @@
import http from "./http"
import type { LearnerCourseProgressResponse } from "../types/progress.types"
export const getAdminLearnerCourseProgress = (userId: number, courseId: number) =>
http.get<LearnerCourseProgressResponse>(`/admin/users/${userId}/progress/courses/${courseId}`)

View File

@ -18,6 +18,16 @@ export const getUsers = (
}, },
}); });
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
export interface UpdateUserStatusRequest {
user_id: number;
status: UserStatus;
}
export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
http.patch("/user/status", payload);
export const getUserById = (id: number) => export const getUserById = (id: number) =>
http.get<UserProfileResponse>(`/user/single/${id}`); http.get<UserProfileResponse>(`/user/single/${id}`);

View File

@ -2,6 +2,8 @@ import {
BarChart3, BarChart3,
Bell, Bell,
BookOpen, BookOpen,
ChevronLeft,
ChevronRight,
CircleAlert, CircleAlert,
ClipboardList, ClipboardList,
LayoutDashboard, LayoutDashboard,
@ -39,10 +41,12 @@ const navItems: NavItem[] = [
type SidebarProps = { type SidebarProps = {
isOpen: boolean isOpen: boolean
isCollapsed: boolean
onToggleCollapse: () => void
onClose: () => void onClose: () => void
} }
export function Sidebar({ isOpen, onClose }: SidebarProps) { export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: SidebarProps) {
const [unreadCount, setUnreadCount] = useState(0) const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => { useEffect(() => {
@ -76,12 +80,31 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
{/* Sidebar panel */} {/* Sidebar panel */}
<aside <aside
className={cn( className={cn(
"fixed left-0 top-0 z-50 flex h-screen w-[264px] flex-col border-r bg-grayScale-50 px-4 py-5 transition-transform duration-300 lg:translate-x-0", "group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300",
"w-[264px] px-4 lg:translate-x-0",
isCollapsed && "lg:w-[88px] lg:px-2",
isOpen ? "translate-x-0" : "-translate-x-full", isOpen ? "translate-x-0" : "-translate-x-full",
)} )}
> >
<div className="flex items-center justify-between px-2"> <div className={cn("flex items-center justify-between px-2", isCollapsed && "justify-center")}>
<BrandLogo /> {isCollapsed ? (
<span className="h-10 w-10 overflow-hidden">
<BrandLogo className="h-10 w-auto max-w-none" />
</span>
) : (
<BrandLogo />
)}
<button
type="button"
className={cn(
"hidden h-8 w-8 place-items-center rounded-lg text-grayScale-500 transition-opacity hover:bg-grayScale-100 hover:text-brand-600 lg:grid lg:opacity-0 lg:pointer-events-none lg:group-hover:opacity-100 lg:group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto",
isCollapsed && "translate-x-2",
)}
onClick={onToggleCollapse}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? <ChevronRight className="h-5 w-5" /> : <ChevronLeft className="h-5 w-5" />}
</button>
<button <button
type="button" type="button"
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600 lg:hidden" className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600 lg:hidden"
@ -103,31 +126,36 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 transition", "group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 transition",
isCollapsed && "justify-center px-2",
"hover:bg-grayScale-100 hover:text-brand-600", "hover:bg-grayScale-100 hover:text-brand-600",
isActive && isActive &&
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100", "bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
) )
} }
title={isCollapsed ? item.label : undefined}
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
<span <span
className={cn( className={cn(
"grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600", "relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
isActive && "bg-brand-500 text-white", isActive && "bg-brand-500 text-white",
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
)}
</span> </span>
<span className="truncate">{item.label}</span> {!isCollapsed && <span className="truncate">{item.label}</span>}
{item.to === "/notifications" && unreadCount > 0 && ( {!isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white"> <span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount} {unreadCount > 99 ? "99+" : unreadCount}
</span> </span>
)} )}
{item.to !== "/notifications" && isActive ? ( {!isCollapsed && item.to !== "/notifications" && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" /> <span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
) : item.to === "/notifications" && unreadCount === 0 && isActive ? ( ) : !isCollapsed && item.to === "/notifications" && unreadCount === 0 && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" /> <span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
) : null} ) : null}
</> </>
@ -144,10 +172,14 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
localStorage.clear() localStorage.clear()
window.location.href = "/login" window.location.href = "/login"
}} }}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600" className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
isCollapsed && "justify-center px-2",
)}
title={isCollapsed ? "Logout" : undefined}
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Logout {!isCollapsed && "Logout"}
</button> </button>
</div> </div>
</aside> </aside>

View File

@ -20,7 +20,7 @@ import {
import { Badge } from "../ui/badge" import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { useNotifications } from "../../hooks/useNotifications" import { useNotifications } from "../../hooks/useNotifications"
import type { Notification } from "../../types/notification.types" import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = { const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" }, announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
@ -105,10 +105,10 @@ function NotificationItem({
!notification.is_read && "font-semibold" !notification.is_read && "font-semibold"
)} )}
> >
{notification.payload.headline} {getNotificationTitle(notification)}
</p> </p>
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500"> <p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500">
{notification.payload.message} {getNotificationMessage(notification)}
</p> </p>
<p className="mt-1 text-[11px] text-grayScale-400"> <p className="mt-1 text-[11px] text-grayScale-400">
{formatTimestamp(notification.timestamp)} {formatTimestamp(notification.timestamp)}

View File

@ -9,10 +9,10 @@ import { cn } from "../../lib/utils"
import { NotificationDropdown } from "./NotificationDropdown" import { NotificationDropdown } from "./NotificationDropdown"
type TopbarProps = { type TopbarProps = {
onMenuClick: () => void onSidebarToggle: () => void
} }
export function Topbar({ onMenuClick }: TopbarProps) { export function Topbar({ onSidebarToggle }: TopbarProps) {
const navigate = useNavigate() const navigate = useNavigate()
const [shortName, setShortName] = useState("AA") const [shortName, setShortName] = useState("AA")
@ -46,11 +46,11 @@ export function Topbar({ onMenuClick }: TopbarProps) {
return ( return (
<header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6"> <header className="sticky top-0 z-10 flex h-16 items-center justify-between gap-3 border-b bg-grayScale-50/85 px-4 backdrop-blur lg:justify-end lg:px-6">
{/* Mobile hamburger */} {/* Sidebar toggle */}
<button <button
type="button" type="button"
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600 lg:hidden" className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600 lg:hidden"
onClick={onMenuClick} onClick={onSidebarToggle}
aria-label="Open menu" aria-label="Open menu"
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />

View File

@ -5,14 +5,15 @@ import { Topbar } from "../components/topbar/Topbar"
export function AppLayout() { export function AppLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const token = localStorage.getItem("access_token") const token = localStorage.getItem("access_token")
if (!token) { if (!token) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
const handleMenuClick = useCallback(() => { const handleSidebarToggle = useCallback(() => {
setSidebarOpen(true) setSidebarOpen((prev) => !prev)
}, []) }, [])
const handleSidebarClose = useCallback(() => { const handleSidebarClose = useCallback(() => {
@ -21,9 +22,18 @@ export function AppLayout() {
return ( return (
<div className="flex min-h-screen bg-grayScale-100"> <div className="flex min-h-screen bg-grayScale-100">
<Sidebar isOpen={sidebarOpen} onClose={handleSidebarClose} /> <Sidebar
<div className="flex min-w-0 flex-1 flex-col lg:ml-[264px]"> isOpen={sidebarOpen}
<Topbar onMenuClick={handleMenuClick} /> isCollapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed((prev) => !prev)}
onClose={handleSidebarClose}
/>
<div
className={`flex min-w-0 flex-1 flex-col transition-[margin] duration-300 ${
sidebarCollapsed ? "lg:ml-[88px]" : "lg:ml-[264px]"
}`}
>
<Topbar onSidebarToggle={handleSidebarToggle} />
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6"> <main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6">
<Outlet /> <Outlet />
</main> </main>

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from "react" import { useEffect, useState, useCallback, useMemo } from "react"
import { import {
Bell, Bell,
BellOff, BellOff,
@ -20,6 +20,9 @@ import {
CheckCheck, CheckCheck,
MailX, MailX,
Search, Search,
ChevronDown,
Calendar,
Clock3,
} from "lucide-react" } from "lucide-react"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
@ -35,6 +38,17 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../../components/ui/dialog" } from "../../components/ui/dialog"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../components/ui/dropdown-menu"
import { FileUpload } from "../../components/ui/file-upload" import { FileUpload } from "../../components/ui/file-upload"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { import {
@ -48,9 +62,13 @@ import {
sendBulkEmail, sendBulkEmail,
sendBulkPush, sendBulkPush,
} from "../../api/notifications.api" } from "../../api/notifications.api"
import { getRoles } from "../../api/rbac.api"
import { getTeamMembers } from "../../api/team.api" import { getTeamMembers } from "../../api/team.api"
import type { Notification } from "../../types/notification.types" import { getUsers } from "../../api/users.api"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
import type { Role } from "../../types/rbac.types"
import type { TeamMember } from "../../types/team.types" import type { TeamMember } from "../../types/team.types"
import type { UserApiDTO } from "../../types/user.types"
import { toast } from "sonner" import { toast } from "sonner"
const PAGE_SIZE = 10 const PAGE_SIZE = 10
@ -117,6 +135,10 @@ function formatTypeLabel(type: string) {
.join(" ") .join(" ")
} }
function digitsOnly(value: string, maxLength: number) {
return value.replace(/\D/g, "").slice(0, maxLength)
}
function NotificationItem({ function NotificationItem({
notification, notification,
onToggleRead, onToggleRead,
@ -165,7 +187,7 @@ function NotificationItem({
notification.is_read ? "text-grayScale-600" : "text-grayScale-800", notification.is_read ? "text-grayScale-600" : "text-grayScale-800",
)} )}
> >
{notification.payload.headline} {getNotificationTitle(notification)}
</span> </span>
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0"> <Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
{notification.level} {notification.level}
@ -177,7 +199,7 @@ function NotificationItem({
notification.is_read ? "text-grayScale-400" : "text-grayScale-600", notification.is_read ? "text-grayScale-400" : "text-grayScale-600",
)} )}
> >
{notification.payload.message} {getNotificationMessage(notification)}
</p> </p>
</div> </div>
@ -270,10 +292,146 @@ export function NotificationsPage() {
const [bulkTitle, setBulkTitle] = useState("") const [bulkTitle, setBulkTitle] = useState("")
const [bulkMessage, setBulkMessage] = useState("") const [bulkMessage, setBulkMessage] = useState("")
const [bulkRole, setBulkRole] = useState("") const [bulkRole, setBulkRole] = useState("")
const [bulkUserIds, setBulkUserIds] = useState("") const [bulkUserIds, setBulkUserIds] = useState<number[]>([])
const [bulkScheduledAt, setBulkScheduledAt] = useState("") const [bulkScheduledAt, setBulkScheduledAt] = useState("")
const [bulkFile, setBulkFile] = useState<File | null>(null) const [bulkFile, setBulkFile] = useState<File | null>(null)
const [bulkSending, setBulkSending] = useState(false) const [bulkSending, setBulkSending] = useState(false)
const [bulkRoles, setBulkRoles] = useState<Role[]>([])
const [bulkUsers, setBulkUsers] = useState<UserApiDTO[]>([])
const [bulkRolesLoading, setBulkRolesLoading] = useState(false)
const [bulkUsersLoading, setBulkUsersLoading] = useState(false)
const [scheduleMenuOpen, setScheduleMenuOpen] = useState(false)
const [scheduleYear, setScheduleYear] = useState("")
const [scheduleMonth, setScheduleMonth] = useState("")
const [scheduleDay, setScheduleDay] = useState("")
const [scheduleHour, setScheduleHour] = useState("")
const [scheduleMinute, setScheduleMinute] = useState("")
const filteredBulkUsers = useMemo(() => {
if (!bulkRole.trim()) return bulkUsers
const selectedRole = bulkRole.trim().toLowerCase()
return bulkUsers.filter((user) => user.role?.toLowerCase() === selectedRole)
}, [bulkUsers, bulkRole])
const scheduledAtLabel = useMemo(() => {
if (!bulkScheduledAt) return "Set date & time"
const parsed = new Date(bulkScheduledAt)
if (Number.isNaN(parsed.getTime())) return bulkScheduledAt
return parsed.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}, [bulkScheduledAt])
const loadBulkOptions = useCallback(async () => {
if (!bulkOpen) return
const needsRoles = bulkRoles.length === 0
const needsUsers = bulkUsers.length === 0
if (!needsRoles && !needsUsers) return
try {
if (needsRoles) setBulkRolesLoading(true)
if (needsUsers) setBulkUsersLoading(true)
const tasks: Promise<unknown>[] = []
if (needsRoles) {
tasks.push(
getRoles({ page: 1, page_size: 20 })
.then(async (res) => {
const firstBatch = res.data?.data?.roles ?? []
const total = res.data?.data?.total ?? firstBatch.length
const pageSize = 20
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (totalPages <= 1) {
setBulkRoles(firstBatch)
return
}
const remainingRequests: Array<ReturnType<typeof getRoles>> = []
for (let page = 2; page <= totalPages; page += 1) {
remainingRequests.push(getRoles({ page, page_size: pageSize }))
}
try {
const responses = await Promise.all(remainingRequests)
const rest = responses.flatMap((r) => r.data?.data?.roles ?? [])
setBulkRoles([...firstBatch, ...rest])
} catch {
setBulkRoles(firstBatch)
}
})
.catch(() => {
setBulkRoles([])
}),
)
}
if (needsUsers) {
tasks.push(
getUsers(1, 20)
.then(async (res) => {
const firstBatch = res.data?.data?.users ?? []
const total = res.data?.data?.total ?? firstBatch.length
const pageSize = 20
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (totalPages <= 1) {
setBulkUsers(firstBatch)
return
}
const remainingRequests: Array<ReturnType<typeof getUsers>> = []
for (let page = 2; page <= totalPages; page += 1) {
remainingRequests.push(getUsers(page, pageSize))
}
try {
const responses = await Promise.all(remainingRequests)
const rest = responses.flatMap((r) => r.data?.data?.users ?? [])
setBulkUsers([...firstBatch, ...rest])
} catch {
setBulkUsers(firstBatch)
}
})
.catch(() => {
setBulkUsers([])
}),
)
}
await Promise.all(tasks)
} finally {
setBulkRolesLoading(false)
setBulkUsersLoading(false)
}
}, [bulkOpen, bulkRoles.length, bulkUsers.length])
useEffect(() => {
loadBulkOptions()
}, [loadBulkOptions])
useEffect(() => {
if (!scheduleMenuOpen) return
if (!bulkScheduledAt) {
setScheduleYear("")
setScheduleMonth("")
setScheduleDay("")
setScheduleHour("")
setScheduleMinute("")
return
}
const [datePart = "", timePart = ""] = bulkScheduledAt.split("T")
const [y = "", m = "", d = ""] = datePart.split("-")
const [hh = "", mm = ""] = timePart.split(":")
setScheduleYear(y)
setScheduleMonth(m)
setScheduleDay(d)
setScheduleHour(hh)
setScheduleMinute(mm.slice(0, 2))
}, [scheduleMenuOpen, bulkScheduledAt])
const fetchData = useCallback(async (currentOffset: number) => { const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true) setLoading(true)
@ -358,8 +516,8 @@ export function NotificationsPage() {
if (searchTerm.trim()) { if (searchTerm.trim()) {
const q = searchTerm.toLowerCase() const q = searchTerm.toLowerCase()
const haystack = [ const haystack = [
n.payload.headline, getNotificationTitle(n),
n.payload.message, getNotificationMessage(n),
formatTypeLabel(n.type), formatTypeLabel(n.type),
n.delivery_channel, n.delivery_channel,
n.level, n.level,
@ -699,12 +857,12 @@ export function NotificationsPage() {
n.is_read ? "text-grayScale-600" : "text-grayScale-800", n.is_read ? "text-grayScale-600" : "text-grayScale-800",
)} )}
> >
{n.payload.headline} {getNotificationTitle(n)}
</p> </p>
</TableCell> </TableCell>
<TableCell className="hidden lg:table-cell"> <TableCell className="hidden lg:table-cell">
<p className="max-w-sm truncate text-xs text-grayScale-500"> <p className="max-w-sm truncate text-xs text-grayScale-500">
{n.payload.message} {getNotificationMessage(n)}
</p> </p>
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -811,7 +969,7 @@ export function NotificationsPage() {
})()} })()}
</span> </span>
<span className="truncate text-base"> <span className="truncate text-base">
{selectedNotification.payload.headline} {getNotificationTitle(selectedNotification)}
</span> </span>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
@ -823,7 +981,7 @@ export function NotificationsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg bg-grayScale-50 p-3"> <div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-600">
{selectedNotification.payload.message} {getNotificationMessage(selectedNotification)}
</p> </p>
</div> </div>
@ -1109,11 +1267,7 @@ export function NotificationsPage() {
toast.error("Message is required") toast.error("Message is required")
return return
} }
const trimmedIds = bulkUserIds const userIds = bulkUserIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
const userIds = trimmedIds.map((id) => Number(id)).filter((id) => !Number.isNaN(id))
try { try {
setBulkSending(true) setBulkSending(true)
@ -1172,7 +1326,7 @@ export function NotificationsPage() {
setBulkTitle("") setBulkTitle("")
setBulkMessage("") setBulkMessage("")
setBulkRole("") setBulkRole("")
setBulkUserIds("") setBulkUserIds([])
setBulkScheduledAt("") setBulkScheduledAt("")
setBulkFile(null) setBulkFile(null)
setBulkChannel("sms") setBulkChannel("sms")
@ -1239,23 +1393,119 @@ export function NotificationsPage() {
<label className="mb-1 block text-xs font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-500">
Role (optional) Role (optional)
</label> </label>
<Input <DropdownMenu>
placeholder='e.g. "student"' <DropdownMenuTrigger asChild>
value={bulkRole} <button
onChange={(e) => setBulkRole(e.target.value)} type="button"
/> disabled={bulkRolesLoading}
className={cn(
"flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
bulkRolesLoading && "cursor-not-allowed opacity-50",
)}
>
<span className="truncate text-left">
{bulkRolesLoading ? "Loading roles..." : bulkRole || "All roles"}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-grayScale-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[220px]">
<DropdownMenuLabel>Roles</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={bulkRole}
onValueChange={(value) => {
setBulkRole(value)
setBulkUserIds([])
}}
>
<DropdownMenuRadioItem value="">All roles</DropdownMenuRadioItem>
{bulkRoles.map((role) => (
<DropdownMenuRadioItem key={role.id} value={role.name}>
{role.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-500">
User IDs (comma separated) Users (optional)
</label> </label>
<Input <DropdownMenu>
placeholder="e.g. 1,2,3" <DropdownMenuTrigger asChild>
value={bulkUserIds} <button
onChange={(e) => setBulkUserIds(e.target.value)} type="button"
/> disabled={bulkUsersLoading}
className={cn(
"flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
bulkUsersLoading && "cursor-not-allowed opacity-50",
)}
>
<span className="truncate text-left">
{bulkUsersLoading
? "Loading users..."
: bulkUserIds.length === 0
? "Select users"
: `${bulkUserIds.length} user(s) selected`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-grayScale-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px]">
<DropdownMenuLabel>Users</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setBulkUserIds(filteredBulkUsers.map((u) => u.id))
}}
disabled={filteredBulkUsers.length === 0}
>
Select all
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setBulkUserIds([])
}}
disabled={bulkUserIds.length === 0}
>
Deselect all
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto">
{filteredBulkUsers.length === 0 ? (
<p className="px-2 py-2 text-xs text-grayScale-400">No users available</p>
) : (
filteredBulkUsers.map((user) => {
const isChecked = bulkUserIds.includes(user.id)
return (
<DropdownMenuCheckboxItem
key={user.id}
checked={isChecked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(checked) => {
setBulkUserIds((prev) => {
if (checked) return prev.includes(user.id) ? prev : [...prev, user.id]
return prev.filter((id) => id !== user.id)
})
}}
>
{user.first_name} {user.last_name} ({user.id})
</DropdownMenuCheckboxItem>
)
})
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
<p className="text-[11px] text-grayScale-400">Choose one or more users from the dropdown list.</p>
</div> </div>
</div> </div>
@ -1276,11 +1526,163 @@ export function NotificationsPage() {
<label className="mb-1 block text-xs font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-500">
Scheduled at (optional) Scheduled at (optional)
</label> </label>
<Input <DropdownMenu open={scheduleMenuOpen} onOpenChange={setScheduleMenuOpen}>
type="datetime-local" <DropdownMenuTrigger asChild>
value={bulkScheduledAt} <button
onChange={(e) => setBulkScheduledAt(e.target.value)} type="button"
/> className={cn(
"flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-grayScale-50/70 px-3 text-sm text-grayScale-700 shadow-sm transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100",
)}
>
<span className="truncate text-left">{scheduledAtLabel}</span>
<span className="ml-2 inline-flex items-center gap-1 rounded-md border border-grayScale-200 bg-white px-2 py-1 text-[11px] text-grayScale-500">
<Calendar className="h-3.5 w-3.5" />
<Clock3 className="h-3.5 w-3.5" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px] p-3">
<p className="mb-2 text-xs font-semibold text-grayScale-500">Schedule notification</p>
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Date</label>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="YYYY"
value={scheduleYear}
onChange={(e) => setScheduleYear(digitsOnly(e.target.value, 4))}
inputMode="numeric"
maxLength={4}
className="h-9 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">-</span>
<Input
type="text"
placeholder="MM"
value={scheduleMonth}
onChange={(e) => setScheduleMonth(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">-</span>
<Input
type="text"
placeholder="DD"
value={scheduleDay}
onChange={(e) => setScheduleDay(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
</div>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Time</label>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="HH"
value={scheduleHour}
onChange={(e) => setScheduleHour(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">:</span>
<Input
type="text"
placeholder="MM"
value={scheduleMinute}
onChange={(e) => setScheduleMinute(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => {
const now = new Date()
setScheduleYear(String(now.getFullYear()))
setScheduleMonth(String(now.getMonth() + 1).padStart(2, "0"))
setScheduleDay(String(now.getDate()).padStart(2, "0"))
}}
>
Today
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => {
setScheduleYear("")
setScheduleMonth("")
setScheduleDay("")
setScheduleHour("")
setScheduleMinute("")
setBulkScheduledAt("")
}}
>
Clear
</Button>
</div>
<Button
type="button"
size="sm"
className="h-8"
onClick={() => {
const year = Number(scheduleYear)
const month = Number(scheduleMonth)
const day = Number(scheduleDay)
const hour = Number(scheduleHour)
const minute = Number(scheduleMinute)
const formatOk =
scheduleYear.length === 4 &&
scheduleMonth.length === 2 &&
scheduleDay.length === 2 &&
scheduleHour.length === 2 &&
scheduleMinute.length === 2
const dateValue = new Date(year, month - 1, day)
const dateOk =
formatOk &&
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= 31 &&
dateValue.getFullYear() === year &&
dateValue.getMonth() === month - 1 &&
dateValue.getDate() === day
const timeOk = hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59
if (!dateOk || !timeOk) {
toast.error("Use valid date/time format", {
description: "Date: YYYY-MM-DD, Time: HH:MM (24h).",
})
return
}
setBulkScheduledAt(
`${scheduleYear}-${scheduleMonth}-${scheduleDay}T${scheduleHour}:${scheduleMinute}`,
)
setScheduleMenuOpen(false)
}}
>
Apply
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
<p className="text-[11px] text-grayScale-400"> <p className="text-[11px] text-grayScale-400">
Leave empty to send immediately. When set, the notification is stored in{" "} Leave empty to send immediately. When set, the notification is stored in{" "}
<code>scheduled_notifications</code> and sent at the specified time. <code>scheduled_notifications</code> and sent at the specified time.
@ -1297,7 +1699,7 @@ export function NotificationsPage() {
setBulkTitle("") setBulkTitle("")
setBulkMessage("") setBulkMessage("")
setBulkRole("") setBulkRole("")
setBulkUserIds("") setBulkUserIds([])
setBulkScheduledAt("") setBulkScheduledAt("")
setBulkFile(null) setBulkFile(null)
setBulkChannel("sms") setBulkChannel("sms")

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Search, Search,
@ -23,6 +23,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api"; import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types"; import type { TeamMember } from "../../types/team.types";
import { toast } from "sonner";
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", { return new Date(dateStr).toLocaleDateString("en-US", {
@ -90,9 +91,7 @@ export function TeamManagementPage() {
const [statusFilter, setStatusFilter] = useState(""); const [statusFilter, setStatusFilter] = useState("");
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({}); const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null); const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
const [countdown, setCountdown] = useState(5);
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => { useEffect(() => {
const fetchMembers = async () => { const fetchMembers = async () => {
@ -143,30 +142,23 @@ export function TeamManagementPage() {
const currentlyActive = toggledStatuses[id] ?? false; const currentlyActive = toggledStatuses[id] ?? false;
const newStatus = currentlyActive ? "inactive" : "active"; const newStatus = currentlyActive ? "inactive" : "active";
setConfirmDialog({ id, name: `${member.first_name} ${member.last_name}`, newStatus }); setConfirmDialog({ id, name: `${member.first_name} ${member.last_name}`, newStatus });
setCountdown(5);
if (countdownRef.current) clearInterval(countdownRef.current);
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (countdownRef.current) clearInterval(countdownRef.current);
return 0;
}
return prev - 1;
});
}, 1000);
}; };
const handleConfirmStatusUpdate = async () => { const handleConfirmStatusUpdate = async () => {
if (!confirmDialog) return; if (!confirmDialog) return;
const { id, newStatus } = confirmDialog; const { id, newStatus, name } = confirmDialog;
const previousActive = toggledStatuses[id] ?? false; const previousActive = toggledStatuses[id] ?? false;
setUpdating(true); setUpdating(true);
setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" })); setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" }));
try { try {
await updateTeamMemberStatus(id, newStatus); await updateTeamMemberStatus(id, newStatus);
toast.success(
`${name || "Team member"} ${newStatus === "active" ? "activated" : "deactivated"} successfully`,
);
} catch (error) { } catch (error) {
console.error("Failed to update member status:", error); console.error("Failed to update member status:", error);
setToggledStatuses((prev) => ({ ...prev, [id]: previousActive })); setToggledStatuses((prev) => ({ ...prev, [id]: previousActive }));
toast.error("Failed to update team member status. Please try again.");
} finally { } finally {
setUpdating(false); setUpdating(false);
handleCancelConfirm(); handleCancelConfirm();
@ -174,9 +166,7 @@ export function TeamManagementPage() {
}; };
const handleCancelConfirm = () => { const handleCancelConfirm = () => {
if (countdownRef.current) clearInterval(countdownRef.current);
setConfirmDialog(null); setConfirmDialog(null);
setCountdown(5);
}; };
return ( return (
@ -252,6 +242,8 @@ export function TeamManagementPage() {
<TableRow> <TableRow>
<TableHead>USER</TableHead> <TableHead>USER</TableHead>
<TableHead>ROLE</TableHead> <TableHead>ROLE</TableHead>
<TableHead className="hidden md:table-cell">DEPARTMENT</TableHead>
<TableHead className="hidden lg:table-cell">JOB TITLE</TableHead>
<TableHead className="hidden sm:table-cell">LAST LOGIN</TableHead> <TableHead className="hidden sm:table-cell">LAST LOGIN</TableHead>
<TableHead>STATUS</TableHead> <TableHead>STATUS</TableHead>
</TableRow> </TableRow>
@ -260,7 +252,7 @@ export function TeamManagementPage() {
<TableBody> <TableBody>
{members.length === 0 ? ( {members.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-grayScale-400"> <TableCell colSpan={6} className="text-center text-grayScale-400">
No team members found No team members found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -304,6 +296,12 @@ export function TeamManagementPage() {
{formatRoleLabel(member.team_role)} {formatRoleLabel(member.team_role)}
</span> </span>
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell text-sm text-grayScale-600">
{member.department || "—"}
</TableCell>
<TableCell className="hidden lg:table-cell text-sm text-grayScale-600">
{member.job_title || "—"}
</TableCell>
<TableCell className="hidden sm:table-cell"> <TableCell className="hidden sm:table-cell">
{member.last_login ? ( {member.last_login ? (
<div> <div>
@ -326,13 +324,16 @@ export function TeamManagementPage() {
type="button" type="button"
onClick={() => handleToggle(member.id)} onClick={() => handleToggle(member.id)}
className={cn( className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors", "relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border p-0.5 transition-all duration-200",
isActive ? "bg-brand-500" : "bg-grayScale-200" "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-300 focus-visible:ring-offset-1",
isActive
? "border-brand-500 bg-brand-500 shadow-[0_6px_16px_rgba(168,85,247,0.35)]"
: "border-grayScale-300 bg-grayScale-200 hover:bg-grayScale-300/80"
)} )}
> >
<span <span
className={cn( className={cn(
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform", "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-out",
isActive ? "translate-x-5" : "translate-x-0" isActive ? "translate-x-5" : "translate-x-0"
)} )}
/> />
@ -443,13 +444,9 @@ export function TeamManagementPage() {
<Button <Button
className="bg-brand-600 hover:bg-brand-500 text-white" className="bg-brand-600 hover:bg-brand-500 text-white"
onClick={handleConfirmStatusUpdate} onClick={handleConfirmStatusUpdate}
disabled={countdown > 0 || updating} disabled={updating}
> >
{updating {updating ? "Updating..." : "Confirm"}
? "Updating..."
: countdown > 0
? `Confirm (${countdown}s)`
: "Confirm"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,11 +1,13 @@
import { useEffect } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
ArrowLeft, ArrowLeft,
BarChart3,
BookOpen, BookOpen,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
Globe, Globe,
GraduationCap, GraduationCap,
Lock,
Mail, Mail,
MapPin, MapPin,
Phone, Phone,
@ -23,6 +25,19 @@ import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { useUsersStore } from "../../zustand/userStore"; import { useUsersStore } from "../../zustand/userStore";
import { getUserById } from "../../api/users.api"; import { getUserById } from "../../api/users.api";
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
import { getAdminLearnerCourseProgress } from "../../api/progress.api";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Select } from "../../components/ui/select";
import type { LearnerCourseProgressItem } from "../../types/progress.types";
import type { Course } from "../../types/course.types";
const activityIcons: Record<string, typeof CheckCircle2> = { const activityIcons: Record<string, typeof CheckCircle2> = {
completed: CheckCircle2, completed: CheckCircle2,
@ -30,10 +45,18 @@ const activityIcons: Record<string, typeof CheckCircle2> = {
joined: UserPlus, joined: UserPlus,
}; };
type CourseOption = Course & { category_name: string };
export function UserDetailPage() { export function UserDetailPage() {
const { id } = useParams(); const { id } = useParams();
const userProfile = useUsersStore((s) => s.userProfile); const userProfile = useUsersStore((s) => s.userProfile);
const setUserProfile = useUsersStore((s) => s.setUserProfile); const setUserProfile = useUsersStore((s) => s.setUserProfile);
const [courseOptions, setCourseOptions] = useState<CourseOption[]>([]);
const [loadingCourseOptions, setLoadingCourseOptions] = useState(false);
const [selectedProgressCourseId, setSelectedProgressCourseId] = useState<number | null>(null);
const [progressItems, setProgressItems] = useState<LearnerCourseProgressItem[]>([]);
const [loadingProgress, setLoadingProgress] = useState(false);
const [progressError, setProgressError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@ -49,6 +72,87 @@ export function UserDetailPage() {
fetchUser(); fetchUser();
}, [id, setUserProfile]); }, [id, setUserProfile]);
useEffect(() => {
const loadCourseOptions = async () => {
setLoadingCourseOptions(true);
try {
const categoriesRes = await getCourseCategories();
const categories = categoriesRes.data?.data?.categories ?? [];
const options: CourseOption[] = [];
for (const category of categories) {
const coursesRes = await getCoursesByCategory(category.id);
const courses = coursesRes.data?.data?.courses ?? [];
options.push(
...courses.map((course) => ({
...course,
category_name: category.name,
})),
);
}
setCourseOptions(options);
if (options.length > 0 && !selectedProgressCourseId) {
setSelectedProgressCourseId(options[0].id);
}
} catch {
setCourseOptions([]);
} finally {
setLoadingCourseOptions(false);
}
};
loadCourseOptions();
}, []);
useEffect(() => {
if (!id || !selectedProgressCourseId) return;
const userId = Number(id);
if (Number.isNaN(userId)) return;
const loadProgress = async () => {
setLoadingProgress(true);
setProgressError(null);
try {
const res = await getAdminLearnerCourseProgress(userId, selectedProgressCourseId);
const ordered = [...(res.data?.data ?? [])].sort(
(a, b) => a.display_order - b.display_order || a.sub_course_id - b.sub_course_id,
);
setProgressItems(ordered);
} catch (err: any) {
setProgressItems([]);
const status = err?.response?.status;
if (status === 403) {
setProgressError("Missing permission: progress.get_any_user");
} else if (status === 400) {
setProgressError("Invalid learner or course selection.");
} else {
setProgressError(err?.response?.data?.message || "Failed to load learner progress.");
}
} finally {
setLoadingProgress(false);
}
};
loadProgress();
}, [id, selectedProgressCourseId]);
const progressMetrics = useMemo(() => {
const total = progressItems.length;
const completed = progressItems.filter((item) => item.progress_status === "COMPLETED").length;
const inProgress = progressItems.filter((item) => item.progress_status === "IN_PROGRESS").length;
const locked = progressItems.filter((item) => item.is_locked).length;
const averageProgress =
total === 0
? 0
: Math.round(
progressItems.reduce((sum, item) => sum + Number(item.progress_percentage || 0), 0) / total,
);
return { total, completed, inProgress, locked, averageProgress };
}, [progressItems]);
if (!userProfile) { if (!userProfile) {
return ( return (
<div className="mx-auto w-full max-w-3xl space-y-4 py-12"> <div className="mx-auto w-full max-w-3xl space-y-4 py-12">
@ -87,6 +191,25 @@ export function UserDetailPage() {
{ icon: MapPin, label: "Region", value: user.region }, { icon: MapPin, label: "Region", value: user.region },
]; ];
const statusVariant = (status: LearnerCourseProgressItem["progress_status"]) => {
if (status === "COMPLETED") return "success" as const;
if (status === "IN_PROGRESS") return "warning" as const;
return "secondary" as const;
};
const formatDateTime = (value?: string | null) => {
if (!value) return "—";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "—";
return parsed.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -285,6 +408,132 @@ export function UserDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Learner course progress */}
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-100/70">
<BarChart3 className="h-4 w-4 text-sky-600" />
</div>
<CardTitle>Learner Course Progress</CardTitle>
</div>
<div className="w-full sm:w-72">
<Select
value={selectedProgressCourseId ? String(selectedProgressCourseId) : ""}
onChange={(e) =>
setSelectedProgressCourseId(e.target.value ? Number(e.target.value) : null)
}
disabled={loadingCourseOptions || courseOptions.length === 0}
>
<option value="">
{loadingCourseOptions ? "Loading course sub-categories..." : "Select course sub-category..."}
</option>
{courseOptions.map((course) => (
<option key={course.id} value={course.id}>
{course.title} ({course.category_name})
</option>
))}
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
<Metric label="Total Courses" value={progressMetrics.total} />
<Metric label="Completed" value={progressMetrics.completed} />
<Metric label="In Progress" value={progressMetrics.inProgress} />
<Metric label="Locked" value={progressMetrics.locked} />
<Metric label="Avg Progress" value={`${progressMetrics.averageProgress}%`} />
</div>
{progressError && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{progressError}
</div>
)}
{!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" />
Loading learner progress...
</div>
)}
{!progressError && !loadingProgress && selectedProgressCourseId && progressItems.length === 0 && (
<div className="rounded-lg border border-dashed border-grayScale-200 px-3 py-5 text-center text-xs text-grayScale-400">
No learner progress records found for this course sub-category.
</div>
)}
{!progressError && !loadingProgress && progressItems.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
<Table>
<TableHeader>
<TableRow className="bg-grayScale-100/70">
<TableHead>Course</TableHead>
<TableHead>Level</TableHead>
<TableHead>Status</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Started</TableHead>
<TableHead>Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{progressItems.map((item) => (
<TableRow key={item.sub_course_id}>
<TableCell className="min-w-[220px]">
<div className="flex items-start gap-2">
{item.is_locked && <Lock className="mt-0.5 h-3.5 w-3.5 text-gold-600" />}
<div>
<p className="text-sm font-medium text-grayScale-700">{item.title}</p>
{item.description && (
<p className="mt-0.5 line-clamp-1 text-xs text-grayScale-400">{item.description}</p>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{item.level}</Badge>
</TableCell>
<TableCell>
<Badge variant={statusVariant(item.progress_status)}>{item.progress_status}</Badge>
</TableCell>
<TableCell className="min-w-[170px]">
<div className="space-y-1">
<div className="h-2 w-full rounded-full bg-grayScale-200">
<div
className={cn(
"h-2 rounded-full transition-all",
item.progress_status === "COMPLETED"
? "bg-mint-500"
: item.progress_status === "IN_PROGRESS"
? "bg-gold-600"
: "bg-grayScale-300",
)}
style={{
width: `${Math.min(100, Math.max(0, item.progress_percentage || 0))}%`,
}}
/>
</div>
<p className="text-[11px] text-grayScale-500">{item.progress_percentage}%</p>
</div>
</TableCell>
<TableCell className="text-xs text-grayScale-500">
{formatDateTime(item.started_at)}
</TableCell>
<TableCell className="text-xs text-grayScale-500">
{formatDateTime(item.completed_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Recent activity */} {/* Recent activity */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -346,6 +595,15 @@ function InfoItem({ label, value }: { label: string; value: string }) {
); );
} }
function Metric({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-wide text-grayScale-400">{label}</div>
<div className="mt-1 text-sm font-semibold text-grayScale-700">{value}</div>
</div>
);
}
function TagItem({ label, value }: { label: string; value: string }) { function TagItem({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div>

View File

@ -11,18 +11,19 @@ import {
Loader2, Loader2,
} from "lucide-react" } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
import { getUserSummary } from "../../api/users.api" import { getDashboard } from "../../api/analytics.api"
import type { UserSummary } from "../../types/user.types" import type { DashboardUsers } from "../../types/analytics.types"
export function UserManagementDashboard() { export function UserManagementDashboard() {
const [stats, setStats] = useState<UserSummary | null>(null) const [stats, setStats] = useState<DashboardUsers | null>(null)
const [statsLoading, setStatsLoading] = useState(true) const [statsLoading, setStatsLoading] = useState(true)
useEffect(() => { useEffect(() => {
const fetchStats = async () => { const fetchStats = async () => {
try { try {
const res = await getUserSummary() const res = await getDashboard()
setStats(res.data.data) const usersData = (res.data as any)?.users ?? (res.data as any)?.data?.users ?? null
setStats(usersData)
} catch { } catch {
// silently fail — cards will show "—" // silently fail — cards will show "—"
} finally { } finally {
@ -33,6 +34,8 @@ export function UserManagementDashboard() {
}, []) }, [])
const formatNum = (n: number) => n.toLocaleString() const formatNum = (n: number) => n.toLocaleString()
const activeUsers =
stats?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@ -68,7 +71,13 @@ export function UserManagementDashboard() {
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-white/80">Active Users</p> <p className="text-sm font-medium text-white/80">Active Users</p>
<p className="text-2xl font-bold text-white"> <p className="text-2xl font-bold text-white">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.active_users) : "—"} {statsLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
) : activeUsers !== null ? (
formatNum(activeUsers)
) : (
"—"
)}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -82,7 +91,13 @@ export function UserManagementDashboard() {
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-white/80">New This Month</p> <p className="text-sm font-medium text-white/80">New This Month</p>
<p className="text-2xl font-bold text-white"> <p className="text-2xl font-bold text-white">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.joined_this_month) : "—"} {statsLoading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
) : stats ? (
formatNum(stats.new_month)
) : (
"—"
)}
</p> </p>
</div> </div>
</CardContent> </CardContent>

View File

@ -1,13 +1,15 @@
import { ChevronDown, ChevronLeft, ChevronRight, Search, Users } from "lucide-react" import { ChevronDown, ChevronLeft, ChevronRight, Search, Users, X } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
import { Button } from "../../components/ui/button"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { getUsers } from "../../api/users.api" import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
import { mapUserApiToUser } from "../../types/user.types" import { mapUserApiToUser } from "../../types/user.types"
import { useUsersStore } from "../../zustand/userStore" import { useUsersStore } from "../../zustand/userStore"
import { toast } from "sonner"
export function UsersListPage() { export function UsersListPage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -26,6 +28,12 @@ export function UsersListPage() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({}) const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({})
const [updatingStatusIds, setUpdatingStatusIds] = useState<Set<number>>(new Set())
const [confirmDialog, setConfirmDialog] = useState<{
id: number
name: string
nextStatus: UserStatus
} | null>(null)
const [roleFilter, setRoleFilter] = useState("") const [roleFilter, setRoleFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("") const [statusFilter, setStatusFilter] = useState("")
@ -47,7 +55,7 @@ export function UsersListPage() {
const initialStatuses: Record<number, boolean> = {} const initialStatuses: Record<number, boolean> = {}
mapped.forEach((u) => { mapped.forEach((u) => {
initialStatuses[u.id] = true initialStatuses[u.id] = u.status === "ACTIVE"
}) })
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses })) setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }))
} catch (error) { } catch (error) {
@ -107,7 +115,46 @@ export function UsersListPage() {
} }
const handleToggle = (id: number) => { const handleToggle = (id: number) => {
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] })) if (updatingStatusIds.has(id)) return
const user = users.find((u) => u.id === id)
if (!user) return
const isCurrentlyActive = toggledStatuses[id] ?? false
const nextStatus: UserStatus = isCurrentlyActive ? "DEACTIVATED" : "ACTIVE"
setConfirmDialog({
id,
name: `${user.firstName} ${user.lastName}`.trim(),
nextStatus,
})
}
const handleConfirmStatusUpdate = async () => {
if (!confirmDialog) return
const { id, nextStatus } = confirmDialog
const nextActive = nextStatus === "ACTIVE"
const previousActive = toggledStatuses[id] ?? false
setToggledStatuses((prev) => ({ ...prev, [id]: nextActive }))
setUpdatingStatusIds((prev) => new Set(prev).add(id))
try {
await updateUserStatus({ user_id: id, status: nextStatus })
setUsers(
users.map((user) => (user.id === id ? { ...user, status: nextStatus } : user)),
)
toast.success(`User ${nextActive ? "activated" : "deactivated"} successfully`)
} catch (err: any) {
setToggledStatuses((prev) => ({ ...prev, [id]: previousActive }))
toast.error("Failed to update user status", {
description: err?.response?.data?.message || "Please try again.",
})
} finally {
setUpdatingStatusIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
setConfirmDialog(null)
}
} }
const handleRowClick = (userId: number) => { const handleRowClick = (userId: number) => {
@ -159,7 +206,9 @@ export function UsersListPage() {
> >
<option value="">All statuses</option> <option value="">All statuses</option>
<option value="ACTIVE">Active</option> <option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option> <option value="DEACTIVATED">Deactivated</option>
<option value="SUSPENDED">Suspended</option>
<option value="PENDING">Pending</option>
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" /> <ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
</div> </div>
@ -205,6 +254,7 @@ export function UsersListPage() {
) : ( ) : (
users.map((u) => { users.map((u) => {
const isActive = toggledStatuses[u.id] ?? false const isActive = toggledStatuses[u.id] ?? false
const isUpdatingStatus = updatingStatusIds.has(u.id)
return ( return (
<TableRow <TableRow
key={u.id} key={u.id}
@ -240,14 +290,19 @@ export function UsersListPage() {
<button <button
type="button" type="button"
onClick={() => handleToggle(u.id)} onClick={() => handleToggle(u.id)}
disabled={isUpdatingStatus}
className={cn( className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors", "relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border p-0.5 transition-all duration-200",
isActive ? "bg-brand-500" : "bg-grayScale-200" "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-300 focus-visible:ring-offset-1",
isActive
? "border-brand-500 bg-brand-500 shadow-[0_6px_16px_rgba(168,85,247,0.35)]"
: "border-grayScale-300 bg-grayScale-200 hover:bg-grayScale-300/80",
isUpdatingStatus && "cursor-not-allowed opacity-60",
)} )}
> >
<span <span
className={cn( className={cn(
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform", "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-out",
isActive ? "translate-x-5" : "translate-x-0" isActive ? "translate-x-5" : "translate-x-0"
)} )}
/> />
@ -331,6 +386,41 @@ export function UsersListPage() {
</div> </div>
</div> </div>
</div> </div>
{confirmDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-xl bg-white shadow-2xl">
<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">Confirm Status Change</h2>
<button
onClick={() => setConfirmDialog(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="px-6 py-6">
<p className="text-sm leading-relaxed text-grayScale-600">
Are you sure you want to change the status of{" "}
<span className="font-semibold">{confirmDialog.name || "this user"}</span> to{" "}
<span className="font-semibold capitalize">{confirmDialog.nextStatus.toLowerCase()}</span>?
</p>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setConfirmDialog(null)}>
Cancel
</Button>
<Button
className="bg-brand-600 text-white hover:bg-brand-500"
onClick={handleConfirmStatusUpdate}
disabled={updatingStatusIds.has(confirmDialog.id)}
>
{updatingStatusIds.has(confirmDialog.id) ? "Updating..." : "Confirm"}
</Button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -470,12 +470,29 @@ export interface LearningPathSubCourse {
thumbnail: string thumbnail: string
display_order: number display_order: number
level: string level: string
sub_level?: string
prerequisite_count: number prerequisite_count: number
video_count: number video_count: number
practice_count: number practice_count: number
prerequisites: { sub_course_id: number; title: string; level: string }[] prerequisites: { sub_course_id: number; title: string; level: string }[]
videos: unknown[] videos: LearningPathVideo[]
practices: unknown[] practices: LearningPathPractice[]
}
export interface LearningPathVideo {
id: number
title: string
display_order: number
duration: number
video_url: string
}
export interface LearningPathPractice {
id: number
title: string
status: string
question_count: number
display_order?: number
} }
export interface LearningPath { export interface LearningPath {
@ -497,6 +514,14 @@ export interface GetLearningPathResponse {
metadata: unknown metadata: unknown
} }
export interface GetSubCourseEntryAssessmentResponse {
message: string
data: QuestionSet | null
success: boolean
status_code: number
metadata: unknown
}
export interface ReorderItem { export interface ReorderItem {
id: number id: number
position: number position: number

View File

@ -1,6 +1,8 @@
export interface NotificationPayload { export interface NotificationPayload {
headline: string headline?: string
message: string title?: string
message?: string
body?: string
tags: string[] | null tags: string[] | null
} }
@ -20,6 +22,28 @@ export interface Notification {
image: string image: string
} }
export function getNotificationTitle(notification: Notification): string {
const payload: any = notification?.payload ?? {}
return (
payload.headline ??
payload.title ??
(notification as any)?.headline ??
(notification as any)?.title ??
""
)
}
export function getNotificationMessage(notification: Notification): string {
const payload: any = notification?.payload ?? {}
return (
payload.message ??
payload.body ??
(notification as any)?.message ??
(notification as any)?.body ??
""
)
}
export interface GetNotificationsResponse { export interface GetNotificationsResponse {
notifications: Notification[] notifications: Notification[]
total_count: number total_count: number

View File

@ -0,0 +1,20 @@
export type LearnerCourseProgressStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED"
export interface LearnerCourseProgressItem {
sub_course_id: number
title: string
description?: string | null
thumbnail?: string | null
display_order: number
level: string
progress_status: LearnerCourseProgressStatus
progress_percentage: number
started_at?: string | null
completed_at?: string | null
is_locked: boolean
}
export interface LearnerCourseProgressResponse {
message: string
data: LearnerCourseProgressItem[]
}

View File

@ -53,6 +53,7 @@ export interface User {
region: string region: string
country: string country: string
lastLogin: string | null lastLogin: string | null
status: string
} }
export const mapUserApiToUser = (u: UserApiDTO): User => ({ export const mapUserApiToUser = (u: UserApiDTO): User => ({
@ -65,6 +66,7 @@ export const mapUserApiToUser = (u: UserApiDTO): User => ({
region: u.region, region: u.region,
country: u.country, country: u.country,
lastLogin: null, lastLogin: null,
status: u.status,
}) })
export interface UserProfileData { export interface UserProfileData {