settings page + inapp notifications integration + analytics page
This commit is contained in:
parent
25badbcca5
commit
fc983c055e
5
src/api/analytics.api.ts
Normal file
5
src/api/analytics.api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import http from "./http";
|
||||||
|
import type { DashboardResponse } from "../types/analytics.types";
|
||||||
|
|
||||||
|
export const getDashboard = () =>
|
||||||
|
http.get<DashboardResponse>("/analytics/dashboard");
|
||||||
22
src/api/notifications.api.ts
Normal file
22
src/api/notifications.api.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import http from "./http";
|
||||||
|
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
|
||||||
|
|
||||||
|
export const getNotifications = (limit = 10, offset = 0) =>
|
||||||
|
http.get<GetNotificationsResponse>("/notifications", {
|
||||||
|
params: { limit, offset },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUnreadCount = () =>
|
||||||
|
http.get<UnreadCountResponse>("/notifications/unread");
|
||||||
|
|
||||||
|
export const markAsRead = (id: string) =>
|
||||||
|
http.patch(`/notifications/${id}/read`);
|
||||||
|
|
||||||
|
export const markAsUnread = (id: string) =>
|
||||||
|
http.patch(`/notifications/${id}/unread`);
|
||||||
|
|
||||||
|
export const markAllRead = () =>
|
||||||
|
http.post("/notifications/mark-all-read");
|
||||||
|
|
||||||
|
export const markAllUnread = () =>
|
||||||
|
http.post("/notifications/mark-all-unread");
|
||||||
|
|
@ -31,6 +31,7 @@ import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
||||||
import { IssuesPage } from "../pages/issues/IssuesPage"
|
import { IssuesPage } from "../pages/issues/IssuesPage"
|
||||||
import { ProfilePage } from "../pages/ProfilePage"
|
import { ProfilePage } from "../pages/ProfilePage"
|
||||||
|
import { SettingsPage } from "../pages/SettingsPage"
|
||||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
||||||
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
||||||
import { LoginPage } from "../pages/auth/LoginPage"
|
import { LoginPage } from "../pages/auth/LoginPage"
|
||||||
|
|
@ -86,6 +87,7 @@ export function AppRoutes() {
|
||||||
<Route path="/team" element={<TeamManagementPage />} />
|
<Route path="/team" element={<TeamManagementPage />} />
|
||||||
<Route path="/team/:id" element={<TeamMemberDetailPage />} />
|
<Route path="/team/:id" element={<TeamMemberDetailPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ import {
|
||||||
Users2,
|
Users2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { ComponentType } from "react"
|
import { type ComponentType, useEffect, useState } from "react"
|
||||||
import { NavLink } from "react-router-dom"
|
import { NavLink } from "react-router-dom"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { BrandLogo } from "../brand/BrandLogo"
|
import { BrandLogo } from "../brand/BrandLogo"
|
||||||
|
import { getUnreadCount } from "../../api/notifications.api"
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -42,6 +43,24 @@ type SidebarProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUnread = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getUnreadCount()
|
||||||
|
setUnreadCount(res.data.unread)
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUnread()
|
||||||
|
|
||||||
|
window.addEventListener("notifications-updated", fetchUnread)
|
||||||
|
return () => window.removeEventListener("notifications-updated", fetchUnread)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
|
|
@ -101,7 +120,14 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
{isActive ? (
|
{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>
|
||||||
|
)}
|
||||||
|
{item.to !== "/notifications" && isActive ? (
|
||||||
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500" />
|
||||||
|
) : 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}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
254
src/components/topbar/NotificationDropdown.tsx
Normal file
254
src/components/topbar/NotificationDropdown.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
Info,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Megaphone,
|
||||||
|
UserPlus,
|
||||||
|
CreditCard,
|
||||||
|
BookOpen,
|
||||||
|
Video,
|
||||||
|
ShieldAlert,
|
||||||
|
Loader2,
|
||||||
|
MailOpen,
|
||||||
|
Mail,
|
||||||
|
CheckCheck,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Badge } from "../ui/badge"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import { useNotifications } from "../../hooks/useNotifications"
|
||||||
|
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" },
|
||||||
|
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
|
||||||
|
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
|
||||||
|
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
|
||||||
|
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
|
||||||
|
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
|
||||||
|
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
|
||||||
|
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
|
||||||
|
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
|
||||||
|
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
|
||||||
|
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
|
||||||
|
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||||
|
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||||
|
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
|
||||||
|
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
|
||||||
|
}
|
||||||
|
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
|
||||||
|
|
||||||
|
function formatTimestamp(ts: string) {
|
||||||
|
const date = new Date(ts)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMin = Math.floor(diffMs / 60_000)
|
||||||
|
const diffHr = Math.floor(diffMs / 3_600_000)
|
||||||
|
const diffDay = Math.floor(diffMs / 86_400_000)
|
||||||
|
if (diffMin < 1) return "Just now"
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationItem({
|
||||||
|
notification,
|
||||||
|
onMarkRead,
|
||||||
|
onMarkUnread,
|
||||||
|
}: {
|
||||||
|
notification: Notification
|
||||||
|
onMarkRead: (id: string) => void
|
||||||
|
onMarkUnread: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const cfg = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
|
||||||
|
const Icon = cfg.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
|
||||||
|
!notification.is_read && "bg-brand-100/30"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!notification.is_read) onMarkRead(notification.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Unread dot */}
|
||||||
|
{!notification.is_read && (
|
||||||
|
<span className="absolute left-1 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Type icon */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-9 w-9 shrink-0 place-items-center rounded-lg",
|
||||||
|
cfg.bg
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-4 w-4", cfg.color)} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm leading-snug text-grayScale-800",
|
||||||
|
!notification.is_read && "font-semibold"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.payload.headline}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-xs text-grayScale-500">
|
||||||
|
{notification.payload.message}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-grayScale-400">
|
||||||
|
{formatTimestamp(notification.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Read / Unread toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hidden shrink-0 self-center rounded-md p-1.5 text-grayScale-400 hover:bg-grayScale-200 hover:text-grayScale-600 group-hover:block"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (notification.is_read) {
|
||||||
|
onMarkUnread(notification.id)
|
||||||
|
} else {
|
||||||
|
onMarkRead(notification.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={notification.is_read ? "Mark as unread" : "Mark as read"}
|
||||||
|
>
|
||||||
|
{notification.is_read ? (
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<MailOpen className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationDropdown() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
loading,
|
||||||
|
markOneRead,
|
||||||
|
markOneUnread,
|
||||||
|
markAllAsRead,
|
||||||
|
} = useNotifications()
|
||||||
|
|
||||||
|
// Click-outside handler
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseDown(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener("mousedown", handleMouseDown)
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleMouseDown)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
{/* Bell button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
|
||||||
|
aria-label="Notifications"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown panel */}
|
||||||
|
{open && (
|
||||||
|
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-grayScale-800">
|
||||||
|
Notifications
|
||||||
|
</h3>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
||||||
|
{unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||||
|
onClick={markAllAsRead}
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-3.5 w-3.5" />
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="max-h-[480px] overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-grayScale-400" />
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
|
||||||
|
<BellOff className="h-8 w-8" />
|
||||||
|
<p className="text-sm">No notifications</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-1">
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
onMarkRead={markOneRead}
|
||||||
|
onMarkUnread={markOneUnread}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t px-4 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
navigate("/notifications")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View all notifications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Bell, LogOut, Menu, Settings, UserCircle2 } from "lucide-react"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { LogOut, Menu, Settings, UserCircle2 } from "lucide-react"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { NotificationDropdown } from "./NotificationDropdown"
|
||||||
|
|
||||||
type TopbarProps = {
|
type TopbarProps = {
|
||||||
onMenuClick: () => void
|
onMenuClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||||
const [shortName, setShortName] = useState("AA")
|
const navigate = useNavigate()
|
||||||
|
const [shortName, setShortName] = useState("AA")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateShortName = () => {
|
const updateShortName = () => {
|
||||||
|
|
@ -29,10 +32,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||||
const handleOptionClick = (option: string) => {
|
const handleOptionClick = (option: string) => {
|
||||||
switch (option) {
|
switch (option) {
|
||||||
case "profile":
|
case "profile":
|
||||||
console.log("Go to profile")
|
navigate("/profile")
|
||||||
break
|
break
|
||||||
case "settings":
|
case "settings":
|
||||||
console.log("Go to settings")
|
navigate("/settings")
|
||||||
break
|
break
|
||||||
case "logout":
|
case "logout":
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|
@ -55,13 +58,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button
|
<NotificationDropdown />
|
||||||
type="button"
|
|
||||||
className="grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 hover:text-brand-600 transition-colors"
|
|
||||||
aria-label="Notifications"
|
|
||||||
>
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="h-6 w-px bg-grayScale-200" />
|
<div className="h-6 w-px bg-grayScale-200" />
|
||||||
|
|
|
||||||
152
src/hooks/useNotifications.ts
Normal file
152
src/hooks/useNotifications.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { useEffect, useState, useCallback, useRef } from "react"
|
||||||
|
import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead } from "../api/notifications.api"
|
||||||
|
import type { Notification } from "../types/notification.types"
|
||||||
|
|
||||||
|
const MAX_DROPDOWN = 5
|
||||||
|
|
||||||
|
function getWsUrl() {
|
||||||
|
const base = import.meta.env.VITE_API_BASE_URL as string
|
||||||
|
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
|
||||||
|
const token = localStorage.getItem("access_token") ?? ""
|
||||||
|
return `${wsBase}/ws/connect?token=${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
const dispatchUpdate = () => {
|
||||||
|
window.dispatchEvent(new Event("notifications-updated"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const [notifRes, countRes] = await Promise.all([
|
||||||
|
getNotifications(5, 0),
|
||||||
|
getUnreadCount(),
|
||||||
|
])
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setNotifications(notifRes.data.notifications ?? [])
|
||||||
|
setUnreadCount(countRes.data.unread)
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const connectWs = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(getWsUrl())
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(event.data)
|
||||||
|
const notif: Notification = {
|
||||||
|
id: raw.id ?? crypto.randomUUID(),
|
||||||
|
recipient_id: raw.recipient_id ?? 0,
|
||||||
|
type: raw.type ?? "",
|
||||||
|
level: raw.level ?? "",
|
||||||
|
error_severity: raw.error_severity ?? "",
|
||||||
|
reciever: raw.reciever ?? "",
|
||||||
|
is_read: raw.is_read ?? false,
|
||||||
|
delivery_status: raw.delivery_status ?? "",
|
||||||
|
delivery_channel: raw.delivery_channel ?? "",
|
||||||
|
payload: {
|
||||||
|
headline: raw.payload?.headline ?? raw.payload?.title ?? raw.headline ?? raw.title ?? "",
|
||||||
|
message: raw.payload?.message ?? raw.payload?.body ?? raw.message ?? raw.body ?? "",
|
||||||
|
tags: raw.payload?.tags ?? raw.tags ?? null,
|
||||||
|
},
|
||||||
|
timestamp: raw.timestamp ?? raw.created_at ?? new Date().toISOString(),
|
||||||
|
expires: raw.expires ?? "",
|
||||||
|
image: raw.image ?? "",
|
||||||
|
}
|
||||||
|
setNotifications((prev) => [notif, ...prev].slice(0, MAX_DROPDOWN))
|
||||||
|
setUnreadCount((prev) => prev + 1)
|
||||||
|
dispatchUpdate()
|
||||||
|
} catch {
|
||||||
|
// ignore malformed messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
reconnectTimer.current = setTimeout(() => {
|
||||||
|
if (mountedRef.current) connectWs()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
fetchData()
|
||||||
|
connectWs()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
wsRef.current?.close()
|
||||||
|
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
|
||||||
|
}
|
||||||
|
}, [fetchData, connectWs])
|
||||||
|
|
||||||
|
const markOneRead = useCallback(async (id: string) => {
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
|
||||||
|
)
|
||||||
|
setUnreadCount((prev) => Math.max(0, prev - 1))
|
||||||
|
dispatchUpdate()
|
||||||
|
try {
|
||||||
|
await markAsRead(id)
|
||||||
|
} catch {
|
||||||
|
// revert on failure
|
||||||
|
await fetchData()
|
||||||
|
}
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const markOneUnread = useCallback(async (id: string) => {
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n))
|
||||||
|
)
|
||||||
|
setUnreadCount((prev) => prev + 1)
|
||||||
|
dispatchUpdate()
|
||||||
|
try {
|
||||||
|
await markAsUnread(id)
|
||||||
|
} catch {
|
||||||
|
await fetchData()
|
||||||
|
}
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const markAllAsRead = useCallback(async () => {
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
|
||||||
|
setUnreadCount(0)
|
||||||
|
dispatchUpdate()
|
||||||
|
try {
|
||||||
|
await markAllRead()
|
||||||
|
} catch {
|
||||||
|
await fetchData()
|
||||||
|
}
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
loading,
|
||||||
|
markOneRead,
|
||||||
|
markOneUnread,
|
||||||
|
markAllAsRead,
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,21 @@ export function AppLayout() {
|
||||||
<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>
|
||||||
|
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
|
||||||
|
<span>Powered by</span>
|
||||||
|
<a
|
||||||
|
href="https://yaltopia.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-semibold text-brand-500 transition-colors hover:text-brand-600"
|
||||||
|
>
|
||||||
|
Yaltopia
|
||||||
|
</a>
|
||||||
|
<span>·</span>
|
||||||
|
<span>© {new Date().getFullYear()}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
// import type { UserProfileResponse } from "../types/user.types";
|
|
||||||
import {
|
import {
|
||||||
Activity,
|
// Activity,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Coins,
|
BookOpen,
|
||||||
|
// Coins,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
TrendingUp,
|
HelpCircle,
|
||||||
|
TicketCheck,
|
||||||
|
// TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
|
Bell,
|
||||||
|
UsersRound,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
|
|
@ -22,48 +26,24 @@ import {
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts"
|
} from "recharts"
|
||||||
import { StatCard } from "../components/dashboard/StatCard"
|
import { StatCard } from "../components/dashboard/StatCard"
|
||||||
import { Button } from "../components/ui/button"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||||
import { cn } from "../lib/utils"
|
// import { cn } from "../lib/utils"
|
||||||
import { getTeamMemberById } from "../api/team.api"
|
import { getTeamMemberById } from "../api/team.api"
|
||||||
|
import { getDashboard } from "../api/analytics.api"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import type { DashboardData } from "../types/analytics.types"
|
||||||
|
|
||||||
const userGrowth = [
|
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
||||||
{ month: "Jan", users: 2400 },
|
|
||||||
{ month: "Feb", users: 2700 },
|
|
||||||
{ month: "Mar", users: 3100 },
|
|
||||||
{ month: "Apr", users: 1900 },
|
|
||||||
{ month: "May", users: 1900 },
|
|
||||||
{ month: "Jun", users: 2100 },
|
|
||||||
{ month: "Jul", users: 2050 },
|
|
||||||
{ month: "Aug", users: 2900 },
|
|
||||||
{ month: "Sep", users: 2000 },
|
|
||||||
{ month: "Oct", users: 2050 },
|
|
||||||
{ month: "Nov", users: 1850 },
|
|
||||||
{ month: "Dec", users: 1900 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const subscriptionStatus = [
|
function formatDate(dateStr: string) {
|
||||||
{ name: "Free Plan", value: 3125, color: "#9E2891" },
|
const d = new Date(dateStr)
|
||||||
{ name: "Monthly Plan", value: 5901, color: "#FFD23F" },
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||||
{ name: "3-Month Plan", value: 1203, color: "#1DE9B6" },
|
}
|
||||||
{ name: "6-Monthly Plan", value: 825, color: "#C26FC0" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const revenueTrend = [
|
|
||||||
{ month: "Jan", value: 52000 },
|
|
||||||
{ month: "Feb", value: 30000 },
|
|
||||||
{ month: "Mar", value: 50000 },
|
|
||||||
{ month: "Apr", value: 28000 },
|
|
||||||
{ month: "May", value: 70000 },
|
|
||||||
{ month: "Jun", value: 76000 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const ranges = ["1D", "1W", "1M", "3M", "6M", "1Y"] as const
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const [userFirstName, setUserFirstName] = useState<string>("")
|
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||||
const activeRange = "1Y"
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
|
|
@ -81,9 +61,47 @@ export function DashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDashboard()
|
||||||
|
setDashboard(res.data as unknown as DashboardData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchUser()
|
fetchUser()
|
||||||
|
fetchDashboard()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const registrationData =
|
||||||
|
dashboard?.users.registrations_last_30_days.map((d) => ({
|
||||||
|
date: formatDate(d.date),
|
||||||
|
count: d.count,
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
const revenueData =
|
||||||
|
dashboard?.payments.revenue_last_30_days.map((d) => ({
|
||||||
|
date: formatDate(d.date),
|
||||||
|
revenue: d.revenue,
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
const subscriptionStatusData =
|
||||||
|
dashboard?.subscriptions.by_status.map((s, i) => ({
|
||||||
|
name: s.label,
|
||||||
|
value: s.count,
|
||||||
|
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
const issueStatusData =
|
||||||
|
dashboard?.issues.by_status.map((s, i) => ({
|
||||||
|
name: s.label,
|
||||||
|
value: s.count,
|
||||||
|
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div>
|
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div>
|
||||||
|
|
@ -91,134 +109,107 @@ export function DashboardPage() {
|
||||||
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
{loading ? (
|
||||||
<StatCard
|
<div className="flex items-center justify-center py-20 text-grayScale-500">Loading dashboard…</div>
|
||||||
icon={Users}
|
) : !dashboard ? (
|
||||||
label="Total Users"
|
<div className="flex items-center justify-center py-20 text-destructive">Failed to load dashboard data.</div>
|
||||||
value="12,490"
|
) : (
|
||||||
deltaLabel="-15%"
|
<>
|
||||||
deltaPositive={false}
|
{/* Stat Cards */}
|
||||||
/>
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={BadgeCheck}
|
icon={Users}
|
||||||
label="Active Subscribers"
|
label="Total Users"
|
||||||
value="3,200"
|
value={dashboard.users.total_users.toLocaleString()}
|
||||||
deltaLabel="+35%"
|
deltaLabel={`+${dashboard.users.new_month} this month`}
|
||||||
deltaPositive
|
deltaPositive={dashboard.users.new_month > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Activity}
|
icon={BadgeCheck}
|
||||||
label="Monthly Active Users"
|
label="Active Subscribers"
|
||||||
value="521"
|
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
|
||||||
deltaLabel="+41%"
|
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
|
||||||
deltaPositive
|
deltaPositive={dashboard.subscriptions.new_month > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
label="Total Revenue (ETB)"
|
label="Total Revenue (ETB)"
|
||||||
value="927,004"
|
value={dashboard.payments.total_revenue.toLocaleString()}
|
||||||
deltaLabel="-20%"
|
deltaLabel={`${dashboard.payments.total_payments} payments`}
|
||||||
deltaPositive={false}
|
deltaPositive={dashboard.payments.total_revenue > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Coins}
|
icon={TicketCheck}
|
||||||
label="Monthly Revenue (ETB)"
|
label="Issues"
|
||||||
value="81,290"
|
value={`${dashboard.issues.resolved_issues}/${dashboard.issues.total_issues}`}
|
||||||
deltaLabel="+35%"
|
deltaLabel={`${(dashboard.issues.resolution_rate * 100).toFixed(1)}% resolved`}
|
||||||
deltaPositive
|
deltaPositive={dashboard.issues.resolution_rate > 0.5}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
</div>
|
||||||
icon={TrendingUp}
|
|
||||||
label="Growth Rate"
|
|
||||||
value="12.5%"
|
|
||||||
deltaLabel="+5%"
|
|
||||||
deltaPositive
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-4">
|
{/* Secondary Stats */}
|
||||||
<Card className="shadow-none">
|
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<CardHeader className="pb-2">
|
<StatCard
|
||||||
<div className="flex items-center justify-between gap-3">
|
icon={BookOpen}
|
||||||
<div>
|
label="Courses"
|
||||||
<CardTitle>User Growth</CardTitle>
|
value={dashboard.courses.total_courses.toLocaleString()}
|
||||||
<div className="mt-1 text-2xl font-semibold tracking-tight">5,730</div>
|
deltaLabel={`${dashboard.courses.total_sub_courses} sub-courses, ${dashboard.courses.total_videos} videos`}
|
||||||
<div className="text-xs font-medium text-mint-500">Last 12 Months +15.2%</div>
|
deltaPositive
|
||||||
</div>
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={HelpCircle}
|
||||||
|
label="Questions"
|
||||||
|
value={dashboard.content.total_questions.toLocaleString()}
|
||||||
|
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
|
||||||
|
deltaPositive
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Bell}
|
||||||
|
label="Notifications"
|
||||||
|
value={dashboard.notifications.total_sent.toLocaleString()}
|
||||||
|
deltaLabel={`${dashboard.notifications.unread_count} unread`}
|
||||||
|
deltaPositive={dashboard.notifications.unread_count === 0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={UsersRound}
|
||||||
|
label="Team Members"
|
||||||
|
value={dashboard.team.total_members.toLocaleString()}
|
||||||
|
deltaLabel={`${dashboard.team.by_role.length} roles`}
|
||||||
|
deltaPositive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 rounded-full bg-grayScale-100 p-1">
|
{/* User Registrations Chart */}
|
||||||
{ranges.map((r) => (
|
<div className="mt-5 grid gap-4">
|
||||||
<button
|
<Card className="shadow-none">
|
||||||
key={r}
|
<CardHeader className="pb-2">
|
||||||
type="button"
|
<div className="flex items-center justify-between gap-3">
|
||||||
className={cn(
|
<div>
|
||||||
"rounded-full px-3 py-1 text-xs font-semibold text-grayScale-500",
|
<CardTitle>User Registrations</CardTitle>
|
||||||
r === activeRange && "bg-brand-500 text-white",
|
<div className="mt-1 text-2xl font-semibold tracking-tight">
|
||||||
)}
|
{dashboard.users.total_users.toLocaleString()}
|
||||||
>
|
</div>
|
||||||
{r}
|
<div className="text-xs font-medium text-mint-500">
|
||||||
</button>
|
+{dashboard.users.new_today} today · +{dashboard.users.new_week} this week
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
||||||
</CardHeader>
|
Last 30 Days
|
||||||
<CardContent className="h-[280px] p-6 pt-2">
|
</div>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={userGrowth} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="fillBrand" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#9E2891" stopOpacity={0.25} />
|
|
||||||
<stop offset="100%" stopColor="#9E2891" stopOpacity={0.02} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
|
||||||
<XAxis dataKey="month" tickLine={false} axisLine={false} fontSize={12} />
|
|
||||||
<YAxis tickLine={false} axisLine={false} fontSize={12} width={32} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
borderRadius: 12,
|
|
||||||
border: "1px solid #E0E0E0",
|
|
||||||
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="users"
|
|
||||||
stroke="#9E2891"
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="url(#fillBrand)"
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<Card className="shadow-none">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>Subscription Status</CardTitle>
|
|
||||||
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
|
||||||
Weekly
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent className="h-[280px] p-6 pt-2">
|
||||||
<CardContent className="grid gap-4 p-6 pt-2 md:grid-cols-2">
|
|
||||||
<div className="h-[180px]">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<AreaChart data={registrationData} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
|
||||||
<Pie
|
<defs>
|
||||||
data={subscriptionStatus}
|
<linearGradient id="fillBrand" x1="0" y1="0" x2="0" y2="1">
|
||||||
dataKey="value"
|
<stop offset="0%" stopColor="#9E2891" stopOpacity={0.25} />
|
||||||
nameKey="name"
|
<stop offset="100%" stopColor="#9E2891" stopOpacity={0.02} />
|
||||||
innerRadius={55}
|
</linearGradient>
|
||||||
outerRadius={80}
|
</defs>
|
||||||
paddingAngle={2}
|
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
||||||
>
|
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
|
||||||
{subscriptionStatus.map((entry) => (
|
<YAxis tickLine={false} axisLine={false} fontSize={12} width={32} allowDecimals={false} />
|
||||||
<Cell key={entry.name} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
|
@ -226,60 +217,152 @@ export function DashboardPage() {
|
||||||
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PieChart>
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke="#9E2891"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#fillBrand)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{subscriptionStatus.map((s) => (
|
{/* Subscription / Issue Status Pie */}
|
||||||
<div key={s.name} className="flex items-center justify-between gap-3 text-sm">
|
<Card className="shadow-none">
|
||||||
<div className="flex items-center gap-2">
|
<CardHeader className="pb-2">
|
||||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: s.color }} />
|
<CardTitle>
|
||||||
<span className="text-grayScale-600">{s.name}</span>
|
{subscriptionStatusData.length > 0 ? "Subscription Status" : "Issue Status"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 p-6 pt-2 md:grid-cols-2">
|
||||||
|
{(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="h-[180px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).map(
|
||||||
|
(entry) => (
|
||||||
|
<Cell key={entry.name} fill={entry.color} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E0E0E0",
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).map((s) => (
|
||||||
|
<div key={s.name} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: s.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-grayScale-600">{s.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-grayScale-600">{s.value.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2 flex items-center justify-center py-10 text-sm text-grayScale-400">
|
||||||
|
No data available
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-grayScale-600">{s.value.toLocaleString()} Users</span>
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
))}
|
</Card>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="shadow-none">
|
{/* Revenue Chart */}
|
||||||
<CardHeader className="pb-2">
|
<Card className="shadow-none">
|
||||||
<div className="flex items-center justify-between">
|
<CardHeader className="pb-2">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Revenue Trend</CardTitle>
|
<div>
|
||||||
<div className="mt-2 text-2xl font-semibold tracking-tight">ETB 923,417</div>
|
<CardTitle>Revenue Trend</CardTitle>
|
||||||
<div className="text-xs font-medium text-grayScale-500">Last 6 Months (ETB)</div>
|
<div className="mt-2 text-2xl font-semibold tracking-tight">
|
||||||
</div>
|
ETB {dashboard.payments.total_revenue.toLocaleString()}
|
||||||
<Button variant="ghost" className="text-brand-600 hover:text-brand-600">
|
</div>
|
||||||
View Report
|
<div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[220px] p-6 pt-2">
|
<CardContent className="h-[220px] p-6 pt-2">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={revenueTrend} margin={{ left: 8, right: 8, top: 8 }}>
|
<BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
|
||||||
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
||||||
<XAxis dataKey="month" tickLine={false} axisLine={false} fontSize={12} />
|
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
|
||||||
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
|
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
|
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: "1px solid #E0E0E0",
|
border: "1px solid #E0E0E0",
|
||||||
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" radius={[10, 10, 0, 0]} fill="#9E2891" />
|
<Bar dataKey="revenue" radius={[10, 10, 0, 0]} fill="#9E2891" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Users by Role / Region / Knowledge Level */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{ title: "Users by Role", data: dashboard.users.by_role },
|
||||||
|
{ title: "Users by Region", data: dashboard.users.by_region },
|
||||||
|
{ title: "Users by Knowledge Level", data: dashboard.users.by_knowledge_level },
|
||||||
|
].map(({ title, data }) => (
|
||||||
|
<Card key={title} className="shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 pt-2">
|
||||||
|
{data.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.map((item, i) => (
|
||||||
|
<div key={item.label} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-grayScale-600">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400">
|
||||||
|
No data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
522
src/pages/SettingsPage.tsx
Normal file
522
src/pages/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
Languages,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
Moon,
|
||||||
|
Palette,
|
||||||
|
Save,
|
||||||
|
Shield,
|
||||||
|
Sun,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Select } from "../components/ui/select";
|
||||||
|
import { Separator } from "../components/ui/separator";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { getMyProfile } from "../api/users.api";
|
||||||
|
import type { UserProfileData } from "../types/user.types";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type SettingsTab = "profile" | "security" | "notifications" | "appearance";
|
||||||
|
|
||||||
|
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
||||||
|
{ id: "profile", label: "Profile", icon: User },
|
||||||
|
{ id: "security", label: "Security", icon: Shield },
|
||||||
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||||
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
enabled,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={enabled}
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2",
|
||||||
|
enabled ? "bg-brand-500" : "bg-grayScale-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||||||
|
enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: typeof User;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg px-3 py-4 transition-colors hover:bg-grayScale-100/50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-grayScale-100 text-grayScale-400">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-grayScale-600">{title}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-grayScale-400">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-7 w-32 rounded-lg bg-grayScale-100" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-10 w-28 rounded-lg bg-grayScale-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-grayScale-100 p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[1, 2, 3, 4].map((j) => (
|
||||||
|
<div key={j} className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-32 rounded bg-grayScale-100" />
|
||||||
|
<div className="h-3 w-48 rounded bg-grayScale-100" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-48 rounded-lg bg-grayScale-100" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
|
const [firstName, setFirstName] = useState(profile.first_name);
|
||||||
|
const [lastName, setLastName] = useState(profile.last_name);
|
||||||
|
const [nickName, setNickName] = useState(profile.nick_name || "");
|
||||||
|
const [language, setLanguage] = useState(profile.preferred_language || "en");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// placeholder — wire up to API when endpoint is ready
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
toast.success("Profile settings saved");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border border-grayScale-100">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Personal Information
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 pb-6">
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">First Name</label>
|
||||||
|
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">Last Name</label>
|
||||||
|
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">Nickname</label>
|
||||||
|
<Input value={nickName} onChange={(e) => setNickName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-grayScale-100">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-500" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-500 text-white shadow-sm">
|
||||||
|
<Languages className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Preferences
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 pb-6">
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">Preferred Language</label>
|
||||||
|
<Select value={language} onChange={(e) => setLanguage(e.target.value)}>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="am">Amharic</option>
|
||||||
|
<option value="or">Afan Oromo</option>
|
||||||
|
<option value="ti">Tigrinya</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">Timezone</label>
|
||||||
|
<Select defaultValue="eat">
|
||||||
|
<option value="eat">East Africa Time (UTC+3)</option>
|
||||||
|
<option value="utc">UTC</option>
|
||||||
|
<option value="est">Eastern Time (UTC-5)</option>
|
||||||
|
<option value="pst">Pacific Time (UTC-8)</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={saving} className="min-w-[140px]">
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{saving ? "Saving…" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecurityTab() {
|
||||||
|
const [showCurrent, setShowCurrent] = useState(false);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
|
toast.success("Password updated successfully");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border border-grayScale-100">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-600 to-brand-500" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-600 to-brand-500 text-white shadow-sm">
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Change Password
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 pb-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">Current Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input type={showCurrent ? "text" : "password"} placeholder="Enter current password" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCurrent(!showCurrent)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">New Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input type={showNew ? "text" : "password"} placeholder="Enter new password" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNew(!showNew)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-grayScale-500">Confirm New Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input type={showConfirm ? "text" : "password"} placeholder="Confirm new password" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(!showConfirm)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleChangePassword} disabled={saving} className="min-w-[160px]">
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{saving ? "Updating…" : "Update Password"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-grayScale-100">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-400" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-400 text-white shadow-sm">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Two-Factor Authentication
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<SettingRow
|
||||||
|
icon={Shield}
|
||||||
|
title="Enable 2FA"
|
||||||
|
description="Add an extra layer of security to your account"
|
||||||
|
>
|
||||||
|
<Toggle enabled={false} onToggle={() => toast.info("2FA coming soon")} />
|
||||||
|
</SettingRow>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationsTab() {
|
||||||
|
const [emailNotifs, setEmailNotifs] = useState(true);
|
||||||
|
const [pushNotifs, setPushNotifs] = useState(true);
|
||||||
|
const [loginAlerts, setLoginAlerts] = useState(true);
|
||||||
|
const [weeklyDigest, setWeeklyDigest] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border border-grayScale-100">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-sm">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Notification Preferences
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 pb-6">
|
||||||
|
<SettingRow
|
||||||
|
icon={Bell}
|
||||||
|
title="Email Notifications"
|
||||||
|
description="Receive important updates via email"
|
||||||
|
>
|
||||||
|
<Toggle enabled={emailNotifs} onToggle={() => setEmailNotifs(!emailNotifs)} />
|
||||||
|
</SettingRow>
|
||||||
|
<Separator />
|
||||||
|
<SettingRow
|
||||||
|
icon={Bell}
|
||||||
|
title="Push Notifications"
|
||||||
|
description="Get notified in the browser"
|
||||||
|
>
|
||||||
|
<Toggle enabled={pushNotifs} onToggle={() => setPushNotifs(!pushNotifs)} />
|
||||||
|
</SettingRow>
|
||||||
|
<Separator />
|
||||||
|
<SettingRow
|
||||||
|
icon={Shield}
|
||||||
|
title="Login Alerts"
|
||||||
|
description="Get notified when someone logs into your account"
|
||||||
|
>
|
||||||
|
<Toggle enabled={loginAlerts} onToggle={() => setLoginAlerts(!loginAlerts)} />
|
||||||
|
</SettingRow>
|
||||||
|
<Separator />
|
||||||
|
<SettingRow
|
||||||
|
icon={Globe}
|
||||||
|
title="Weekly Digest"
|
||||||
|
description="Receive a weekly summary of activity"
|
||||||
|
>
|
||||||
|
<Toggle enabled={weeklyDigest} onToggle={() => setWeeklyDigest(!weeklyDigest)} />
|
||||||
|
</SettingRow>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppearanceTab() {
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border border-grayScale-100">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-400 to-brand-600" />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 text-white shadow-sm">
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight text-grayScale-600">
|
||||||
|
Theme
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: "light", label: "Light", icon: Sun },
|
||||||
|
{ id: "dark", label: "Dark", icon: Moon },
|
||||||
|
{ id: "system", label: "System", icon: Globe },
|
||||||
|
] as const
|
||||||
|
).map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-2.5 rounded-xl border-2 px-4 py-5 transition-all",
|
||||||
|
theme === id
|
||||||
|
? "border-brand-500 bg-brand-100/30 text-brand-600 shadow-sm"
|
||||||
|
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-100/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||||
|
theme === id ? "bg-brand-500 text-white" : "bg-grayScale-100 text-grayScale-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>("profile");
|
||||||
|
const [profile, setProfile] = useState<UserProfileData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfile = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getMyProfile();
|
||||||
|
setProfile(res.data.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch profile", err);
|
||||||
|
setError("Failed to load settings. Please try again later.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSkeleton />;
|
||||||
|
|
||||||
|
if (error || !profile) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6">
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center gap-5 p-12">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-grayScale-100">
|
||||||
|
<User className="h-10 w-10 text-grayScale-300" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-semibold tracking-tight text-grayScale-600">
|
||||||
|
{error || "Settings not available"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
|
Please check your connection and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-6 px-4 py-8 sm:px-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Settings</h1>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
|
Manage your account preferences and configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab navigation */}
|
||||||
|
<div className="flex gap-1 rounded-xl border border-grayScale-100 bg-grayScale-100/50 p-1">
|
||||||
|
{tabs.map(({ id, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all",
|
||||||
|
activeTab === id
|
||||||
|
? "bg-white text-brand-600 shadow-sm"
|
||||||
|
: "text-grayScale-400 hover:text-grayScale-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{activeTab === "profile" && <ProfileTab profile={profile} />}
|
||||||
|
{activeTab === "security" && <SecurityTab />}
|
||||||
|
{activeTab === "notifications" && <NotificationsTab />}
|
||||||
|
{activeTab === "appearance" && <AppearanceTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,678 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
// Legend,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts"
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
BadgeCheck,
|
||||||
|
DollarSign,
|
||||||
|
BookOpen,
|
||||||
|
HelpCircle,
|
||||||
|
Bell,
|
||||||
|
TicketCheck,
|
||||||
|
UsersRound,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
CreditCard,
|
||||||
|
Video,
|
||||||
|
Layers,
|
||||||
|
FolderOpen,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import { getDashboard } from "../../api/analytics.api"
|
||||||
|
import type { DashboardData, LabelCount } from "../../types/analytics.types"
|
||||||
|
|
||||||
export function AnalyticsPage() {
|
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number) {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||||
|
return n.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
trend,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sub?: string
|
||||||
|
trend?: "up" | "down" | "neutral"
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<Card className={cn("shadow-none transition-shadow hover:shadow-md", className)}>
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
<CardContent className="p-4">
|
||||||
<Card className="shadow-none">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<CardHeader>
|
<div className="min-w-0">
|
||||||
<CardTitle>Analytics</CardTitle>
|
<div className="text-xs font-medium text-grayScale-500">{label}</div>
|
||||||
</CardHeader>
|
<div className="mt-1 text-2xl font-semibold tracking-tight">{value}</div>
|
||||||
<CardContent className="text-sm text-muted-foreground">Analytics module placeholder.</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sub && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-2 flex items-center gap-1 text-xs font-medium",
|
||||||
|
trend === "up" && "text-mint-500",
|
||||||
|
trend === "down" && "text-destructive",
|
||||||
|
(!trend || trend === "neutral") && "text-grayScale-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend === "up" && <TrendingUp className="h-3 w-3" />}
|
||||||
|
{trend === "down" && <TrendingDown className="h-3 w-3" />}
|
||||||
|
{sub}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreakdownList({
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
data: LabelCount[]
|
||||||
|
total?: number
|
||||||
|
}) {
|
||||||
|
const computedTotal = total ?? data.reduce((s, d) => s + d.count, 0)
|
||||||
|
return (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
{data.length > 0 ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{data.map((item, i) => {
|
||||||
|
const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={item.label}>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-grayScale-600">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-grayScale-700">
|
||||||
|
{item.count.toLocaleString()}
|
||||||
|
<span className="ml-1 font-normal text-grayScale-400">({pct.toFixed(0)}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-grayScale-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: PIE_COLORS[i % PIE_COLORS.length],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-xs text-grayScale-400">No data available</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DonutCard({
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
centerLabel,
|
||||||
|
centerValue,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
data: { name: string; value: number; color: string }[]
|
||||||
|
centerLabel?: string
|
||||||
|
centerValue?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
{data.length > 0 ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="relative h-[170px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={72}
|
||||||
|
paddingAngle={2}
|
||||||
|
strokeWidth={0}
|
||||||
|
>
|
||||||
|
{data.map((entry) => (
|
||||||
|
<Cell key={entry.name} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E0E0E0",
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
{centerLabel && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-lg font-semibold">{centerValue}</span>
|
||||||
|
<span className="text-[10px] text-grayScale-400">{centerLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center space-y-2">
|
||||||
|
{data.map((s) => (
|
||||||
|
<div key={s.name} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: s.color }} />
|
||||||
|
<span className="text-grayScale-600">{s.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-grayScale-700">{s.value.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-10 text-center text-xs text-grayScale-400">No data available</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
count,
|
||||||
|
defaultOpen = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: React.ElementType
|
||||||
|
count?: number
|
||||||
|
defaultOpen?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-colors hover:bg-grayScale-50"
|
||||||
|
>
|
||||||
|
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-semibold text-grayScale-800">{title}</span>
|
||||||
|
{count !== undefined && (
|
||||||
|
<Badge variant="secondary" className="mr-2 text-[10px]">
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 text-grayScale-400 transition-transform duration-200",
|
||||||
|
open && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid transition-all duration-200",
|
||||||
|
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-5 pb-5 pt-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AnalyticsPage() {
|
||||||
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
try {
|
||||||
|
const res = await getDashboard()
|
||||||
|
setDashboard(res.data as unknown as DashboardData)
|
||||||
|
} catch {
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
||||||
|
<div className="flex items-center justify-center py-20 text-grayScale-500">Loading analytics…</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !dashboard) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
<div className="mb-4 text-sm font-semibold text-grayScale-500">Analytics</div>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||||
|
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
|
||||||
|
|
||||||
|
const registrationData = users.registrations_last_30_days.map((d) => ({
|
||||||
|
date: formatDate(d.date),
|
||||||
|
count: d.count,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const subscriptionData = subscriptions.new_subscriptions_last_30_days.map((d) => ({
|
||||||
|
date: formatDate(d.date),
|
||||||
|
count: d.count,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const revenueData = payments.revenue_last_30_days.map((d) => ({
|
||||||
|
date: formatDate(d.date),
|
||||||
|
revenue: d.revenue,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const issueStatusPie = issues.by_status.map((s, i) => ({
|
||||||
|
name: s.label,
|
||||||
|
value: s.count,
|
||||||
|
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const subscriptionStatusPie = subscriptions.by_status.map((s, i) => ({
|
||||||
|
name: s.label,
|
||||||
|
value: s.count,
|
||||||
|
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const notifByTypePie = notifications.by_type.slice(0, 8).map((s, i) => ({
|
||||||
|
name: s.label,
|
||||||
|
value: s.count,
|
||||||
|
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const generatedAt = new Date(dashboard.generated_at).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-5 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-sm font-semibold text-grayScale-500">Analytics</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Platform Overview</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
|
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ─── Key Metrics ─── */}
|
||||||
|
<Section title="Key Metrics" icon={TrendingUp} defaultOpen>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
icon={Users}
|
||||||
|
label="Total Users"
|
||||||
|
value={formatNumber(users.total_users)}
|
||||||
|
sub={`+${users.new_today} today · +${users.new_week} this week · +${users.new_month} this month`}
|
||||||
|
trend={users.new_month > 0 ? "up" : "neutral"}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={BadgeCheck}
|
||||||
|
label="Active Subscriptions"
|
||||||
|
value={formatNumber(subscriptions.active_subscriptions)}
|
||||||
|
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
|
||||||
|
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={DollarSign}
|
||||||
|
label="Total Revenue"
|
||||||
|
value={`ETB ${formatNumber(payments.total_revenue)}`}
|
||||||
|
sub={`${payments.successful_payments}/${payments.total_payments} successful · Avg ETB ${payments.avg_transaction_value.toLocaleString()}`}
|
||||||
|
trend={payments.total_revenue > 0 ? "up" : "neutral"}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={TicketCheck}
|
||||||
|
label="Issue Resolution"
|
||||||
|
value={`${(issues.resolution_rate * 100).toFixed(1)}%`}
|
||||||
|
sub={`${issues.resolved_issues} resolved of ${issues.total_issues} total`}
|
||||||
|
trend={issues.resolution_rate >= 0.5 ? "up" : "down"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Content & Platform ─── */}
|
||||||
|
<Section title="Content & Platform" icon={BookOpen} count={courses.total_courses + content.total_questions} defaultOpen>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
icon={FolderOpen}
|
||||||
|
label="Categories"
|
||||||
|
value={courses.total_categories.toLocaleString()}
|
||||||
|
sub={`${courses.total_courses} courses`}
|
||||||
|
trend="neutral"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={BookOpen}
|
||||||
|
label="Sub-Courses"
|
||||||
|
value={courses.total_sub_courses.toLocaleString()}
|
||||||
|
sub={`across ${courses.total_courses} courses`}
|
||||||
|
trend="neutral"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Video}
|
||||||
|
label="Videos"
|
||||||
|
value={courses.total_videos.toLocaleString()}
|
||||||
|
trend="neutral"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={HelpCircle}
|
||||||
|
label="Questions"
|
||||||
|
value={content.total_questions.toLocaleString()}
|
||||||
|
sub={`${content.total_question_sets} question sets`}
|
||||||
|
trend="neutral"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Operations ─── */}
|
||||||
|
<Section title="Operations" icon={Bell} defaultOpen>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
icon={Bell}
|
||||||
|
label="Notifications Sent"
|
||||||
|
value={formatNumber(notifications.total_sent)}
|
||||||
|
sub={`${notifications.read_count} read · ${notifications.unread_count} unread`}
|
||||||
|
trend={notifications.unread_count === 0 ? "up" : "neutral"}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={UsersRound}
|
||||||
|
label="Team Members"
|
||||||
|
value={team.total_members.toLocaleString()}
|
||||||
|
sub={`${team.by_role.length} roles`}
|
||||||
|
trend="neutral"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={CreditCard}
|
||||||
|
label="Payments"
|
||||||
|
value={payments.total_payments.toLocaleString()}
|
||||||
|
sub={`${payments.successful_payments} successful`}
|
||||||
|
trend={payments.successful_payments > 0 ? "up" : "neutral"}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={Layers}
|
||||||
|
label="Question Sets"
|
||||||
|
value={content.total_question_sets.toLocaleString()}
|
||||||
|
sub={content.question_sets_by_type.map((q) => `${q.count} ${q.label.toLowerCase()}`).join(" · ")}
|
||||||
|
trend="neutral"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── User Analytics ─── */}
|
||||||
|
<Section title="User Analytics" icon={Users} count={users.total_users} defaultOpen>
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>User Registrations</CardTitle>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-semibold tracking-tight">
|
||||||
|
{users.total_users.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<Badge variant="success" className="text-[10px]">
|
||||||
|
+{users.new_today} today
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">Last 30 Days</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[280px] p-6 pt-2">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={registrationData} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradUsers" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#9E2891" stopOpacity={0.25} />
|
||||||
|
<stop offset="100%" stopColor="#9E2891" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={11} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} fontSize={11} width={30} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E0E0E0",
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="count" stroke="#9E2891" strokeWidth={2} fill="url(#gradUsers)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
|
||||||
|
<BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} />
|
||||||
|
<BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} />
|
||||||
|
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
|
||||||
|
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Subscriptions & Revenue ─── */}
|
||||||
|
<Section title="Subscriptions & Revenue" icon={DollarSign} defaultOpen={false}>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>New Subscriptions</CardTitle>
|
||||||
|
<div className="mt-1 text-2xl font-semibold tracking-tight">
|
||||||
|
{subscriptions.total_subscriptions.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-grayScale-400">
|
||||||
|
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">Last 30 Days</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[240px] p-6 pt-2">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={subscriptionData} margin={{ left: 8, right: 8, top: 8, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradSub" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#6366F1" stopOpacity={0.2} />
|
||||||
|
<stop offset="100%" stopColor="#6366F1" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={11} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} fontSize={11} width={30} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E0E0E0",
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="count" stroke="#6366F1" strokeWidth={2} fill="url(#gradSub)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Revenue</CardTitle>
|
||||||
|
<div className="mt-1 text-2xl font-semibold tracking-tight">
|
||||||
|
ETB {payments.total_revenue.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">Last 30 Days</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[240px] p-6 pt-2">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
|
||||||
|
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={11} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} fontSize={11} width={42} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => [`ETB ${Number(v).toLocaleString()}`, "Revenue"]}
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E0E0E0",
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="revenue" radius={[6, 6, 0, 0]} fill="#9E2891" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid items-start gap-4 lg:grid-cols-3">
|
||||||
|
<DonutCard
|
||||||
|
title="Subscription Status"
|
||||||
|
data={subscriptionStatusPie}
|
||||||
|
centerValue={subscriptions.total_subscriptions.toString()}
|
||||||
|
centerLabel="Total"
|
||||||
|
/>
|
||||||
|
<BreakdownList title="Payments by Method" data={payments.by_method} total={payments.total_payments} />
|
||||||
|
<BreakdownList title="Payments by Status" data={payments.by_status} total={payments.total_payments} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Issues & Support ─── */}
|
||||||
|
<Section title="Issues & Support" icon={TicketCheck} count={issues.total_issues} defaultOpen={false}>
|
||||||
|
<div className="grid items-start gap-4 lg:grid-cols-3">
|
||||||
|
<DonutCard
|
||||||
|
title="Issue Status"
|
||||||
|
data={issueStatusPie}
|
||||||
|
centerValue={issues.total_issues.toString()}
|
||||||
|
centerLabel="Total"
|
||||||
|
/>
|
||||||
|
<BreakdownList title="Issues by Type" data={issues.by_type} total={issues.total_issues} />
|
||||||
|
<BreakdownList title="Issues by Status" data={issues.by_status} total={issues.total_issues} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Notifications ─── */}
|
||||||
|
<Section title="Notifications" icon={Bell} count={notifications.total_sent} defaultOpen={false}>
|
||||||
|
<div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<DonutCard
|
||||||
|
title="Notification Types"
|
||||||
|
data={notifByTypePie}
|
||||||
|
centerValue={notifications.total_sent.toString()}
|
||||||
|
centerLabel="Sent"
|
||||||
|
/>
|
||||||
|
<BreakdownList title="Notifications by Channel" data={notifications.by_channel} total={notifications.total_sent} />
|
||||||
|
<BreakdownList title="Notifications by Type" data={notifications.by_type} total={notifications.total_sent} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Content Breakdown ─── */}
|
||||||
|
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
|
||||||
|
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||||
|
<BreakdownList title="Questions by Type" data={content.questions_by_type} />
|
||||||
|
<BreakdownList title="Question Sets by Type" data={content.question_sets_by_type} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Team ─── */}
|
||||||
|
<Section title="Team" icon={UsersRound} count={team.total_members} defaultOpen={false}>
|
||||||
|
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||||
|
<BreakdownList title="Team by Role" data={team.by_role} total={team.total_members} />
|
||||||
|
<BreakdownList title="Team by Status" data={team.by_status} total={team.total_members} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,440 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { useEffect, useState, useCallback } from "react"
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Megaphone,
|
||||||
|
UserPlus,
|
||||||
|
CreditCard,
|
||||||
|
BookOpen,
|
||||||
|
Video,
|
||||||
|
ShieldAlert,
|
||||||
|
Loader2,
|
||||||
|
MailOpen,
|
||||||
|
Mail,
|
||||||
|
CheckCheck,
|
||||||
|
MailX,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import {
|
||||||
|
getNotifications,
|
||||||
|
getUnreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAsUnread,
|
||||||
|
markAllRead,
|
||||||
|
markAllUnread,
|
||||||
|
} from "../../api/notifications.api"
|
||||||
|
import type { Notification } from "../../types/notification.types"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||||
|
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
||||||
|
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
|
||||||
|
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
|
||||||
|
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
|
||||||
|
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
|
||||||
|
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
|
||||||
|
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
|
||||||
|
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
|
||||||
|
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
|
||||||
|
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
|
||||||
|
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
|
||||||
|
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||||
|
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||||
|
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
|
||||||
|
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
|
||||||
|
|
||||||
|
function getLevelBadge(level: string) {
|
||||||
|
switch (level) {
|
||||||
|
case "error":
|
||||||
|
case "critical":
|
||||||
|
return "destructive" as const
|
||||||
|
case "warning":
|
||||||
|
return "warning" as const
|
||||||
|
case "success":
|
||||||
|
return "success" as const
|
||||||
|
case "info":
|
||||||
|
default:
|
||||||
|
return "info" as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: string) {
|
||||||
|
const date = new Date(ts)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMin = Math.floor(diffMs / 60_000)
|
||||||
|
const diffHr = Math.floor(diffMs / 3_600_000)
|
||||||
|
const diffDay = Math.floor(diffMs / 86_400_000)
|
||||||
|
|
||||||
|
if (diffMin < 1) return "Just now"
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`
|
||||||
|
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTypeLabel(type: string) {
|
||||||
|
return type
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationItem({
|
||||||
|
notification,
|
||||||
|
onToggleRead,
|
||||||
|
toggling,
|
||||||
|
}: {
|
||||||
|
notification: Notification
|
||||||
|
onToggleRead: (id: string, currentlyRead: boolean) => void
|
||||||
|
toggling: boolean
|
||||||
|
}) {
|
||||||
|
const config = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
export function NotificationsPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div
|
||||||
<div className="mb-4 text-sm font-semibold text-grayScale-500">Notifications</div>
|
className={cn(
|
||||||
<Card className="shadow-none">
|
"group relative flex gap-4 rounded-xl border p-4 transition-all",
|
||||||
<CardHeader>
|
notification.is_read
|
||||||
<CardTitle>Notifications</CardTitle>
|
? "border-transparent bg-white hover:bg-grayScale-50"
|
||||||
</CardHeader>
|
: "border-brand-100 bg-brand-50/30 hover:bg-brand-50/50",
|
||||||
<CardContent className="text-sm text-muted-foreground">Notifications module placeholder.</CardContent>
|
)}
|
||||||
</Card>
|
>
|
||||||
|
{/* Unread dot */}
|
||||||
|
{!notification.is_read && (
|
||||||
|
<span className="absolute left-1.5 top-1.5 h-2 w-2 rounded-full bg-brand-500" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid h-10 w-10 shrink-0 place-items-center rounded-xl",
|
||||||
|
config.bg,
|
||||||
|
config.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-semibold",
|
||||||
|
notification.is_read ? "text-grayScale-600" : "text-grayScale-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.payload.headline}
|
||||||
|
</span>
|
||||||
|
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
|
||||||
|
{notification.level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 text-sm leading-relaxed",
|
||||||
|
notification.is_read ? "text-grayScale-400" : "text-grayScale-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.payload.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="text-xs text-grayScale-400">
|
||||||
|
{formatTimestamp(notification.timestamp)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={toggling}
|
||||||
|
onClick={() => onToggleRead(notification.id, notification.is_read)}
|
||||||
|
className={cn(
|
||||||
|
"grid h-7 w-7 place-items-center rounded-lg transition-colors",
|
||||||
|
"opacity-0 group-hover:opacity-100 focus:opacity-100",
|
||||||
|
notification.is_read
|
||||||
|
? "text-grayScale-400 hover:bg-brand-50 hover:text-brand-600"
|
||||||
|
: "text-brand-500 hover:bg-brand-100 hover:text-brand-700",
|
||||||
|
toggling && "opacity-50",
|
||||||
|
)}
|
||||||
|
title={notification.is_read ? "Mark as unread" : "Mark as read"}
|
||||||
|
>
|
||||||
|
{toggling ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : notification.is_read ? (
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<MailOpen className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
||||||
|
{formatTypeLabel(notification.type)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
||||||
|
{notification.delivery_channel}
|
||||||
|
</Badge>
|
||||||
|
{notification.delivery_status !== "delivered" && notification.delivery_status !== "pending" && (
|
||||||
|
<Badge variant="warning" className="text-[10px] px-2 py-0">
|
||||||
|
{notification.delivery_status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{notification.payload.tags && notification.payload.tags.length > 0 && (
|
||||||
|
notification.payload.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-[10px] px-2 py-0">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NotificationsPage() {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
|
const [globalUnread, setGlobalUnread] = useState(0)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (currentOffset: number) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
try {
|
||||||
|
const [notifRes, unreadRes] = await Promise.all([
|
||||||
|
getNotifications(PAGE_SIZE, currentOffset),
|
||||||
|
getUnreadCount(),
|
||||||
|
])
|
||||||
|
setNotifications(notifRes.data.notifications ?? [])
|
||||||
|
setTotalCount(notifRes.data.total_count)
|
||||||
|
setGlobalUnread(unreadRes.data.unread)
|
||||||
|
} catch {
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(offset)
|
||||||
|
}, [offset, fetchData])
|
||||||
|
|
||||||
|
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
|
||||||
|
setTogglingIds((prev) => new Set(prev).add(id))
|
||||||
|
try {
|
||||||
|
if (currentlyRead) {
|
||||||
|
await markAsUnread(id)
|
||||||
|
} else {
|
||||||
|
await markAsRead(id)
|
||||||
|
}
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, is_read: !currentlyRead } : n)),
|
||||||
|
)
|
||||||
|
setGlobalUnread((prev) => (currentlyRead ? prev + 1 : Math.max(0, prev - 1)))
|
||||||
|
} catch {
|
||||||
|
// silently fail — user can retry
|
||||||
|
} finally {
|
||||||
|
setTogglingIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMarkAllRead = useCallback(async () => {
|
||||||
|
setBulkLoading(true)
|
||||||
|
try {
|
||||||
|
await markAllRead()
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })))
|
||||||
|
setGlobalUnread(0)
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setBulkLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMarkAllUnread = useCallback(async () => {
|
||||||
|
setBulkLoading(true)
|
||||||
|
try {
|
||||||
|
await markAllUnread()
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: false })))
|
||||||
|
setGlobalUnread(totalCount)
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setBulkLoading(false)
|
||||||
|
}
|
||||||
|
}, [totalCount])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||||
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-5">
|
||||||
|
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<Badge variant="secondary">{totalCount}</Badge>
|
||||||
|
)}
|
||||||
|
{globalUnread > 0 && (
|
||||||
|
<Badge variant="default">{globalUnread} unread</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk actions */}
|
||||||
|
{!loading && !error && notifications.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{globalUnread > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkLoading}
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
>
|
||||||
|
{bulkLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCheck className="mr-2 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={bulkLoading}
|
||||||
|
onClick={handleMarkAllUnread}
|
||||||
|
>
|
||||||
|
{bulkLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MailX className="mr-2 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Mark all unread
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{!loading && error && (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-16">
|
||||||
|
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||||
|
<span className="text-sm text-destructive">Failed to load notifications.</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => fetchData(offset)}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty */}
|
||||||
|
{!loading && !error && notifications.length === 0 && (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-20">
|
||||||
|
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-grayScale-100">
|
||||||
|
<BellOff className="h-7 w-7 text-grayScale-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-grayScale-500">No notifications yet</span>
|
||||||
|
<span className="text-xs text-grayScale-400">When you receive notifications, they'll appear here.</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notification list */}
|
||||||
|
{!loading && !error && notifications.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardContent className="divide-y-0 p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
onToggleRead={handleToggleRead}
|
||||||
|
toggling={togglingIds.has(n.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-grayScale-400">
|
||||||
|
Showing {offset + 1}–{Math.min(offset + PAGE_SIZE, totalCount)} of {totalCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="px-3 text-xs font-medium text-grayScale-600">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
105
src/types/analytics.types.ts
Normal file
105
src/types/analytics.types.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
export interface LabelCount {
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateCount {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRevenue {
|
||||||
|
date: string
|
||||||
|
revenue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenuePlan {
|
||||||
|
label: string
|
||||||
|
revenue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardUsers {
|
||||||
|
total_users: number
|
||||||
|
new_today: number
|
||||||
|
new_week: number
|
||||||
|
new_month: number
|
||||||
|
by_role: LabelCount[]
|
||||||
|
by_status: LabelCount[]
|
||||||
|
by_age_group: LabelCount[]
|
||||||
|
by_knowledge_level: LabelCount[]
|
||||||
|
by_region: LabelCount[]
|
||||||
|
registrations_last_30_days: DateCount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSubscriptions {
|
||||||
|
total_subscriptions: number
|
||||||
|
active_subscriptions: number
|
||||||
|
new_today: number
|
||||||
|
new_week: number
|
||||||
|
new_month: number
|
||||||
|
by_status: LabelCount[]
|
||||||
|
revenue_by_plan: RevenuePlan[]
|
||||||
|
new_subscriptions_last_30_days: DateCount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPayments {
|
||||||
|
total_revenue: number
|
||||||
|
avg_transaction_value: number
|
||||||
|
total_payments: number
|
||||||
|
successful_payments: number
|
||||||
|
by_status: LabelCount[]
|
||||||
|
by_method: LabelCount[]
|
||||||
|
revenue_last_30_days: DateRevenue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCourses {
|
||||||
|
total_categories: number
|
||||||
|
total_courses: number
|
||||||
|
total_sub_courses: number
|
||||||
|
total_videos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardContent {
|
||||||
|
total_questions: number
|
||||||
|
total_question_sets: number
|
||||||
|
questions_by_type: LabelCount[]
|
||||||
|
question_sets_by_type: LabelCount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardNotifications {
|
||||||
|
total_sent: number
|
||||||
|
read_count: number
|
||||||
|
unread_count: number
|
||||||
|
by_channel: LabelCount[]
|
||||||
|
by_type: LabelCount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardIssues {
|
||||||
|
total_issues: number
|
||||||
|
resolved_issues: number
|
||||||
|
resolution_rate: number
|
||||||
|
by_status: LabelCount[]
|
||||||
|
by_type: LabelCount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTeam {
|
||||||
|
total_members: number
|
||||||
|
by_role: LabelCount[]
|
||||||
|
by_status: LabelCount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
generated_at: string
|
||||||
|
users: DashboardUsers
|
||||||
|
subscriptions: DashboardSubscriptions
|
||||||
|
payments: DashboardPayments
|
||||||
|
courses: DashboardCourses
|
||||||
|
content: DashboardContent
|
||||||
|
notifications: DashboardNotifications
|
||||||
|
issues: DashboardIssues
|
||||||
|
team: DashboardTeam
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponse {
|
||||||
|
data: DashboardData
|
||||||
|
}
|
||||||
32
src/types/notification.types.ts
Normal file
32
src/types/notification.types.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
export interface NotificationPayload {
|
||||||
|
headline: string
|
||||||
|
message: string
|
||||||
|
tags: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string
|
||||||
|
recipient_id: number
|
||||||
|
type: string
|
||||||
|
level: string
|
||||||
|
error_severity: string
|
||||||
|
reciever: string
|
||||||
|
is_read: boolean
|
||||||
|
delivery_status: string
|
||||||
|
delivery_channel: string
|
||||||
|
payload: NotificationPayload
|
||||||
|
timestamp: string
|
||||||
|
expires: string
|
||||||
|
image: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetNotificationsResponse {
|
||||||
|
notifications: Notification[]
|
||||||
|
total_count: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCountResponse {
|
||||||
|
unread: number
|
||||||
|
}
|
||||||
|
|
@ -5,3 +5,4 @@ import react from '@vitejs/plugin-react'
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user