diff --git a/src/api/analytics.api.ts b/src/api/analytics.api.ts new file mode 100644 index 0000000..7f31690 --- /dev/null +++ b/src/api/analytics.api.ts @@ -0,0 +1,5 @@ +import http from "./http"; +import type { DashboardResponse } from "../types/analytics.types"; + +export const getDashboard = () => + http.get("/analytics/dashboard"); diff --git a/src/api/notifications.api.ts b/src/api/notifications.api.ts new file mode 100644 index 0000000..9505b30 --- /dev/null +++ b/src/api/notifications.api.ts @@ -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("/notifications", { + params: { limit, offset }, + }); + +export const getUnreadCount = () => + http.get("/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"); diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 61a70a9..730eed3 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -31,6 +31,7 @@ import { AddQuestionPage } from "../pages/content-management/AddQuestionPage" import { UserLogPage } from "../pages/user-log/UserLogPage" import { IssuesPage } from "../pages/issues/IssuesPage" import { ProfilePage } from "../pages/ProfilePage" +import { SettingsPage } from "../pages/SettingsPage" import { TeamManagementPage } from "../pages/team/TeamManagementPage" import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage" import { LoginPage } from "../pages/auth/LoginPage" @@ -86,6 +87,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 2001851..4c1c1fd 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -12,10 +12,11 @@ import { Users2, X, } from "lucide-react" -import type { ComponentType } from "react" +import { type ComponentType, useEffect, useState } from "react" import { NavLink } from "react-router-dom" import { cn } from "../../lib/utils" import { BrandLogo } from "../brand/BrandLogo" +import { getUnreadCount } from "../../api/notifications.api" type NavItem = { label: string @@ -42,6 +43,24 @@ type 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 ( <> {/* Mobile overlay */} @@ -101,7 +120,14 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { {item.label} - {isActive ? ( + {item.to === "/notifications" && unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + )} + {item.to !== "/notifications" && isActive ? ( + + ) : item.to === "/notifications" && unreadCount === 0 && isActive ? ( ) : null} diff --git a/src/components/topbar/NotificationDropdown.tsx b/src/components/topbar/NotificationDropdown.tsx new file mode 100644 index 0000000..71528fd --- /dev/null +++ b/src/components/topbar/NotificationDropdown.tsx @@ -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 = { + 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 ( + + + ) +} + +export function NotificationDropdown() { + const [open, setOpen] = useState(false) + const containerRef = useRef(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 ( +
+ {/* Bell button */} + + + {/* Dropdown panel */} + {open && ( +
+ {/* Header */} +
+
+

+ Notifications +

+ {unreadCount > 0 && ( + + {unreadCount} + + )} +
+ {unreadCount > 0 && ( + + )} +
+ + {/* Body */} +
+ {loading ? ( +
+ +
+ ) : notifications.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( +
+ {notifications.map((n) => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+ )} +
+ ) +} diff --git a/src/components/topbar/Topbar.tsx b/src/components/topbar/Topbar.tsx index 7475f1a..6ceca01 100644 --- a/src/components/topbar/Topbar.tsx +++ b/src/components/topbar/Topbar.tsx @@ -1,17 +1,20 @@ "use client" 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 * as DropdownMenu from "@radix-ui/react-dropdown-menu" import { cn } from "../../lib/utils" +import { NotificationDropdown } from "./NotificationDropdown" type TopbarProps = { onMenuClick: () => void } export function Topbar({ onMenuClick }: TopbarProps) { - const [shortName, setShortName] = useState("AA") + const navigate = useNavigate() + const [shortName, setShortName] = useState("AA") useEffect(() => { const updateShortName = () => { @@ -29,10 +32,10 @@ export function Topbar({ onMenuClick }: TopbarProps) { const handleOptionClick = (option: string) => { switch (option) { case "profile": - console.log("Go to profile") + navigate("/profile") break case "settings": - console.log("Go to settings") + navigate("/settings") break case "logout": localStorage.clear() @@ -55,13 +58,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
{/* Notifications */} - + {/* Separator */}
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..c6ab70d --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -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([]) + const [unreadCount, setUnreadCount] = useState(0) + const [loading, setLoading] = useState(true) + const wsRef = useRef(null) + const reconnectTimer = useRef | 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, + } +} diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 035fb4d..38c74d2 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -22,6 +22,21 @@ export function AppLayout() {
+
+
+ Powered by + + Yaltopia + + · + © {new Date().getFullYear()} +
+
) diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 7a7ffe2..aae2257 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,11 +1,15 @@ -// import type { UserProfileResponse } from "../types/user.types"; import { - Activity, + // Activity, BadgeCheck, - Coins, + BookOpen, + // Coins, DollarSign, - TrendingUp, + HelpCircle, + TicketCheck, + // TrendingUp, Users, + Bell, + UsersRound, } from "lucide-react" import { Area, @@ -22,48 +26,24 @@ import { YAxis, } from "recharts" import { StatCard } from "../components/dashboard/StatCard" -import { Button } from "../components/ui/button" 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 { getDashboard } from "../api/analytics.api" import { useEffect, useState } from "react" +import type { DashboardData } from "../types/analytics.types" -const userGrowth = [ - { 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 PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"] -const subscriptionStatus = [ - { name: "Free Plan", value: 3125, color: "#9E2891" }, - { name: "Monthly Plan", value: 5901, color: "#FFD23F" }, - { 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 +function formatDate(dateStr: string) { + const d = new Date(dateStr) + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} export function DashboardPage() { const [userFirstName, setUserFirstName] = useState("") - const activeRange = "1Y" + const [dashboard, setDashboard] = useState(null) + const [loading, setLoading] = useState(true) useEffect(() => { 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() + 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 (
Dashboard
@@ -91,134 +109,107 @@ export function DashboardPage() { Welcome, {userFirstName || localStorage.getItem("user_first_name")}
-
- - - - - - -
+ {loading ? ( +
Loading dashboard…
+ ) : !dashboard ? ( +
Failed to load dashboard data.
+ ) : ( + <> + {/* Stat Cards */} +
+ 0} + /> + 0} + /> + 0} + /> + 0.5} + /> +
-
- - -
-
- User Growth -
5,730
-
Last 12 Months +15.2%
-
+ {/* Secondary Stats */} +
+ + + + +
-
- {ranges.map((r) => ( - - ))} -
-
-
- - - - - - - - - - - - - - - - - -
- -
- - -
- Subscription Status -
- Weekly + {/* User Registrations Chart */} +
+ + +
+
+ User Registrations +
+ {dashboard.users.total_users.toLocaleString()} +
+
+ +{dashboard.users.new_today} today · +{dashboard.users.new_week} this week +
+
+
+ Last 30 Days +
-
- - -
+ + - - - {subscriptionStatus.map((entry) => ( - - ))} - + + + + + + + + + + - + + -
+
+ -
- {subscriptionStatus.map((s) => ( -
-
- - {s.name} +
+ {/* Subscription / Issue Status Pie */} + + + + {subscriptionStatusData.length > 0 ? "Subscription Status" : "Issue Status"} + + + + {(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).length > 0 ? ( + <> +
+ + + 0 ? subscriptionStatusData : issueStatusData} + dataKey="value" + nameKey="name" + innerRadius={55} + outerRadius={80} + paddingAngle={2} + > + {(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).map( + (entry) => ( + + ), + )} + + + + +
+
+ {(subscriptionStatusData.length > 0 ? subscriptionStatusData : issueStatusData).map((s) => ( +
+
+ + {s.name} +
+ {s.value.toLocaleString()} +
+ ))} +
+ + ) : ( +
+ No data available
- {s.value.toLocaleString()} Users -
- ))} -
- - + )} + + - - -
-
- Revenue Trend -
ETB 923,417
-
Last 6 Months (ETB)
-
- -
-
- - - - - - - [`${Number(v).toLocaleString()}`, "ETB"]} - contentStyle={{ - borderRadius: 12, - border: "1px solid #E0E0E0", - boxShadow: "0 10px 30px rgba(0,0,0,0.08)", - }} - /> - - - - -
-
-
+ {/* Revenue Chart */} + + +
+
+ Revenue Trend +
+ ETB {dashboard.payments.total_revenue.toLocaleString()} +
+
Last 30 Days (ETB)
+
+
+
+ + + + + + + [`${Number(v).toLocaleString()}`, "ETB"]} + contentStyle={{ + borderRadius: 12, + border: "1px solid #E0E0E0", + boxShadow: "0 10px 30px rgba(0,0,0,0.08)", + }} + /> + + + + +
+
+ + {/* Users by Role / Region / Knowledge Level */} +
+ {[ + { 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 }) => ( + + + {title} + + + {data.length > 0 ? ( +
+ {data.map((item, i) => ( +
+
+ + {item.label} +
+ {item.count.toLocaleString()} +
+ ))} +
+ ) : ( +
+ No data available +
+ )} +
+
+ ))} +
+
+ + )}
) } - - diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..c90ad59 --- /dev/null +++ b/src/pages/SettingsPage.tsx @@ -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 ( + + ); +} + +function SettingRow({ + icon: Icon, + title, + description, + children, +}: { + icon: typeof User; + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+
+ +
+
+

{title}

+

{description}

+
+
+
{children}
+
+ ); +} + +function LoadingSkeleton() { + return ( +
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+
+ {[1, 2, 3, 4].map((j) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+
+ ); +} + +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 ( +
+ +
+ +
+
+ +
+ + Personal Information + +
+
+ +
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
+
+
+ + setNickName(e.target.value)} /> +
+
+ + + +
+ +
+
+ +
+ + Preferences + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ ); +} + +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 ( +
+ +
+ +
+
+ +
+ + Change Password + +
+
+ +
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
+ + + +
+ +
+
+ +
+ + Two-Factor Authentication + +
+
+ + + toast.info("2FA coming soon")} /> + + + +
+ ); +} + +function NotificationsTab() { + const [emailNotifs, setEmailNotifs] = useState(true); + const [pushNotifs, setPushNotifs] = useState(true); + const [loginAlerts, setLoginAlerts] = useState(true); + const [weeklyDigest, setWeeklyDigest] = useState(false); + + return ( + +
+ +
+
+ +
+ + Notification Preferences + +
+
+ + + setEmailNotifs(!emailNotifs)} /> + + + + setPushNotifs(!pushNotifs)} /> + + + + setLoginAlerts(!loginAlerts)} /> + + + + setWeeklyDigest(!weeklyDigest)} /> + + + + ); +} + +function AppearanceTab() { + const [theme, setTheme] = useState<"light" | "dark" | "system">("light"); + + return ( + +
+ +
+
+ +
+ + Theme + +
+
+ +
+ {( + [ + { 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 }) => ( + + ))} +
+
+ + ); +} + +export function SettingsPage() { + const [activeTab, setActiveTab] = useState("profile"); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ; + + if (error || !profile) { + return ( +
+ + +
+ +
+
+

+ {error || "Settings not available"} +

+

+ Please check your connection and try again. +

+
+
+
+
+ ); + } + + return ( +
+ {/* Page header */} +
+

Settings

+

+ Manage your account preferences and configuration +

+
+ + {/* Tab navigation */} +
+ {tabs.map(({ id, label, icon: Icon }) => ( + + ))} +
+ + {/* Tab content */} + {activeTab === "profile" && } + {activeTab === "security" && } + {activeTab === "notifications" && } + {activeTab === "appearance" && } +
+ ); +} diff --git a/src/pages/analytics/AnalyticsPage.tsx b/src/pages/analytics/AnalyticsPage.tsx index b4bb565..882fd0c 100644 --- a/src/pages/analytics/AnalyticsPage.tsx +++ b/src/pages/analytics/AnalyticsPage.tsx @@ -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 { 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 ( -
-
Analytics
- - - Analytics - - Analytics module placeholder. - + + +
+
+
{label}
+
{value}
+
+
+ +
+
+ {sub && ( +
+ {trend === "up" && } + {trend === "down" && } + {sub} +
+ )} +
+
+ ) +} + +function BreakdownList({ + title, + data, + total, +}: { + title: string + data: LabelCount[] + total?: number +}) { + const computedTotal = total ?? data.reduce((s, d) => s + d.count, 0) + return ( + + + {title} + + + {data.length > 0 ? ( +
+ {data.map((item, i) => { + const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0 + return ( +
+
+
+ + {item.label} +
+ + {item.count.toLocaleString()} + ({pct.toFixed(0)}%) + +
+
+
+
+
+ ) + })} +
+ ) : ( +
No data available
+ )} + + + ) +} + +function DonutCard({ + title, + data, + centerLabel, + centerValue, +}: { + title: string + data: { name: string; value: number; color: string }[] + centerLabel?: string + centerValue?: string +}) { + return ( + + + {title} + + + {data.length > 0 ? ( +
+
+ + + + {data.map((entry) => ( + + ))} + + + + + {centerLabel && ( +
+ {centerValue} + {centerLabel} +
+ )} +
+
+ {data.map((s) => ( +
+
+ + {s.name} +
+ {s.value.toLocaleString()} +
+ ))} +
+
+ ) : ( +
No data available
+ )} +
+
+ ) +} + +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 ( +
+ +
+
+
{children}
+
+
) } +export function AnalyticsPage() { + const [dashboard, setDashboard] = useState(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 ( +
+
Analytics
+
Loading analytics…
+
+ ) + } + + if (error || !dashboard) { + return ( +
+
Analytics
+
+ Failed to load analytics data. + +
+
+ ) + } + + 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 ( +
+ {/* Header */} +
+
+
Analytics
+

Platform Overview

+
+
+ Generated {generatedAt} + +
+
+ +
+ {/* ─── Key Metrics ─── */} +
+
+ 0 ? "up" : "neutral"} + /> + 0 ? "up" : "neutral"} + /> + 0 ? "up" : "neutral"} + /> + = 0.5 ? "up" : "down"} + /> +
+
+ + {/* ─── Content & Platform ─── */} +
+
+ + + + +
+
+ + {/* ─── Operations ─── */} +
+
+ + + 0 ? "up" : "neutral"} + /> + `${q.count} ${q.label.toLowerCase()}`).join(" · ")} + trend="neutral" + /> +
+
+ + {/* ─── User Analytics ─── */} +
+ + +
+
+ User Registrations +
+ + {users.total_users.toLocaleString()} + + + +{users.new_today} today + +
+
+ Last 30 Days +
+
+ + + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + {/* ─── Subscriptions & Revenue ─── */} +
+
+ + +
+
+ New Subscriptions +
+ {subscriptions.total_subscriptions.toLocaleString()} +
+
+ +{subscriptions.new_today} today · +{subscriptions.new_week} this week +
+
+ Last 30 Days +
+
+ + + + + + + + + + + + + + + + + +
+ + + +
+
+ Revenue +
+ ETB {payments.total_revenue.toLocaleString()} +
+
Daily revenue over last 30 days
+
+ Last 30 Days +
+
+ + + + + + + [`ETB ${Number(v).toLocaleString()}`, "Revenue"]} + contentStyle={{ + borderRadius: 12, + border: "1px solid #E0E0E0", + boxShadow: "0 10px 30px rgba(0,0,0,0.08)", + fontSize: 12, + }} + /> + + + + +
+
+
+ + + +
+
+ + {/* ─── Issues & Support ─── */} +
+
+ + + +
+
+ + {/* ─── Notifications ─── */} +
+
+ + + +
+
+ + {/* ─── Content Breakdown ─── */} +
+
+ + +
+
+ + {/* ─── Team ─── */} +
+
+ + +
+
+
+
+ ) +} diff --git a/src/pages/notifications/NotificationsPage.tsx b/src/pages/notifications/NotificationsPage.tsx index db2c1df..1dad00f 100644 --- a/src/pages/notifications/NotificationsPage.tsx +++ b/src/pages/notifications/NotificationsPage.tsx @@ -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 = { + 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 ( -
-
Notifications
- - - Notifications - - Notifications module placeholder. - +
+ {/* Unread dot */} + {!notification.is_read && ( + + )} + + {/* Icon */} +
+ +
+ + {/* Content */} +
+
+
+
+ + {notification.payload.headline} + + + {notification.level} + +
+

+ {notification.payload.message} +

+
+ +
+ + {formatTimestamp(notification.timestamp)} + + +
+
+ + {/* Meta row */} +
+ + {formatTypeLabel(notification.type)} + + + {notification.delivery_channel} + + {notification.delivery_status !== "delivered" && notification.delivery_status !== "pending" && ( + + {notification.delivery_status} + + )} + {notification.payload.tags && notification.payload.tags.length > 0 && ( + notification.payload.tags.map((tag) => ( + + {tag} + + )) + )} +
+
) } +export function NotificationsPage() { + const [notifications, setNotifications] = useState([]) + 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>(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 ( +
+ {/* Header */} +
+
Notifications
+
+
+

Notifications

+ {totalCount > 0 && ( + {totalCount} + )} + {globalUnread > 0 && ( + {globalUnread} unread + )} +
+ + {/* Bulk actions */} + {!loading && !error && notifications.length > 0 && ( +
+ {globalUnread > 0 ? ( + + ) : ( + + )} +
+ )} +
+
+ + {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Error */} + {!loading && error && ( + + + + Failed to load notifications. + + + + )} + + {/* Empty */} + {!loading && !error && notifications.length === 0 && ( + + +
+ +
+ No notifications yet + When you receive notifications, they'll appear here. +
+
+ )} + + {/* Notification list */} + {!loading && !error && notifications.length > 0 && ( + <> + + +
+ {notifications.map((n) => ( + + ))} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Showing {offset + 1}–{Math.min(offset + PAGE_SIZE, totalCount)} of {totalCount} + +
+ + + {currentPage} / {totalPages} + + +
+
+ )} + + )} +
+ ) +} diff --git a/src/types/analytics.types.ts b/src/types/analytics.types.ts new file mode 100644 index 0000000..17c6cda --- /dev/null +++ b/src/types/analytics.types.ts @@ -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 +} diff --git a/src/types/notification.types.ts b/src/types/notification.types.ts new file mode 100644 index 0000000..c0dffa0 --- /dev/null +++ b/src/types/notification.types.ts @@ -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 +} diff --git a/vite.config.ts b/vite.config.ts index 8b0f57b..8463f7b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,3 +5,4 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], }) +