From 31912d2e584abeb7fcac5ff6b36198b279cc9e98 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 9 Mar 2026 11:18:45 -0700 Subject: [PATCH] bulk notification user ID and role menu fixes + minor UI fixes --- .env | 2 +- src/api/courses.api.ts | 5 +- src/api/progress.api.ts | 5 + src/api/users.api.ts | 10 + src/components/sidebar/Sidebar.tsx | 54 +- .../topbar/NotificationDropdown.tsx | 6 +- src/components/topbar/Topbar.tsx | 8 +- src/layouts/AppLayout.tsx | 20 +- src/pages/notifications/NotificationsPage.tsx | 470 ++++++++++++++++-- src/pages/team/TeamManagementPage.tsx | 51 +- src/pages/user-management/UserDetailPage.tsx | 260 +++++++++- .../UserManagementDashboard.tsx | 29 +- src/pages/user-management/UsersListPage.tsx | 106 +++- src/types/notification.types.ts | 28 +- src/types/progress.types.ts | 20 + src/types/user.types.ts | 2 + 16 files changed, 972 insertions(+), 104 deletions(-) create mode 100644 src/api/progress.api.ts create mode 100644 src/types/progress.types.ts diff --git a/.env b/.env index fca1b5d..0855a36 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ # 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= diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index d8b0682..98e4bc5 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -99,8 +99,11 @@ 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(`/course-management/sub-courses/${subCourseId}/practices`) + http.get("/question-sets/by-owner", { + params: { owner_type: "SUB_COURSE", owner_id: subCourseId }, + }) export const createPractice = (data: CreatePracticeRequest) => http.post("/course-management/practices", data) diff --git a/src/api/progress.api.ts b/src/api/progress.api.ts new file mode 100644 index 0000000..2f78c53 --- /dev/null +++ b/src/api/progress.api.ts @@ -0,0 +1,5 @@ +import http from "./http" +import type { LearnerCourseProgressResponse } from "../types/progress.types" + +export const getAdminLearnerCourseProgress = (userId: number, courseId: number) => + http.get(`/admin/users/${userId}/progress/courses/${courseId}`) diff --git a/src/api/users.api.ts b/src/api/users.api.ts index 2b4d12e..e270d57 100644 --- a/src/api/users.api.ts +++ b/src/api/users.api.ts @@ -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) => http.get(`/user/single/${id}`); diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 4c1c1fd..23755f1 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -2,6 +2,8 @@ import { BarChart3, Bell, BookOpen, + ChevronLeft, + ChevronRight, CircleAlert, ClipboardList, LayoutDashboard, @@ -39,10 +41,12 @@ const navItems: NavItem[] = [ type SidebarProps = { isOpen: boolean + isCollapsed: boolean + onToggleCollapse: () => void onClose: () => void } -export function Sidebar({ isOpen, onClose }: SidebarProps) { +export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: SidebarProps) { const [unreadCount, setUnreadCount] = useState(0) useEffect(() => { @@ -76,12 +80,31 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { {/* Sidebar panel */} diff --git a/src/components/topbar/NotificationDropdown.tsx b/src/components/topbar/NotificationDropdown.tsx index 71528fd..59da1f8 100644 --- a/src/components/topbar/NotificationDropdown.tsx +++ b/src/components/topbar/NotificationDropdown.tsx @@ -20,7 +20,7 @@ import { import { Badge } from "../ui/badge" import { cn } from "../../lib/utils" 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 = { announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" }, @@ -105,10 +105,10 @@ function NotificationItem({ !notification.is_read && "font-semibold" )} > - {notification.payload.headline} + {getNotificationTitle(notification)}

- {notification.payload.message} + {getNotificationMessage(notification)}

{formatTimestamp(notification.timestamp)} diff --git a/src/components/topbar/Topbar.tsx b/src/components/topbar/Topbar.tsx index 6ceca01..b42a173 100644 --- a/src/components/topbar/Topbar.tsx +++ b/src/components/topbar/Topbar.tsx @@ -9,10 +9,10 @@ import { cn } from "../../lib/utils" import { NotificationDropdown } from "./NotificationDropdown" type TopbarProps = { - onMenuClick: () => void + onSidebarToggle: () => void } -export function Topbar({ onMenuClick }: TopbarProps) { +export function Topbar({ onSidebarToggle }: TopbarProps) { const navigate = useNavigate() const [shortName, setShortName] = useState("AA") @@ -46,11 +46,11 @@ export function Topbar({ onMenuClick }: TopbarProps) { return (

- {/* Mobile hamburger */} + {/* Sidebar toggle */} + + + Roles + + { + setBulkRole(value) + setBulkUserIds([]) + }} + > + All roles + {bulkRoles.map((role) => ( + + {role.name} + + ))} + + +
- setBulkUserIds(e.target.value)} - /> + + + + + + Users + + { + e.preventDefault() + setBulkUserIds(filteredBulkUsers.map((u) => u.id)) + }} + disabled={filteredBulkUsers.length === 0} + > + Select all + + { + e.preventDefault() + setBulkUserIds([]) + }} + disabled={bulkUserIds.length === 0} + > + Deselect all + + +
+ {filteredBulkUsers.length === 0 ? ( +

No users available

+ ) : ( + filteredBulkUsers.map((user) => { + const isChecked = bulkUserIds.includes(user.id) + return ( + 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}) + + ) + }) + )} +
+
+
+

Choose one or more users from the dropdown list.

@@ -1276,11 +1526,163 @@ export function NotificationsPage() { - setBulkScheduledAt(e.target.value)} - /> + + + + + +

Schedule notification

+
+
+ +
+ setScheduleYear(digitsOnly(e.target.value, 4))} + inputMode="numeric" + maxLength={4} + className="h-9 rounded-lg border-grayScale-200 bg-white text-center text-sm" + /> + - + 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" + /> + - + 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" + /> +
+
+
+ +
+ 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" + /> + : + 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" + /> +
+
+
+
+
+ + +
+ +
+
+

Leave empty to send immediately. When set, the notification is stored in{" "} scheduled_notifications and sent at the specified time. @@ -1297,7 +1699,7 @@ export function NotificationsPage() { setBulkTitle("") setBulkMessage("") setBulkRole("") - setBulkUserIds("") + setBulkUserIds([]) setBulkScheduledAt("") setBulkFile(null) setBulkChannel("sms") diff --git a/src/pages/team/TeamManagementPage.tsx b/src/pages/team/TeamManagementPage.tsx index 66c4466..8839c5b 100644 --- a/src/pages/team/TeamManagementPage.tsx +++ b/src/pages/team/TeamManagementPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Search, @@ -23,6 +23,7 @@ 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", { @@ -90,9 +91,7 @@ export function TeamManagementPage() { const [statusFilter, setStatusFilter] = useState(""); const [toggledStatuses, setToggledStatuses] = useState>({}); 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 | null>(null); useEffect(() => { const fetchMembers = async () => { @@ -143,30 +142,23 @@ 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 } = confirmDialog; + const { id, newStatus, name } = 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(); @@ -174,9 +166,7 @@ export function TeamManagementPage() { }; const handleCancelConfirm = () => { - if (countdownRef.current) clearInterval(countdownRef.current); setConfirmDialog(null); - setCountdown(5); }; return ( @@ -252,6 +242,8 @@ export function TeamManagementPage() { USER ROLE + DEPARTMENT + JOB TITLE LAST LOGIN STATUS @@ -260,7 +252,7 @@ export function TeamManagementPage() { {members.length === 0 ? ( - + No team members found @@ -304,6 +296,12 @@ export function TeamManagementPage() { {formatRoleLabel(member.team_role)} + + {member.department || "—"} + + + {member.job_title || "—"} + {member.last_login ? (

@@ -326,13 +324,16 @@ export function TeamManagementPage() { type="button" onClick={() => handleToggle(member.id)} className={cn( - "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" + "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" )} > @@ -443,13 +444,9 @@ export function TeamManagementPage() {
diff --git a/src/pages/user-management/UserDetailPage.tsx b/src/pages/user-management/UserDetailPage.tsx index 8b66a05..f43e806 100644 --- a/src/pages/user-management/UserDetailPage.tsx +++ b/src/pages/user-management/UserDetailPage.tsx @@ -1,11 +1,13 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ArrowLeft, + BarChart3, BookOpen, Calendar, CheckCircle2, Globe, GraduationCap, + Lock, Mail, MapPin, Phone, @@ -23,6 +25,19 @@ 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 = { completed: CheckCircle2, @@ -30,10 +45,18 @@ const activityIcons: Record = { 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([]); + const [loadingCourseOptions, setLoadingCourseOptions] = useState(false); + const [selectedProgressCourseId, setSelectedProgressCourseId] = useState(null); + const [progressItems, setProgressItems] = useState([]); + const [loadingProgress, setLoadingProgress] = useState(false); + const [progressError, setProgressError] = useState(null); useEffect(() => { if (!id) return; @@ -49,6 +72,87 @@ 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 (
@@ -87,6 +191,25 @@ 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 (
@@ -285,6 +408,132 @@ export function UserDetailPage() { + {/* Learner course progress */} + + +
+
+
+ +
+ Learner Course Progress +
+
+ +
+
+
+ +
+ + + + + +
+ + {progressError && ( +
+ {progressError} +
+ )} + + {!progressError && loadingProgress && ( +
+ + Loading learner progress... +
+ )} + + {!progressError && !loadingProgress && selectedProgressCourseId && progressItems.length === 0 && ( +
+ No learner progress records found for this course sub-category. +
+ )} + + {!progressError && !loadingProgress && progressItems.length > 0 && ( +
+ + + + Course + Level + Status + Progress + Started + Completed + + + + {progressItems.map((item) => ( + + +
+ {item.is_locked && } +
+

{item.title}

+ {item.description && ( +

{item.description}

+ )} +
+
+
+ + {item.level} + + + {item.progress_status} + + +
+
+
+
+

{item.progress_percentage}%

+
+ + + {formatDateTime(item.started_at)} + + + {formatDateTime(item.completed_at)} + + + ))} + +
+
+ )} +
+
+ {/* Recent activity */} @@ -346,6 +595,15 @@ function InfoItem({ label, value }: { label: string; value: string }) { ); } +function Metric({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + function TagItem({ label, value }: { label: string; value: string }) { return (
diff --git a/src/pages/user-management/UserManagementDashboard.tsx b/src/pages/user-management/UserManagementDashboard.tsx index 6833754..1023c28 100644 --- a/src/pages/user-management/UserManagementDashboard.tsx +++ b/src/pages/user-management/UserManagementDashboard.tsx @@ -11,18 +11,19 @@ import { Loader2, } from "lucide-react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card" -import { getUserSummary } from "../../api/users.api" -import type { UserSummary } from "../../types/user.types" +import { getDashboard } from "../../api/analytics.api" +import type { DashboardUsers } from "../../types/analytics.types" export function UserManagementDashboard() { - const [stats, setStats] = useState(null) + const [stats, setStats] = useState(null) const [statsLoading, setStatsLoading] = useState(true) useEffect(() => { const fetchStats = async () => { try { - const res = await getUserSummary() - setStats(res.data.data) + const res = await getDashboard() + const usersData = (res.data as any)?.users ?? (res.data as any)?.data?.users ?? null + setStats(usersData) } catch { // silently fail — cards will show "—" } finally { @@ -33,6 +34,8 @@ export function UserManagementDashboard() { }, []) const formatNum = (n: number) => n.toLocaleString() + const activeUsers = + stats?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null return (
@@ -68,7 +71,13 @@ export function UserManagementDashboard() {

Active Users

- {statsLoading ? : stats ? formatNum(stats.active_users) : "—"} + {statsLoading ? ( + + ) : activeUsers !== null ? ( + formatNum(activeUsers) + ) : ( + "—" + )}

@@ -82,7 +91,13 @@ export function UserManagementDashboard() {

New This Month

- {statsLoading ? : stats ? formatNum(stats.joined_this_month) : "—"} + {statsLoading ? ( + + ) : stats ? ( + formatNum(stats.new_month) + ) : ( + "—" + )}

diff --git a/src/pages/user-management/UsersListPage.tsx b/src/pages/user-management/UsersListPage.tsx index 1038494..a294e3d 100644 --- a/src/pages/user-management/UsersListPage.tsx +++ b/src/pages/user-management/UsersListPage.tsx @@ -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 { 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 } from "../../api/users.api" +import { getUsers, updateUserStatus, type UserStatus } 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() @@ -26,6 +28,12 @@ export function UsersListPage() { const [selectedIds, setSelectedIds] = useState>(new Set()) const [toggledStatuses, setToggledStatuses] = useState>({}) + const [updatingStatusIds, setUpdatingStatusIds] = useState>(new Set()) + const [confirmDialog, setConfirmDialog] = useState<{ + id: number + name: string + nextStatus: UserStatus + } | null>(null) const [roleFilter, setRoleFilter] = useState("") const [statusFilter, setStatusFilter] = useState("") @@ -47,7 +55,7 @@ export function UsersListPage() { const initialStatuses: Record = {} mapped.forEach((u) => { - initialStatuses[u.id] = true + initialStatuses[u.id] = u.status === "ACTIVE" }) setToggledStatuses((prev) => ({ ...prev, ...initialStatuses })) } catch (error) { @@ -107,7 +115,46 @@ export function UsersListPage() { } 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) => { @@ -159,7 +206,9 @@ export function UsersListPage() { > - + + +
@@ -205,6 +254,7 @@ export function UsersListPage() { ) : ( users.map((u) => { const isActive = toggledStatuses[u.id] ?? false + const isUpdatingStatus = updatingStatusIds.has(u.id) return ( handleToggle(u.id)} + disabled={isUpdatingStatus} className={cn( - "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" + "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", )} > @@ -331,6 +386,41 @@ export function UsersListPage() {
+ + {confirmDialog && ( +
+
+
+

Confirm Status Change

+ +
+
+

+ Are you sure you want to change the status of{" "} + {confirmDialog.name || "this user"} to{" "} + {confirmDialog.nextStatus.toLowerCase()}? +

+
+
+ + +
+
+
+ )}
) } diff --git a/src/types/notification.types.ts b/src/types/notification.types.ts index c0dffa0..a794c75 100644 --- a/src/types/notification.types.ts +++ b/src/types/notification.types.ts @@ -1,6 +1,8 @@ export interface NotificationPayload { - headline: string - message: string + headline?: string + title?: string + message?: string + body?: string tags: string[] | null } @@ -20,6 +22,28 @@ 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 diff --git a/src/types/progress.types.ts b/src/types/progress.types.ts new file mode 100644 index 0000000..bdd058a --- /dev/null +++ b/src/types/progress.types.ts @@ -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[] +} diff --git a/src/types/user.types.ts b/src/types/user.types.ts index c86aa7a..66a69fd 100644 --- a/src/types/user.types.ts +++ b/src/types/user.types.ts @@ -53,6 +53,7 @@ export interface User { region: string country: string lastLogin: string | null + status: string } export const mapUserApiToUser = (u: UserApiDTO): User => ({ @@ -65,6 +66,7 @@ export const mapUserApiToUser = (u: UserApiDTO): User => ({ region: u.region, country: u.country, lastLogin: null, + status: u.status, }) export interface UserProfileData {