Compare commits
No commits in common. "31912d2e584abeb7fcac5ff6b36198b279cc9e98" and "361424402917160af0befa06ea620a5395879782" have entirely different histories.
31912d2e58
...
3614244029
2
.env
2
.env
|
|
@ -1,3 +1,3 @@
|
|||
# VITE_API_BASE_URL=https://api.yimaru.yaltopia.com/api/v1
|
||||
VITE_API_BASE_URL=http://localhost:8432/api/v1
|
||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
VITE_GOOGLE_CLIENT_ID=
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import type {
|
|||
GetSubCoursePrerequisitesResponse,
|
||||
AddSubCoursePrerequisiteRequest,
|
||||
GetLearningPathResponse,
|
||||
GetSubCourseEntryAssessmentResponse,
|
||||
ReorderItem,
|
||||
GetRatingsResponse,
|
||||
GetRatingsParams,
|
||||
|
|
@ -99,11 +98,8 @@ export const deleteSubCourseVideo = (videoId: number) =>
|
|||
http.delete(`/course-management/sub-course-videos/${videoId}`)
|
||||
|
||||
// Practice APIs - for SubCourse practices (New Hierarchy)
|
||||
// Practices are sourced from question sets by owner_type=SUB_COURSE.
|
||||
export const getPracticesBySubCourse = (subCourseId: number) =>
|
||||
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
||||
params: { owner_type: "SUB_COURSE", owner_id: subCourseId },
|
||||
})
|
||||
http.get<GetPracticesResponse>(`/course-management/sub-courses/${subCourseId}/practices`)
|
||||
|
||||
export const createPractice = (data: CreatePracticeRequest) =>
|
||||
http.post("/course-management/practices", data)
|
||||
|
|
@ -221,11 +217,6 @@ export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId:
|
|||
export const getLearningPath = (courseId: number) =>
|
||||
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 normalized = items.map((item, idx) => ({
|
||||
id: Number(item.id),
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
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,16 +18,6 @@ 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) =>
|
||||
http.get<UserProfileResponse>(`/user/single/${id}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import {
|
|||
BarChart3,
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
ClipboardList,
|
||||
LayoutDashboard,
|
||||
|
|
@ -41,12 +39,10 @@ const navItems: NavItem[] = [
|
|||
|
||||
type SidebarProps = {
|
||||
isOpen: boolean
|
||||
isCollapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: SidebarProps) {
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -80,31 +76,12 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
{/* Sidebar panel */}
|
||||
<aside
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-between px-2", isCollapsed && "justify-center")}>
|
||||
{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>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<BrandLogo />
|
||||
<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"
|
||||
|
|
@ -126,36 +103,31 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
className={({ isActive }) =>
|
||||
cn(
|
||||
"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",
|
||||
isActive &&
|
||||
"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 }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{!isCollapsed && <span className="truncate">{item.label}</span>}
|
||||
{!isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
|
||||
<span className="truncate">{item.label}</span>
|
||||
{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">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{!isCollapsed && item.to !== "/notifications" && isActive ? (
|
||||
{item.to !== "/notifications" && isActive ? (
|
||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
|
||||
) : !isCollapsed && item.to === "/notifications" && unreadCount === 0 && isActive ? (
|
||||
) : item.to === "/notifications" && unreadCount === 0 && isActive ? (
|
||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
|
||||
) : null}
|
||||
</>
|
||||
|
|
@ -172,14 +144,10 @@ export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: Side
|
|||
localStorage.clear()
|
||||
window.location.href = "/login"
|
||||
}}
|
||||
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}
|
||||
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"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{!isCollapsed && "Logout"}
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
import { Badge } from "../ui/badge"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { useNotifications } from "../../hooks/useNotifications"
|
||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
||||
import type { Notification } from "../../types/notification.types"
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
||||
|
|
@ -105,10 +105,10 @@ function NotificationItem({
|
|||
!notification.is_read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{getNotificationTitle(notification)}
|
||||
{notification.payload.headline}
|
||||
</p>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500">
|
||||
{getNotificationMessage(notification)}
|
||||
{notification.payload.message}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-grayScale-400">
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import { cn } from "../../lib/utils"
|
|||
import { NotificationDropdown } from "./NotificationDropdown"
|
||||
|
||||
type TopbarProps = {
|
||||
onSidebarToggle: () => void
|
||||
onMenuClick: () => void
|
||||
}
|
||||
|
||||
export function Topbar({ onSidebarToggle }: TopbarProps) {
|
||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
const navigate = useNavigate()
|
||||
const [shortName, setShortName] = useState("AA")
|
||||
|
||||
|
|
@ -46,11 +46,11 @@ export function Topbar({ onSidebarToggle }: TopbarProps) {
|
|||
|
||||
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">
|
||||
{/* Sidebar toggle */}
|
||||
{/* Mobile hamburger */}
|
||||
<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"
|
||||
onClick={onSidebarToggle}
|
||||
onClick={onMenuClick}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
|
|
|
|||
|
|
@ -5,15 +5,14 @@ import { Topbar } from "../components/topbar/Topbar"
|
|||
|
||||
export function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const handleSidebarToggle = useCallback(() => {
|
||||
setSidebarOpen((prev) => !prev)
|
||||
const handleMenuClick = useCallback(() => {
|
||||
setSidebarOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleSidebarClose = useCallback(() => {
|
||||
|
|
@ -22,18 +21,9 @@ export function AppLayout() {
|
|||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-grayScale-100">
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
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} />
|
||||
<Sidebar isOpen={sidebarOpen} onClose={handleSidebarClose} />
|
||||
<div className="flex min-w-0 flex-1 flex-col lg:ml-[264px]">
|
||||
<Topbar onMenuClick={handleMenuClick} />
|
||||
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useCallback, useMemo } from "react"
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
|
|
@ -20,9 +20,6 @@ import {
|
|||
CheckCheck,
|
||||
MailX,
|
||||
Search,
|
||||
ChevronDown,
|
||||
Calendar,
|
||||
Clock3,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { Badge } from "../../components/ui/badge"
|
||||
|
|
@ -38,17 +35,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { cn } from "../../lib/utils"
|
||||
import {
|
||||
|
|
@ -62,13 +48,9 @@ import {
|
|||
sendBulkEmail,
|
||||
sendBulkPush,
|
||||
} from "../../api/notifications.api"
|
||||
import { getRoles } from "../../api/rbac.api"
|
||||
import { getTeamMembers } from "../../api/team.api"
|
||||
import { getUsers } from "../../api/users.api"
|
||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
||||
import type { Role } from "../../types/rbac.types"
|
||||
import type { Notification } from "../../types/notification.types"
|
||||
import type { TeamMember } from "../../types/team.types"
|
||||
import type { UserApiDTO } from "../../types/user.types"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
|
@ -135,10 +117,6 @@ function formatTypeLabel(type: string) {
|
|||
.join(" ")
|
||||
}
|
||||
|
||||
function digitsOnly(value: string, maxLength: number) {
|
||||
return value.replace(/\D/g, "").slice(0, maxLength)
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onToggleRead,
|
||||
|
|
@ -187,7 +165,7 @@ function NotificationItem({
|
|||
notification.is_read ? "text-grayScale-600" : "text-grayScale-800",
|
||||
)}
|
||||
>
|
||||
{getNotificationTitle(notification)}
|
||||
{notification.payload.headline}
|
||||
</span>
|
||||
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
|
||||
{notification.level}
|
||||
|
|
@ -199,7 +177,7 @@ function NotificationItem({
|
|||
notification.is_read ? "text-grayScale-400" : "text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
{getNotificationMessage(notification)}
|
||||
{notification.payload.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -292,146 +270,10 @@ export function NotificationsPage() {
|
|||
const [bulkTitle, setBulkTitle] = useState("")
|
||||
const [bulkMessage, setBulkMessage] = useState("")
|
||||
const [bulkRole, setBulkRole] = useState("")
|
||||
const [bulkUserIds, setBulkUserIds] = useState<number[]>([])
|
||||
const [bulkUserIds, setBulkUserIds] = useState("")
|
||||
const [bulkScheduledAt, setBulkScheduledAt] = useState("")
|
||||
const [bulkFile, setBulkFile] = useState<File | null>(null)
|
||||
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) => {
|
||||
setLoading(true)
|
||||
|
|
@ -516,8 +358,8 @@ export function NotificationsPage() {
|
|||
if (searchTerm.trim()) {
|
||||
const q = searchTerm.toLowerCase()
|
||||
const haystack = [
|
||||
getNotificationTitle(n),
|
||||
getNotificationMessage(n),
|
||||
n.payload.headline,
|
||||
n.payload.message,
|
||||
formatTypeLabel(n.type),
|
||||
n.delivery_channel,
|
||||
n.level,
|
||||
|
|
@ -857,12 +699,12 @@ export function NotificationsPage() {
|
|||
n.is_read ? "text-grayScale-600" : "text-grayScale-800",
|
||||
)}
|
||||
>
|
||||
{getNotificationTitle(n)}
|
||||
{n.payload.headline}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<p className="max-w-sm truncate text-xs text-grayScale-500">
|
||||
{getNotificationMessage(n)}
|
||||
{n.payload.message}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -969,7 +811,7 @@ export function NotificationsPage() {
|
|||
})()}
|
||||
</span>
|
||||
<span className="truncate text-base">
|
||||
{getNotificationTitle(selectedNotification)}
|
||||
{selectedNotification.payload.headline}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -981,7 +823,7 @@ export function NotificationsPage() {
|
|||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-grayScale-50 p-3">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
{getNotificationMessage(selectedNotification)}
|
||||
{selectedNotification.payload.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -1267,7 +1109,11 @@ export function NotificationsPage() {
|
|||
toast.error("Message is required")
|
||||
return
|
||||
}
|
||||
const userIds = bulkUserIds
|
||||
const trimmedIds = bulkUserIds
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
const userIds = trimmedIds.map((id) => Number(id)).filter((id) => !Number.isNaN(id))
|
||||
|
||||
try {
|
||||
setBulkSending(true)
|
||||
|
|
@ -1326,7 +1172,7 @@ export function NotificationsPage() {
|
|||
setBulkTitle("")
|
||||
setBulkMessage("")
|
||||
setBulkRole("")
|
||||
setBulkUserIds([])
|
||||
setBulkUserIds("")
|
||||
setBulkScheduledAt("")
|
||||
setBulkFile(null)
|
||||
setBulkChannel("sms")
|
||||
|
|
@ -1393,119 +1239,23 @@ export function NotificationsPage() {
|
|||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Role (optional)
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
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>
|
||||
<Input
|
||||
placeholder='e.g. "student"'
|
||||
value={bulkRole}
|
||||
onChange={(e) => setBulkRole(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Users (optional)
|
||||
User IDs (comma separated)
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
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>
|
||||
<Input
|
||||
placeholder="e.g. 1,2,3"
|
||||
value={bulkUserIds}
|
||||
onChange={(e) => setBulkUserIds(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-grayScale-400">Choose one or more users from the dropdown list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1526,163 +1276,11 @@ export function NotificationsPage() {
|
|||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||
Scheduled at (optional)
|
||||
</label>
|
||||
<DropdownMenu open={scheduleMenuOpen} onOpenChange={setScheduleMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
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>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={bulkScheduledAt}
|
||||
onChange={(e) => setBulkScheduledAt(e.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-grayScale-400">
|
||||
Leave empty to send immediately. When set, the notification is stored in{" "}
|
||||
<code>scheduled_notifications</code> and sent at the specified time.
|
||||
|
|
@ -1699,7 +1297,7 @@ export function NotificationsPage() {
|
|||
setBulkTitle("")
|
||||
setBulkMessage("")
|
||||
setBulkRole("")
|
||||
setBulkUserIds([])
|
||||
setBulkUserIds("")
|
||||
setBulkScheduledAt("")
|
||||
setBulkFile(null)
|
||||
setBulkChannel("sms")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Search,
|
||||
|
|
@ -23,7 +23,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
|||
import { cn } from "../../lib/utils";
|
||||
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
|
||||
import type { TeamMember } from "../../types/team.types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
|
|
@ -91,7 +90,9 @@ export function TeamManagementPage() {
|
|||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [toggledStatuses, setToggledStatuses] = useState<Record<number, boolean>>({});
|
||||
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMembers = async () => {
|
||||
|
|
@ -142,23 +143,30 @@ export function TeamManagementPage() {
|
|||
const currentlyActive = toggledStatuses[id] ?? false;
|
||||
const newStatus = currentlyActive ? "inactive" : "active";
|
||||
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 () => {
|
||||
if (!confirmDialog) return;
|
||||
const { id, newStatus, name } = confirmDialog;
|
||||
const { id, newStatus } = confirmDialog;
|
||||
const previousActive = toggledStatuses[id] ?? false;
|
||||
setUpdating(true);
|
||||
setToggledStatuses((prev) => ({ ...prev, [id]: newStatus === "active" }));
|
||||
try {
|
||||
await updateTeamMemberStatus(id, newStatus);
|
||||
toast.success(
|
||||
`${name || "Team member"} ${newStatus === "active" ? "activated" : "deactivated"} successfully`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update member status:", error);
|
||||
setToggledStatuses((prev) => ({ ...prev, [id]: previousActive }));
|
||||
toast.error("Failed to update team member status. Please try again.");
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
handleCancelConfirm();
|
||||
|
|
@ -166,7 +174,9 @@ export function TeamManagementPage() {
|
|||
};
|
||||
|
||||
const handleCancelConfirm = () => {
|
||||
if (countdownRef.current) clearInterval(countdownRef.current);
|
||||
setConfirmDialog(null);
|
||||
setCountdown(5);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -242,8 +252,6 @@ export function TeamManagementPage() {
|
|||
<TableRow>
|
||||
<TableHead>USER</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>STATUS</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -252,7 +260,7 @@ export function TeamManagementPage() {
|
|||
<TableBody>
|
||||
{members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-grayScale-400">
|
||||
<TableCell colSpan={4} className="text-center text-grayScale-400">
|
||||
No team members found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -296,12 +304,6 @@ export function TeamManagementPage() {
|
|||
{formatRoleLabel(member.team_role)}
|
||||
</span>
|
||||
</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">
|
||||
{member.last_login ? (
|
||||
<div>
|
||||
|
|
@ -324,16 +326,13 @@ export function TeamManagementPage() {
|
|||
type="button"
|
||||
onClick={() => handleToggle(member.id)}
|
||||
className={cn(
|
||||
"relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border p-0.5 transition-all duration-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"
|
||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
isActive ? "bg-brand-500" : "bg-grayScale-200"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-out",
|
||||
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform",
|
||||
isActive ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
|
|
@ -444,9 +443,13 @@ export function TeamManagementPage() {
|
|||
<Button
|
||||
className="bg-brand-600 hover:bg-brand-500 text-white"
|
||||
onClick={handleConfirmStatusUpdate}
|
||||
disabled={updating}
|
||||
disabled={countdown > 0 || updating}
|
||||
>
|
||||
{updating ? "Updating..." : "Confirm"}
|
||||
{updating
|
||||
? "Updating..."
|
||||
: countdown > 0
|
||||
? `Confirm (${countdown}s)`
|
||||
: "Confirm"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
Lock,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
|
|
@ -25,19 +23,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
|||
import { cn } from "../../lib/utils";
|
||||
import { useUsersStore } from "../../zustand/userStore";
|
||||
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> = {
|
||||
completed: CheckCircle2,
|
||||
|
|
@ -45,18 +30,10 @@ const activityIcons: Record<string, typeof CheckCircle2> = {
|
|||
joined: UserPlus,
|
||||
};
|
||||
|
||||
type CourseOption = Course & { category_name: string };
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { id } = useParams();
|
||||
const userProfile = useUsersStore((s) => s.userProfile);
|
||||
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(() => {
|
||||
if (!id) return;
|
||||
|
|
@ -72,87 +49,6 @@ export function UserDetailPage() {
|
|||
fetchUser();
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 py-12">
|
||||
|
|
@ -191,25 +87,6 @@ export function UserDetailPage() {
|
|||
{ 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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -408,132 +285,6 @@ export function UserDetailPage() {
|
|||
</CardContent>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
|
|
@ -595,15 +346,6 @@ 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 }) {
|
||||
return (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -11,19 +11,18 @@ import {
|
|||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { getDashboard } from "../../api/analytics.api"
|
||||
import type { DashboardUsers } from "../../types/analytics.types"
|
||||
import { getUserSummary } from "../../api/users.api"
|
||||
import type { UserSummary } from "../../types/user.types"
|
||||
|
||||
export function UserManagementDashboard() {
|
||||
const [stats, setStats] = useState<DashboardUsers | null>(null)
|
||||
const [stats, setStats] = useState<UserSummary | null>(null)
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
const usersData = (res.data as any)?.users ?? (res.data as any)?.data?.users ?? null
|
||||
setStats(usersData)
|
||||
const res = await getUserSummary()
|
||||
setStats(res.data.data)
|
||||
} catch {
|
||||
// silently fail — cards will show "—"
|
||||
} finally {
|
||||
|
|
@ -34,8 +33,6 @@ export function UserManagementDashboard() {
|
|||
}, [])
|
||||
|
||||
const formatNum = (n: number) => n.toLocaleString()
|
||||
const activeUsers =
|
||||
stats?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
|
@ -71,13 +68,7 @@ export function UserManagementDashboard() {
|
|||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white/80">Active Users</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{statsLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||
) : activeUsers !== null ? (
|
||||
formatNum(activeUsers)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.active_users) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -91,13 +82,7 @@ export function UserManagementDashboard() {
|
|||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white/80">New This Month</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{statsLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||
) : stats ? (
|
||||
formatNum(stats.new_month)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin text-white" /> : stats ? formatNum(stats.joined_this_month) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { ChevronDown, ChevronLeft, ChevronRight, Search, Users, X } from "lucide-react"
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Search, Users } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
|
||||
import { getUsers } from "../../api/users.api"
|
||||
import { mapUserApiToUser } from "../../types/user.types"
|
||||
import { useUsersStore } from "../../zustand/userStore"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function UsersListPage() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -28,12 +26,6 @@ export function UsersListPage() {
|
|||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
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 [statusFilter, setStatusFilter] = useState("")
|
||||
|
||||
|
|
@ -55,7 +47,7 @@ export function UsersListPage() {
|
|||
|
||||
const initialStatuses: Record<number, boolean> = {}
|
||||
mapped.forEach((u) => {
|
||||
initialStatuses[u.id] = u.status === "ACTIVE"
|
||||
initialStatuses[u.id] = true
|
||||
})
|
||||
setToggledStatuses((prev) => ({ ...prev, ...initialStatuses }))
|
||||
} catch (error) {
|
||||
|
|
@ -115,46 +107,7 @@ export function UsersListPage() {
|
|||
}
|
||||
|
||||
const handleToggle = (id: number) => {
|
||||
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)
|
||||
}
|
||||
setToggledStatuses((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
const handleRowClick = (userId: number) => {
|
||||
|
|
@ -206,9 +159,7 @@ export function UsersListPage() {
|
|||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="DEACTIVATED">Deactivated</option>
|
||||
<option value="SUSPENDED">Suspended</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||||
</div>
|
||||
|
|
@ -254,7 +205,6 @@ export function UsersListPage() {
|
|||
) : (
|
||||
users.map((u) => {
|
||||
const isActive = toggledStatuses[u.id] ?? false
|
||||
const isUpdatingStatus = updatingStatusIds.has(u.id)
|
||||
return (
|
||||
<TableRow
|
||||
key={u.id}
|
||||
|
|
@ -290,19 +240,14 @@ export function UsersListPage() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggle(u.id)}
|
||||
disabled={isUpdatingStatus}
|
||||
className={cn(
|
||||
"relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border p-0.5 transition-all duration-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",
|
||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
isActive ? "bg-brand-500" : "bg-grayScale-200"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-out",
|
||||
"pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform",
|
||||
isActive ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
|
|
@ -386,41 +331,6 @@ export function UsersListPage() {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -470,29 +470,12 @@ export interface LearningPathSubCourse {
|
|||
thumbnail: string
|
||||
display_order: number
|
||||
level: string
|
||||
sub_level?: string
|
||||
prerequisite_count: number
|
||||
video_count: number
|
||||
practice_count: number
|
||||
prerequisites: { sub_course_id: number; title: string; level: string }[]
|
||||
videos: LearningPathVideo[]
|
||||
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
|
||||
videos: unknown[]
|
||||
practices: unknown[]
|
||||
}
|
||||
|
||||
export interface LearningPath {
|
||||
|
|
@ -514,14 +497,6 @@ export interface GetLearningPathResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface GetSubCourseEntryAssessmentResponse {
|
||||
message: string
|
||||
data: QuestionSet | null
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
export interface ReorderItem {
|
||||
id: number
|
||||
position: number
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
export interface NotificationPayload {
|
||||
headline?: string
|
||||
title?: string
|
||||
message?: string
|
||||
body?: string
|
||||
headline: string
|
||||
message: string
|
||||
tags: string[] | null
|
||||
}
|
||||
|
||||
|
|
@ -22,28 +20,6 @@ export interface Notification {
|
|||
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 {
|
||||
notifications: Notification[]
|
||||
total_count: number
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
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,7 +53,6 @@ export interface User {
|
|||
region: string
|
||||
country: string
|
||||
lastLogin: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
||||
|
|
@ -66,7 +65,6 @@ export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
|||
region: u.region,
|
||||
country: u.country,
|
||||
lastLogin: null,
|
||||
status: u.status,
|
||||
})
|
||||
|
||||
export interface UserProfileData {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user