bulk notification user ID and role menu fixes + minor UI fixes
This commit is contained in:
parent
85d4199dd7
commit
31912d2e58
2
.env
2
.env
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -99,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)
|
||||||
|
|
|
||||||
5
src/api/progress.api.ts
Normal file
5
src/api/progress.api.ts
Normal 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}`)
|
||||||
|
|
@ -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}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
20
src/types/progress.types.ts
Normal file
20
src/types/progress.types.ts
Normal 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[]
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user