UAT fixes stage 1
This commit is contained in:
parent
2b556d9d09
commit
385f58fd22
|
|
@ -1,5 +1,39 @@
|
||||||
import http from "./http";
|
import http from "./http";
|
||||||
import type { DashboardResponse } from "../types/analytics.types";
|
import type { DashboardData, DashboardFilters, DashboardResponse } from "../types/analytics.types";
|
||||||
|
|
||||||
export const getDashboard = () =>
|
function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
|
||||||
http.get<DashboardResponse>("/analytics/dashboard");
|
if (!filters || filters.mode === "all_time") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.mode === "year" && filters.year != null) {
|
||||||
|
return { year: filters.year };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
|
||||||
|
return { year: filters.year, month: filters.month };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.mode === "custom" && filters.from && filters.to) {
|
||||||
|
return { from: filters.from, to: filters.to };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapDashboardResponse(body: DashboardResponse | DashboardData): DashboardData {
|
||||||
|
if (body && typeof body === "object" && "data" in body && body.data) {
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
return body as DashboardData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDashboard = (filters?: DashboardFilters) =>
|
||||||
|
http
|
||||||
|
.get<DashboardResponse | DashboardData>("/analytics/dashboard", {
|
||||||
|
params: buildDashboardQueryParams(filters),
|
||||||
|
})
|
||||||
|
.then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: unwrapDashboardResponse(res.data),
|
||||||
|
}));
|
||||||
|
|
|
||||||
11
src/api/subscription-plans.api.ts
Normal file
11
src/api/subscription-plans.api.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import http from "./http"
|
||||||
|
import type { SubscriptionPlansListResponse, SubscriptionPlan } from "../types/subscription.types"
|
||||||
|
|
||||||
|
export const getSubscriptionPlans = () =>
|
||||||
|
http.get<SubscriptionPlansListResponse>("/subscription-plans").then((res) => ({
|
||||||
|
...res,
|
||||||
|
data: {
|
||||||
|
...res.data,
|
||||||
|
data: Array.isArray(res.data?.data) ? res.data.data : ([] as SubscriptionPlan[]),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
@ -6,23 +6,46 @@ import {
|
||||||
type UserSummaryResponse,
|
type UserSummaryResponse,
|
||||||
type GetDeletionRequestsParams,
|
type GetDeletionRequestsParams,
|
||||||
type GetDeletionRequestsResponse,
|
type GetDeletionRequestsResponse,
|
||||||
|
type UserRecentActivityResponse,
|
||||||
} from "../types/user.types";
|
} from "../types/user.types";
|
||||||
|
|
||||||
export const getUsers = (
|
/** Query params for GET /users (RFC3339 for created_*; subscription_status: ACTIVE | PENDING | Unsubscribed). */
|
||||||
page?: number,
|
export interface GetUsersParams {
|
||||||
pageSize?: number,
|
page?: number
|
||||||
role?: string,
|
page_size?: number
|
||||||
status?: string,
|
role?: string
|
||||||
query?: string,
|
status?: string
|
||||||
) =>
|
query?: string
|
||||||
|
created_before?: string
|
||||||
|
created_after?: string
|
||||||
|
country?: string
|
||||||
|
region?: string
|
||||||
|
subscription_status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGetUsersQuery(params: GetUsersParams): Record<string, string | number> {
|
||||||
|
const q: Record<string, string | number> = {}
|
||||||
|
const addString = (key: string, value: string | undefined) => {
|
||||||
|
const v = value?.trim()
|
||||||
|
if (!v) return
|
||||||
|
q[key] = v
|
||||||
|
}
|
||||||
|
if (params.page !== undefined) q.page = params.page
|
||||||
|
if (params.page_size !== undefined) q.page_size = params.page_size
|
||||||
|
addString("role", params.role)
|
||||||
|
addString("status", params.status)
|
||||||
|
addString("query", params.query)
|
||||||
|
addString("created_before", params.created_before)
|
||||||
|
addString("created_after", params.created_after)
|
||||||
|
addString("country", params.country)
|
||||||
|
addString("region", params.region)
|
||||||
|
addString("subscription_status", params.subscription_status)
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUsers = (params: GetUsersParams = {}) =>
|
||||||
http.get<GetUsersResponse>("/users", {
|
http.get<GetUsersResponse>("/users", {
|
||||||
params: {
|
params: buildGetUsersQuery(params),
|
||||||
role,
|
|
||||||
status,
|
|
||||||
query,
|
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
|
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
|
||||||
|
|
@ -38,6 +61,9 @@ export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
|
||||||
export const getUserById = (id: number) =>
|
export const getUserById = (id: number) =>
|
||||||
http.get<UserProfileResponse>(`/user/single/${id}`);
|
http.get<UserProfileResponse>(`/user/single/${id}`);
|
||||||
|
|
||||||
|
export const getUserRecentActivity = (userId: number) =>
|
||||||
|
http.get<UserRecentActivityResponse>(`/admin/users/${userId}/recent-activity`);
|
||||||
|
|
||||||
export const getMyProfile = () =>
|
export const getMyProfile = () =>
|
||||||
http.get<UserProfileResponse>("/team/me");
|
http.get<UserProfileResponse>("/team/me");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ import { CreateNotificationPage } from "../pages/notifications/CreateNotificatio
|
||||||
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
|
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
|
||||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
||||||
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
||||||
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
|
|
||||||
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
|
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
|
||||||
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
|
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
|
||||||
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
|
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
|
||||||
|
|
@ -78,7 +77,7 @@ export function AppRoutes() {
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/users" element={<UserManagementLayout />}>
|
<Route path="/users" element={<UserManagementLayout />}>
|
||||||
<Route index element={<UserManagementDashboard />} />
|
<Route index element={<Navigate to="list" replace />} />
|
||||||
<Route path="list" element={<UsersListPage />} />
|
<Route path="list" element={<UsersListPage />} />
|
||||||
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
|
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
|
||||||
<Route path="groups" element={<UserGroupsPage />} />
|
<Route path="groups" element={<UserGroupsPage />} />
|
||||||
|
|
|
||||||
272
src/components/analytics/AnalyticsTimeRangeFilter.tsx
Normal file
272
src/components/analytics/AnalyticsTimeRangeFilter.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import type { DashboardFilters } from "../../types/analytics.types"
|
||||||
|
|
||||||
|
const MONTH_LABELS = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const MIN_SELECTABLE_YEAR = 2000
|
||||||
|
|
||||||
|
export function getYearOptions(): number[] {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const years: number[] = []
|
||||||
|
for (let year = currentYear; year >= MIN_SELECTABLE_YEAR; year--) {
|
||||||
|
years.push(year)
|
||||||
|
}
|
||||||
|
return years
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboardFilterLabel(filters: DashboardFilters): string {
|
||||||
|
if (filters.mode === "year" && filters.year != null) {
|
||||||
|
return String(filters.year)
|
||||||
|
}
|
||||||
|
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
|
||||||
|
return `${MONTH_LABELS[filters.month - 1]} ${filters.year}`
|
||||||
|
}
|
||||||
|
if (filters.mode === "custom" && filters.from && filters.to) {
|
||||||
|
const from = new Date(`${filters.from}T00:00:00`)
|
||||||
|
const to = new Date(`${filters.to}T00:00:00`)
|
||||||
|
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" }
|
||||||
|
return `${from.toLocaleDateString("en-US", opts)} – ${to.toLocaleDateString("en-US", opts)}`
|
||||||
|
}
|
||||||
|
return "All Time"
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalyticsTimeRangeFilterProps = {
|
||||||
|
value: DashboardFilters
|
||||||
|
onChange: (filters: DashboardFilters) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsTimeRangeFilter({ value, onChange, className }: AnalyticsTimeRangeFilterProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [yearOpen, setYearOpen] = useState(true)
|
||||||
|
const [monthOpen, setMonthOpen] = useState(false)
|
||||||
|
const [customOpen, setCustomOpen] = useState(false)
|
||||||
|
const [contextYear, setContextYear] = useState(() => value.year ?? new Date().getFullYear())
|
||||||
|
const [customFrom, setCustomFrom] = useState(value.from ?? "")
|
||||||
|
const [customTo, setCustomTo] = useState(value.to ?? "")
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const years = getYearOptions()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value.year != null) {
|
||||||
|
setContextYear(value.year)
|
||||||
|
}
|
||||||
|
}, [value.year])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value.mode === "custom") {
|
||||||
|
setCustomFrom(value.from ?? "")
|
||||||
|
setCustomTo(value.to ?? "")
|
||||||
|
}
|
||||||
|
}, [value.from, value.mode, value.to])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
|
if (!containerRef.current?.contains(event.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handlePointerDown)
|
||||||
|
return () => document.removeEventListener("mousedown", handlePointerDown)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const selectAllTime = () => {
|
||||||
|
onChange({ mode: "all_time" })
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectYear = (year: number) => {
|
||||||
|
setContextYear(year)
|
||||||
|
onChange({ mode: "year", year })
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectMonth = (month: number) => {
|
||||||
|
onChange({ mode: "year_month", year: contextYear, month })
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyCustomRange = () => {
|
||||||
|
if (!customFrom || !customTo) return
|
||||||
|
onChange({ mode: "custom", from: customFrom, to: customTo })
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn("relative", className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-grayScale-200 bg-white px-4 py-2 text-sm font-medium text-grayScale-700 shadow-sm transition-colors hover:bg-grayScale-50"
|
||||||
|
>
|
||||||
|
Time Range
|
||||||
|
<ChevronDown className={cn("h-4 w-4 text-grayScale-400 transition-transform", open && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 z-50 mt-2 w-[220px] overflow-hidden rounded-xl border border-grayScale-100 bg-white py-2 shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectAllTime}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full px-4 py-2.5 text-left text-sm transition-colors hover:bg-grayScale-50",
|
||||||
|
value.mode === "all_time" ? "font-semibold text-grayScale-900" : "text-grayScale-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All Time
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="border-t border-grayScale-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setYearOpen((prev) => !prev)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
||||||
|
>
|
||||||
|
Year
|
||||||
|
<ChevronDown
|
||||||
|
className={cn("h-4 w-4 text-grayScale-400 transition-transform", yearOpen && "rotate-180")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{yearOpen && (
|
||||||
|
<div className="max-h-[220px] overflow-y-auto pb-1">
|
||||||
|
{years.map((year) => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectYear(year)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
|
||||||
|
value.mode === "year" && value.year === year
|
||||||
|
? "font-semibold text-brand-600"
|
||||||
|
: "text-grayScale-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-grayScale-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMonthOpen((prev) => !prev)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
||||||
|
>
|
||||||
|
Month
|
||||||
|
<ChevronDown
|
||||||
|
className={cn("h-4 w-4 text-grayScale-400 transition-transform", monthOpen && "rotate-180")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{monthOpen && (
|
||||||
|
<div className="max-h-[260px] overflow-y-auto pb-1">
|
||||||
|
<div className="flex max-h-[88px] flex-wrap gap-1 overflow-y-auto px-4 pb-2">
|
||||||
|
{years.map((year) => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContextYear(year)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors",
|
||||||
|
contextYear === year
|
||||||
|
? "bg-brand-100 text-brand-700"
|
||||||
|
: "text-grayScale-500 hover:bg-grayScale-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{MONTH_LABELS.map((label, index) => {
|
||||||
|
const month = index + 1
|
||||||
|
const isSelected =
|
||||||
|
value.mode === "year_month" && value.year === contextYear && value.month === month
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectMonth(month)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
|
||||||
|
isSelected ? "font-semibold text-brand-600" : "text-grayScale-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-grayScale-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomOpen((prev) => !prev)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
|
||||||
|
>
|
||||||
|
Date Range
|
||||||
|
<ChevronDown
|
||||||
|
className={cn("h-4 w-4 text-grayScale-400 transition-transform", customOpen && "rotate-180")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{customOpen && (
|
||||||
|
<div className="space-y-2 px-4 pb-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] font-medium text-grayScale-500">From</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={customFrom}
|
||||||
|
onChange={(e) => setCustomFrom(e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] font-medium text-grayScale-500">To</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={customTo}
|
||||||
|
onChange={(e) => setCustomTo(e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-full text-xs"
|
||||||
|
disabled={!customFrom || !customTo}
|
||||||
|
onClick={applyCustomRange}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/components/dashboard/RevenueTrendCard.tsx
Normal file
130
src/components/dashboard/RevenueTrendCard.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Bar, CartesianGrid, Cell, ComposedChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||||
|
import { getDashboard } from "../../api/analytics.api"
|
||||||
|
import { getYearOptions } from "../analytics/AnalyticsTimeRangeFilter"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
||||||
|
import { Select } from "../ui/select"
|
||||||
|
import { aggregateRevenueByMonth, formatRevenueAxisTick } from "../../lib/analytics"
|
||||||
|
import type { DateRevenue } from "../../types/analytics.types"
|
||||||
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||||
|
|
||||||
|
const TRACK_COLOR = "#E8E8E8"
|
||||||
|
const BAR_COLOR = "#9E2891"
|
||||||
|
|
||||||
|
export function RevenueTrendCard() {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const [year, setYear] = useState(currentYear)
|
||||||
|
const [totalRevenue, setTotalRevenue] = useState(0)
|
||||||
|
const [dailyRevenue, setDailyRevenue] = useState<DateRevenue[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const years = useMemo(() => getYearOptions(), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const fetchRevenueTrend = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getDashboard({ mode: "year", year })
|
||||||
|
if (cancelled) return
|
||||||
|
setTotalRevenue(res.data.payments.total_revenue)
|
||||||
|
setDailyRevenue(res.data.payments.revenue_last_30_days)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTotalRevenue(0)
|
||||||
|
setDailyRevenue([])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRevenueTrend()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [year])
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const monthly = aggregateRevenueByMonth(dailyRevenue, year)
|
||||||
|
const peak = Math.max(...monthly.map((point) => point.revenue), 1)
|
||||||
|
const trackMax = peak * 1.15
|
||||||
|
|
||||||
|
return monthly.map((point) => ({
|
||||||
|
month: point.month,
|
||||||
|
revenue: point.revenue,
|
||||||
|
track: trackMax,
|
||||||
|
}))
|
||||||
|
}, [dailyRevenue, year])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Revenue Trend</CardTitle>
|
||||||
|
<div className="mt-2 text-2xl font-semibold tracking-tight">
|
||||||
|
ETB {totalRevenue.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-grayScale-500">Monthly · {year} (ETB)</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={String(year)}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
className="h-9 w-[96px] shrink-0 rounded-lg py-1 text-sm font-medium"
|
||||||
|
aria-label="Revenue trend year"
|
||||||
|
>
|
||||||
|
{years.map((optionYear) => (
|
||||||
|
<option key={optionYear} value={optionYear}>
|
||||||
|
{optionYear}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[240px] p-6 pt-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={chartData} margin={{ left: 4, right: 8, top: 8, bottom: 0 }} barGap={-28}>
|
||||||
|
<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={44}
|
||||||
|
tickFormatter={formatRevenueAxisTick}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name) => {
|
||||||
|
if (name !== "revenue") return null
|
||||||
|
return [`ETB ${Number(value).toLocaleString()}`, "Revenue"]
|
||||||
|
}}
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E0E0E0",
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="track" barSize={28} radius={[8, 8, 0, 0]} isAnimationActive={false}>
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<Cell key={`track-${entry.month}`} fill={TRACK_COLOR} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<Bar dataKey="revenue" barSize={28} radius={[8, 8, 0, 0]}>
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<Cell key={`revenue-${entry.month}`} fill={BAR_COLOR} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
228
src/data/userFilterLocations.ts
Normal file
228
src/data/userFilterLocations.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
/**
|
||||||
|
* Static options for GET /users filters (`country`, `region`).
|
||||||
|
* Country: common English short names (ISO-style), sorted A–Z.
|
||||||
|
* Region: Ethiopia — federal regions & chartered cities (typical `users.region` values).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COUNTRY_NAMES_RAW = [
|
||||||
|
"Afghanistan",
|
||||||
|
"Albania",
|
||||||
|
"Algeria",
|
||||||
|
"Andorra",
|
||||||
|
"Angola",
|
||||||
|
"Antigua and Barbuda",
|
||||||
|
"Argentina",
|
||||||
|
"Armenia",
|
||||||
|
"Australia",
|
||||||
|
"Austria",
|
||||||
|
"Azerbaijan",
|
||||||
|
"Bahamas",
|
||||||
|
"Bahrain",
|
||||||
|
"Bangladesh",
|
||||||
|
"Barbados",
|
||||||
|
"Belarus",
|
||||||
|
"Belgium",
|
||||||
|
"Belize",
|
||||||
|
"Benin",
|
||||||
|
"Bhutan",
|
||||||
|
"Bolivia",
|
||||||
|
"Bosnia and Herzegovina",
|
||||||
|
"Botswana",
|
||||||
|
"Brazil",
|
||||||
|
"Brunei",
|
||||||
|
"Bulgaria",
|
||||||
|
"Burkina Faso",
|
||||||
|
"Burundi",
|
||||||
|
"Cabo Verde",
|
||||||
|
"Cambodia",
|
||||||
|
"Cameroon",
|
||||||
|
"Canada",
|
||||||
|
"Central African Republic",
|
||||||
|
"Chad",
|
||||||
|
"Chile",
|
||||||
|
"China",
|
||||||
|
"Colombia",
|
||||||
|
"Comoros",
|
||||||
|
"Congo",
|
||||||
|
"Costa Rica",
|
||||||
|
"Croatia",
|
||||||
|
"Cuba",
|
||||||
|
"Cyprus",
|
||||||
|
"Czechia",
|
||||||
|
"Democratic Republic of the Congo",
|
||||||
|
"Denmark",
|
||||||
|
"Djibouti",
|
||||||
|
"Dominica",
|
||||||
|
"Dominican Republic",
|
||||||
|
"Ecuador",
|
||||||
|
"Egypt",
|
||||||
|
"El Salvador",
|
||||||
|
"Equatorial Guinea",
|
||||||
|
"Eritrea",
|
||||||
|
"Estonia",
|
||||||
|
"Eswatini",
|
||||||
|
"Ethiopia",
|
||||||
|
"Fiji",
|
||||||
|
"Finland",
|
||||||
|
"France",
|
||||||
|
"Gabon",
|
||||||
|
"Gambia",
|
||||||
|
"Georgia",
|
||||||
|
"Germany",
|
||||||
|
"Ghana",
|
||||||
|
"Greece",
|
||||||
|
"Grenada",
|
||||||
|
"Guatemala",
|
||||||
|
"Guinea",
|
||||||
|
"Guinea-Bissau",
|
||||||
|
"Guyana",
|
||||||
|
"Haiti",
|
||||||
|
"Honduras",
|
||||||
|
"Hungary",
|
||||||
|
"Iceland",
|
||||||
|
"India",
|
||||||
|
"Indonesia",
|
||||||
|
"Iran",
|
||||||
|
"Iraq",
|
||||||
|
"Ireland",
|
||||||
|
"Israel",
|
||||||
|
"Italy",
|
||||||
|
"Jamaica",
|
||||||
|
"Japan",
|
||||||
|
"Jordan",
|
||||||
|
"Kazakhstan",
|
||||||
|
"Kenya",
|
||||||
|
"Kiribati",
|
||||||
|
"Kuwait",
|
||||||
|
"Kyrgyzstan",
|
||||||
|
"Laos",
|
||||||
|
"Latvia",
|
||||||
|
"Lebanon",
|
||||||
|
"Lesotho",
|
||||||
|
"Liberia",
|
||||||
|
"Libya",
|
||||||
|
"Liechtenstein",
|
||||||
|
"Lithuania",
|
||||||
|
"Luxembourg",
|
||||||
|
"Madagascar",
|
||||||
|
"Malawi",
|
||||||
|
"Malaysia",
|
||||||
|
"Maldives",
|
||||||
|
"Mali",
|
||||||
|
"Malta",
|
||||||
|
"Marshall Islands",
|
||||||
|
"Mauritania",
|
||||||
|
"Mauritius",
|
||||||
|
"Mexico",
|
||||||
|
"Micronesia",
|
||||||
|
"Moldova",
|
||||||
|
"Monaco",
|
||||||
|
"Mongolia",
|
||||||
|
"Montenegro",
|
||||||
|
"Morocco",
|
||||||
|
"Mozambique",
|
||||||
|
"Myanmar",
|
||||||
|
"Namibia",
|
||||||
|
"Nauru",
|
||||||
|
"Nepal",
|
||||||
|
"Netherlands",
|
||||||
|
"New Zealand",
|
||||||
|
"Nicaragua",
|
||||||
|
"Niger",
|
||||||
|
"Nigeria",
|
||||||
|
"North Korea",
|
||||||
|
"North Macedonia",
|
||||||
|
"Norway",
|
||||||
|
"Oman",
|
||||||
|
"Pakistan",
|
||||||
|
"Palau",
|
||||||
|
"Panama",
|
||||||
|
"Papua New Guinea",
|
||||||
|
"Paraguay",
|
||||||
|
"Peru",
|
||||||
|
"Philippines",
|
||||||
|
"Poland",
|
||||||
|
"Portugal",
|
||||||
|
"Qatar",
|
||||||
|
"Romania",
|
||||||
|
"Russia",
|
||||||
|
"Rwanda",
|
||||||
|
"Saint Kitts and Nevis",
|
||||||
|
"Saint Lucia",
|
||||||
|
"Saint Vincent and the Grenadines",
|
||||||
|
"Samoa",
|
||||||
|
"San Marino",
|
||||||
|
"Sao Tome and Principe",
|
||||||
|
"Saudi Arabia",
|
||||||
|
"Senegal",
|
||||||
|
"Serbia",
|
||||||
|
"Seychelles",
|
||||||
|
"Sierra Leone",
|
||||||
|
"Singapore",
|
||||||
|
"Slovakia",
|
||||||
|
"Slovenia",
|
||||||
|
"Solomon Islands",
|
||||||
|
"Somalia",
|
||||||
|
"South Africa",
|
||||||
|
"South Korea",
|
||||||
|
"South Sudan",
|
||||||
|
"Spain",
|
||||||
|
"Sri Lanka",
|
||||||
|
"Sudan",
|
||||||
|
"Suriname",
|
||||||
|
"Sweden",
|
||||||
|
"Switzerland",
|
||||||
|
"Syria",
|
||||||
|
"Tajikistan",
|
||||||
|
"Tanzania",
|
||||||
|
"Thailand",
|
||||||
|
"Timor-Leste",
|
||||||
|
"Togo",
|
||||||
|
"Tonga",
|
||||||
|
"Trinidad and Tobago",
|
||||||
|
"Tunisia",
|
||||||
|
"Turkey",
|
||||||
|
"Turkmenistan",
|
||||||
|
"Tuvalu",
|
||||||
|
"Uganda",
|
||||||
|
"Ukraine",
|
||||||
|
"United Arab Emirates",
|
||||||
|
"United Kingdom",
|
||||||
|
"United States",
|
||||||
|
"Uruguay",
|
||||||
|
"Uzbekistan",
|
||||||
|
"Vanuatu",
|
||||||
|
"Vatican City",
|
||||||
|
"Venezuela",
|
||||||
|
"Vietnam",
|
||||||
|
"Yemen",
|
||||||
|
"Zambia",
|
||||||
|
"Zimbabwe",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** English short names, A–Z (for `<select>` options). */
|
||||||
|
export const USER_FILTER_COUNTRIES: readonly string[] = [...COUNTRY_NAMES_RAW].sort((a, b) =>
|
||||||
|
a.localeCompare(b, "en"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ethiopia — regions & chartered cities (canonical spelling for filters).
|
||||||
|
* Backend matches case-insensitively; use these labels so UI aligns with stored data.
|
||||||
|
*/
|
||||||
|
export const USER_FILTER_ETHIOPIA_REGIONS = [
|
||||||
|
"Addis Ababa",
|
||||||
|
"Afar",
|
||||||
|
"Amhara",
|
||||||
|
"Benishangul-Gumuz",
|
||||||
|
"Dire Dawa",
|
||||||
|
"Gambela Peoples' Region",
|
||||||
|
"Harari",
|
||||||
|
"Oromia",
|
||||||
|
"Sidama",
|
||||||
|
"Somali",
|
||||||
|
"Southern Nations, Nationalities, and Peoples' Region",
|
||||||
|
"South West Ethiopia Peoples' Region",
|
||||||
|
"Tigray",
|
||||||
|
] as const satisfies readonly string[]
|
||||||
|
|
||||||
|
export type UserFilterEthiopiaRegion = (typeof USER_FILTER_ETHIOPIA_REGIONS)[number]
|
||||||
86
src/lib/analytics.ts
Normal file
86
src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import type { DashboardDateFilter, DateRevenue, LabelCount } from "../types/analytics.types"
|
||||||
|
|
||||||
|
const MONTH_SHORT = [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function formatShortDate(iso: string) {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBreakdownLabel(label: string) {
|
||||||
|
return label.replace(/_/g, " ").toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrimaryQuestionTypeSummary(questionsByType: LabelCount[]): string {
|
||||||
|
if (questionsByType.length === 0) return "No question types"
|
||||||
|
const top = [...questionsByType].sort((a, b) => b.count - a.count)[0]
|
||||||
|
return `${top.count.toLocaleString()} ${formatBreakdownLabel(top.label)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoLessonsSummary(lmsLessonsWithVideo = 0, examPrepLessonsWithVideo = 0): string {
|
||||||
|
return `${lmsLessonsWithVideo.toLocaleString()} LMS · ${examPrepLessonsWithVideo.toLocaleString()} exam prep lessons`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyRevenuePoint {
|
||||||
|
month: string
|
||||||
|
monthIndex: number
|
||||||
|
revenue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateRevenueByMonth(daily: DateRevenue[], year: number): MonthlyRevenuePoint[] {
|
||||||
|
const monthly = Array.from({ length: 12 }, (_, monthIndex) => ({
|
||||||
|
month: MONTH_SHORT[monthIndex],
|
||||||
|
monthIndex,
|
||||||
|
revenue: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
for (const { date, revenue } of daily) {
|
||||||
|
const parsed = new Date(date)
|
||||||
|
if (parsed.getUTCFullYear() !== year) continue
|
||||||
|
monthly[parsed.getUTCMonth()].revenue += revenue
|
||||||
|
}
|
||||||
|
|
||||||
|
return monthly
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRevenueAxisTick(value: number): string {
|
||||||
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(0)}M`
|
||||||
|
if (value >= 1_000) return `${Math.round(value / 1_000)}K`
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeriesPeriodLabel(dateFilter?: DashboardDateFilter): string {
|
||||||
|
if (!dateFilter) return "Last 30 Days"
|
||||||
|
|
||||||
|
switch (dateFilter.mode) {
|
||||||
|
case "all_time":
|
||||||
|
return "Last 30 Days"
|
||||||
|
case "year":
|
||||||
|
return dateFilter.year != null ? String(dateFilter.year) : "Selected year"
|
||||||
|
case "year_month":
|
||||||
|
if (dateFilter.year != null && dateFilter.month != null) {
|
||||||
|
return `${MONTH_SHORT[dateFilter.month - 1]} ${dateFilter.year}`
|
||||||
|
}
|
||||||
|
return "Selected month"
|
||||||
|
case "custom":
|
||||||
|
if (dateFilter.from && dateFilter.to) {
|
||||||
|
return `${formatShortDate(dateFilter.from)} – ${formatShortDate(dateFilter.to)}`
|
||||||
|
}
|
||||||
|
return "Custom range"
|
||||||
|
default:
|
||||||
|
return "Selected period"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
// Activity,
|
// Activity,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
BookOpen,
|
Video,
|
||||||
// Coins,
|
// Coins,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
|
@ -11,14 +11,13 @@ import {
|
||||||
// TrendingUp,
|
// TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
Bell,
|
Bell,
|
||||||
|
CreditCard,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
|
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Bar,
|
|
||||||
BarChart,
|
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
Cell,
|
||||||
Pie,
|
Pie,
|
||||||
|
|
@ -28,15 +27,21 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts"
|
} from "recharts"
|
||||||
|
import { RevenueTrendCard } from "../components/dashboard/RevenueTrendCard"
|
||||||
import { StatCard } from "../components/dashboard/StatCard"
|
import { StatCard } from "../components/dashboard/StatCard"
|
||||||
import alertSrc from "../assets/Alert.svg"
|
import alertSrc from "../assets/Alert.svg"
|
||||||
|
import { Badge } from "../components/ui/badge"
|
||||||
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 { getDashboard } from "../api/analytics.api"
|
||||||
|
import { getSubscriptionPlans } from "../api/subscription-plans.api"
|
||||||
import { getRatings } from "../api/courses.api"
|
import { getRatings } from "../api/courses.api"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import type { DashboardData } from "../types/analytics.types"
|
import { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
|
||||||
|
import { getPrimaryQuestionTypeSummary, getSeriesPeriodLabel, getVideoLessonsSummary } from "../lib/analytics"
|
||||||
|
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
|
||||||
|
import type { SubscriptionPlan } from "../types/subscription.types"
|
||||||
import type { Rating } from "../types/course.types"
|
import type { Rating } from "../types/course.types"
|
||||||
|
|
||||||
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
|
||||||
|
|
@ -46,6 +51,19 @@ function formatDate(dateStr: string) {
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
|
||||||
|
|
||||||
|
function formatPlanDuration(plan: SubscriptionPlan): string {
|
||||||
|
const v = plan.duration_value
|
||||||
|
const u = plan.duration_unit.toUpperCase()
|
||||||
|
const word =
|
||||||
|
u === "MONTH" ? "month" : u === "YEAR" ? "year" : u === "WEEK" ? "week" : u === "DAY" ? "day" : plan.duration_unit
|
||||||
|
if (u === "MONTH" || u === "YEAR" || u === "WEEK" || u === "DAY") {
|
||||||
|
return `${v} ${v === 1 ? word : `${word}s`}`
|
||||||
|
}
|
||||||
|
return `${v} ${word}`
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const [userFirstName, setUserFirstName] = useState<string>("")
|
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||||
|
|
@ -53,6 +71,9 @@ export function DashboardPage() {
|
||||||
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
||||||
const [appRatings, setAppRatings] = useState<Rating[]>([])
|
const [appRatings, setAppRatings] = useState<Rating[]>([])
|
||||||
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
|
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
|
||||||
|
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
|
||||||
|
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([])
|
||||||
|
const [subscriptionPlansLoading, setSubscriptionPlansLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
|
|
@ -70,17 +91,10 @@ export function DashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchDashboard = async () => {
|
fetchUser()
|
||||||
try {
|
}, [])
|
||||||
const res = await getDashboard()
|
|
||||||
setDashboard(res.data as unknown as DashboardData)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const fetchAppRatings = async () => {
|
const fetchAppRatings = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
|
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
|
||||||
|
|
@ -92,23 +106,49 @@ export function DashboardPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUser()
|
|
||||||
fetchDashboard()
|
|
||||||
fetchAppRatings()
|
fetchAppRatings()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
setSubscriptionPlansLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getSubscriptionPlans()
|
||||||
|
setSubscriptionPlans(res.data.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setSubscriptionPlans([])
|
||||||
|
} finally {
|
||||||
|
setSubscriptionPlansLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPlans()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDashboard = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getDashboard(filters)
|
||||||
|
setDashboard(res.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setDashboard(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDashboard()
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
const registrationData =
|
const registrationData =
|
||||||
dashboard?.users.registrations_last_30_days.map((d) => ({
|
dashboard?.users.registrations_last_30_days.map((d) => ({
|
||||||
date: formatDate(d.date),
|
date: formatDate(d.date),
|
||||||
count: d.count,
|
count: d.count,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
|
|
||||||
const revenueData =
|
|
||||||
dashboard?.payments.revenue_last_30_days.map((d) => ({
|
|
||||||
date: formatDate(d.date),
|
|
||||||
revenue: d.revenue,
|
|
||||||
})) ?? []
|
|
||||||
|
|
||||||
const subscriptionStatusData =
|
const subscriptionStatusData =
|
||||||
dashboard?.subscriptions.by_status.map((s, i) => ({
|
dashboard?.subscriptions.by_status.map((s, i) => ({
|
||||||
name: s.label,
|
name: s.label,
|
||||||
|
|
@ -123,9 +163,14 @@ export function DashboardPage() {
|
||||||
color: PIE_COLORS[i % PIE_COLORS.length],
|
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||||
})) ?? []
|
})) ?? []
|
||||||
|
|
||||||
|
const seriesPeriodLabel = dashboard ? getSeriesPeriodLabel(dashboard.date_filter) : "Last 30 Days"
|
||||||
|
|
||||||
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 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-semibold text-grayScale-500">Dashboard</div>
|
||||||
|
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
|
||||||
|
</div>
|
||||||
<div className="mb-5 text-2xl font-semibold tracking-tight">
|
<div className="mb-5 text-2xl font-semibold tracking-tight">
|
||||||
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -216,18 +261,21 @@ export function DashboardPage() {
|
||||||
{activeStatTab === "secondary" && (
|
{activeStatTab === "secondary" && (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={BookOpen}
|
icon={Video}
|
||||||
label="Courses"
|
label="Videos"
|
||||||
value={dashboard.courses.total_courses.toLocaleString()}
|
value={dashboard.courses.total_videos.toLocaleString()}
|
||||||
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`}
|
deltaLabel={getVideoLessonsSummary(
|
||||||
deltaPositive
|
dashboard.courses.lms?.lessons_with_video,
|
||||||
|
dashboard.courses.exam_prep?.lessons_with_video,
|
||||||
|
)}
|
||||||
|
deltaPositive={dashboard.courses.total_videos > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={HelpCircle}
|
icon={HelpCircle}
|
||||||
label="Questions"
|
label="Questions"
|
||||||
value={dashboard.content.total_questions.toLocaleString()}
|
value={dashboard.content.total_questions.toLocaleString()}
|
||||||
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
|
deltaLabel={getPrimaryQuestionTypeSummary(dashboard.content.questions_by_type)}
|
||||||
deltaPositive
|
deltaPositive={dashboard.content.total_questions > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
|
|
@ -261,7 +309,7 @@ export function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
||||||
Last 30 Days
|
{seriesPeriodLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -357,76 +405,69 @@ export function DashboardPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Revenue Chart */}
|
<RevenueTrendCard />
|
||||||
<Card className="shadow-none">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Revenue Trend</CardTitle>
|
|
||||||
<div className="mt-2 text-2xl font-semibold tracking-tight">
|
|
||||||
ETB {dashboard.payments.total_revenue.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="h-[220px] 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={12} />
|
|
||||||
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
|
|
||||||
contentStyle={{
|
|
||||||
borderRadius: 12,
|
|
||||||
border: "1px solid #E0E0E0",
|
|
||||||
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="revenue" radius={[10, 10, 0, 0]} fill="#9E2891" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Users by Role / Region / Knowledge Level */}
|
{/* Subscription plans (from catalog API) */}
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<Card className="shadow-none">
|
||||||
{[
|
|
||||||
{ 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">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>{title}</CardTitle>
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5 text-brand-500" />
|
||||||
|
<CardTitle>Subscription plans</CardTitle>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-grayScale-500">Available billing plans for learners.</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-6 pt-2">
|
<CardContent className="p-6 pt-2">
|
||||||
{data.length > 0 ? (
|
{subscriptionPlansLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="flex items-center justify-center py-10">
|
||||||
{data.map((item, i) => (
|
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
||||||
<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>
|
</div>
|
||||||
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
|
) : subscriptionPlans.length === 0 ? (
|
||||||
</div>
|
<div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
|
||||||
))}
|
No subscription plans found
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
No data available
|
{subscriptionPlans.map((plan) => (
|
||||||
|
<div
|
||||||
|
key={plan.id}
|
||||||
|
className="flex flex-col rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-grayScale-700">{plan.name}</h3>
|
||||||
|
<Badge variant={plan.is_active ? "success" : "secondary"}>
|
||||||
|
{plan.is_active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{plan.description ? (
|
||||||
|
<p className="mt-2 line-clamp-2 text-sm text-grayScale-500">{plan.description}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-4 flex flex-wrap items-end justify-between gap-2 border-t border-grayScale-200 pt-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">Price</div>
|
||||||
|
<div className="text-lg font-semibold text-brand-600">
|
||||||
|
{plan.currency}{" "}
|
||||||
|
{Number.isInteger(plan.price)
|
||||||
|
? plan.price.toLocaleString()
|
||||||
|
: plan.price.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">
|
||||||
|
Billing
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-grayScale-600">{formatPlanDuration(plan)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* App Ratings */}
|
{/* App Ratings */}
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,13 @@ import { Badge } from "../../components/ui/badge"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { getDashboard } from "../../api/analytics.api"
|
import { getDashboard } from "../../api/analytics.api"
|
||||||
import type { DashboardData, LabelCount } from "../../types/analytics.types"
|
import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../components/analytics/AnalyticsTimeRangeFilter"
|
||||||
|
import {
|
||||||
|
getPrimaryQuestionTypeSummary,
|
||||||
|
getSeriesPeriodLabel,
|
||||||
|
getVideoLessonsSummary,
|
||||||
|
} from "../../lib/analytics"
|
||||||
|
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
|
||||||
|
|
||||||
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
|
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
|
||||||
|
|
||||||
|
|
@ -285,18 +291,21 @@ function Section({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
|
||||||
|
|
||||||
export function AnalyticsPage() {
|
export function AnalyticsPage() {
|
||||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
|
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
|
||||||
|
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async (nextFilters: DashboardFilters = filters) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(false)
|
setError(false)
|
||||||
try {
|
try {
|
||||||
const res = await getDashboard()
|
const res = await getDashboard(nextFilters)
|
||||||
setDashboard(res.data as unknown as DashboardData)
|
setDashboard(res.data)
|
||||||
} catch {
|
} catch {
|
||||||
setError(true)
|
setError(true)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -305,10 +314,11 @@ export function AnalyticsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
fetchData(filters)
|
||||||
}, [])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
if (loading) {
|
if (!dashboard && loading) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
||||||
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||||
|
|
@ -323,11 +333,14 @@ export function AnalyticsPage() {
|
||||||
if (error || !dashboard) {
|
if (error || !dashboard) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
|
||||||
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||||
|
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
|
||||||
|
</div>
|
||||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24">
|
||||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
<img src={alertSrc} alt="" className="h-12 w-12" />
|
||||||
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
<span className="text-sm text-destructive">Failed to load analytics data.</span>
|
||||||
<Button variant="outline" size="sm" onClick={fetchData}>
|
<Button variant="outline" size="sm" onClick={() => fetchData(filters)}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -337,6 +350,9 @@ export function AnalyticsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
|
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
|
||||||
|
const seriesPeriodLabel = getSeriesPeriodLabel(dashboard.date_filter)
|
||||||
|
const lms = courses.lms
|
||||||
|
const examPrep = courses.exam_prep
|
||||||
|
|
||||||
const registrationData = users.registrations_last_30_days.map((d) => ({
|
const registrationData = users.registrations_last_30_days.map((d) => ({
|
||||||
date: formatDate(d.date),
|
date: formatDate(d.date),
|
||||||
|
|
@ -387,15 +403,25 @@ export function AnalyticsPage() {
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
|
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
|
<span className="text-xs text-grayScale-400">
|
||||||
<Button variant="outline" size="sm" onClick={fetchData}>
|
{getDashboardFilterLabel(filters)} · Generated {generatedAt}
|
||||||
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
</span>
|
||||||
|
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => fetchData(filters)} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("mr-2 h-3.5 w-3.5", loading && "animate-spin")} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 rounded-lg border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500">
|
||||||
|
<img src={spinnerSrc} alt="" className="h-4 w-4 animate-spin" />
|
||||||
|
Updating analytics for {getDashboardFilterLabel(filters)}…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary Tabs */}
|
{/* Summary Tabs */}
|
||||||
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm">
|
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm">
|
||||||
<div className="-mb-px flex gap-6">
|
<div className="-mb-px flex gap-6">
|
||||||
|
|
@ -483,7 +509,7 @@ export function AnalyticsPage() {
|
||||||
<Section
|
<Section
|
||||||
title="Content & Platform"
|
title="Content & Platform"
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
count={courses.total_courses + content.total_questions}
|
count={courses.total_videos + content.total_questions}
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
@ -491,28 +517,29 @@ export function AnalyticsPage() {
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
label="Categories"
|
label="Categories"
|
||||||
value={courses.total_categories.toLocaleString()}
|
value={courses.total_categories.toLocaleString()}
|
||||||
sub={`${courses.total_courses} courses`}
|
sub={`${courses.total_courses} courses · ${courses.total_sub_courses} modules`}
|
||||||
trend="neutral"
|
trend="neutral"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
label="Sub-Courses"
|
label="LMS Programs"
|
||||||
value={courses.total_sub_courses.toLocaleString()}
|
value={(lms?.programs ?? 0).toLocaleString()}
|
||||||
sub={`across ${courses.total_courses} courses`}
|
sub={`${lms?.courses ?? 0} courses · ${lms?.practices ?? 0} practices`}
|
||||||
trend="neutral"
|
trend="neutral"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={Video}
|
icon={Video}
|
||||||
label="Videos"
|
label="Videos"
|
||||||
value={courses.total_videos.toLocaleString()}
|
value={courses.total_videos.toLocaleString()}
|
||||||
trend="neutral"
|
sub={getVideoLessonsSummary(lms?.lessons_with_video, examPrep?.lessons_with_video)}
|
||||||
|
trend={courses.total_videos > 0 ? "up" : "neutral"}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={HelpCircle}
|
icon={HelpCircle}
|
||||||
label="Questions"
|
label="Questions"
|
||||||
value={content.total_questions.toLocaleString()}
|
value={content.total_questions.toLocaleString()}
|
||||||
sub={`${content.total_question_sets} question sets`}
|
sub={getPrimaryQuestionTypeSummary(content.questions_by_type)}
|
||||||
trend="neutral"
|
trend={content.total_questions > 0 ? "up" : "neutral"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
@ -573,7 +600,7 @@ export function AnalyticsPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">Last 30 Days</Badge>
|
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[280px] p-6 pt-2">
|
<CardContent className="h-[280px] p-6 pt-2">
|
||||||
|
|
@ -603,10 +630,10 @@ export function AnalyticsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<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 Role" data={users.by_role} total={users.total_users} />
|
||||||
|
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
|
||||||
|
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
|
||||||
<BreakdownList title="Users by Status" data={users.by_status} 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 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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
@ -625,7 +652,7 @@ export function AnalyticsPage() {
|
||||||
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
|
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">Last 30 Days</Badge>
|
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[240px] p-6 pt-2">
|
<CardContent className="h-[240px] p-6 pt-2">
|
||||||
|
|
@ -664,7 +691,7 @@ export function AnalyticsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
|
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">Last 30 Days</Badge>
|
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="h-[240px] p-6 pt-2">
|
<CardContent className="h-[240px] p-6 pt-2">
|
||||||
|
|
@ -728,6 +755,43 @@ export function AnalyticsPage() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Course Management ─── */}
|
||||||
|
{(lms || examPrep) && (
|
||||||
|
<Section title="Course Management" icon={BookOpen} count={courses.total_videos} defaultOpen={false}>
|
||||||
|
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||||
|
{lms && (
|
||||||
|
<BreakdownList
|
||||||
|
title="LMS"
|
||||||
|
data={[
|
||||||
|
{ label: "Programs", count: lms.programs },
|
||||||
|
{ label: "Courses", count: lms.courses },
|
||||||
|
{ label: "Modules", count: lms.modules },
|
||||||
|
{ label: "Lessons", count: lms.lessons },
|
||||||
|
{ label: "Lessons with video", count: lms.lessons_with_video },
|
||||||
|
{ label: "Practices", count: lms.practices },
|
||||||
|
{ label: "Practices at course", count: lms.practices_at_course },
|
||||||
|
{ label: "Practices at module", count: lms.practices_at_module },
|
||||||
|
{ label: "Practices at lesson", count: lms.practices_at_lesson },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{examPrep && (
|
||||||
|
<BreakdownList
|
||||||
|
title="Exam prep"
|
||||||
|
data={[
|
||||||
|
{ label: "Catalog courses", count: examPrep.catalog_courses },
|
||||||
|
{ label: "Units", count: examPrep.units },
|
||||||
|
{ label: "Unit modules", count: examPrep.unit_modules },
|
||||||
|
{ label: "Lessons", count: examPrep.lessons },
|
||||||
|
{ label: "Lessons with video", count: examPrep.lessons_with_video },
|
||||||
|
{ label: "Lesson practices", count: examPrep.lesson_practices },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ─── Content Breakdown ─── */}
|
{/* ─── Content Breakdown ─── */}
|
||||||
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
|
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
|
||||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||||
|
|
|
||||||
|
|
@ -363,7 +363,7 @@ export function NotificationsPage() {
|
||||||
|
|
||||||
if (needsUsers) {
|
if (needsUsers) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
getUsers(1, 20)
|
getUsers({ page: 1, page_size: 20 })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const firstBatch = res.data?.data?.users ?? []
|
const firstBatch = res.data?.data?.users ?? []
|
||||||
const total = res.data?.data?.total ?? firstBatch.length
|
const total = res.data?.data?.total ?? firstBatch.length
|
||||||
|
|
@ -376,7 +376,7 @@ export function NotificationsPage() {
|
||||||
|
|
||||||
const remainingRequests: Array<ReturnType<typeof getUsers>> = []
|
const remainingRequests: Array<ReturnType<typeof getUsers>> = []
|
||||||
for (let page = 2; page <= totalPages; page += 1) {
|
for (let page = 2; page <= totalPages; page += 1) {
|
||||||
remainingRequests.push(getUsers(page, pageSize))
|
remainingRequests.push(getUsers({ page, page_size: pageSize }))
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import { Separator } from "../../components/ui/separator";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { useUsersStore } from "../../zustand/userStore";
|
import { useUsersStore } from "../../zustand/userStore";
|
||||||
import { getUserById } from "../../api/users.api";
|
import { getUserById, getUserRecentActivity } from "../../api/users.api";
|
||||||
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
|
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
|
||||||
import {
|
import {
|
||||||
getAdminLearnerCourseProgress,
|
getAdminLearnerCourseProgress,
|
||||||
|
|
@ -42,12 +42,48 @@ import { Select } from "../../components/ui/select";
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||||
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
|
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
|
||||||
import type { Course } from "../../types/course.types";
|
import type { Course } from "../../types/course.types";
|
||||||
|
import type { UserRecentActivityItem } from "../../types/user.types";
|
||||||
|
|
||||||
const activityIcons: Record<string, typeof CheckCircle2> = {
|
const activityIcons = {
|
||||||
completed: CheckCircle2,
|
completed: CheckCircle2,
|
||||||
started: PlayCircle,
|
started: PlayCircle,
|
||||||
joined: UserPlus,
|
joined: UserPlus,
|
||||||
};
|
default: BookOpen,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function visualActivityKind(kind: string): keyof typeof activityIcons {
|
||||||
|
const k = kind.toLowerCase();
|
||||||
|
if (k === "completed" || k === "complete") return "completed";
|
||||||
|
if (k === "started" || k === "start") return "started";
|
||||||
|
if (k === "joined" || k === "join") return "joined";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches Recent Activity mock: "Today, 10:27 AM" / "Yesterday, 3:45 PM" / "Jan 10, 2025". */
|
||||||
|
function formatActivityOccurredAt(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
|
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
const dayDiff = Math.round((startToday - startThat) / 86_400_000);
|
||||||
|
|
||||||
|
const timePart = d.toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dayDiff === 0) return `Today, ${timePart}`;
|
||||||
|
if (dayDiff === 1) return `Yesterday, ${timePart}`;
|
||||||
|
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type CourseOption = Course & { category_name: string };
|
type CourseOption = Course & { category_name: string };
|
||||||
|
|
||||||
|
|
@ -62,6 +98,8 @@ export function UserDetailPage() {
|
||||||
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
|
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
|
||||||
const [loadingProgress, setLoadingProgress] = useState(false);
|
const [loadingProgress, setLoadingProgress] = useState(false);
|
||||||
const [progressError, setProgressError] = useState<string | null>(null);
|
const [progressError, setProgressError] = useState<string | null>(null);
|
||||||
|
const [recentActivityItems, setRecentActivityItems] = useState<UserRecentActivityItem[]>([]);
|
||||||
|
const [recentActivityLoading, setRecentActivityLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
@ -149,6 +187,28 @@ export function UserDetailPage() {
|
||||||
loadProgress();
|
loadProgress();
|
||||||
}, [id, selectedProgressCourseId]);
|
}, [id, selectedProgressCourseId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
const userId = Number(id);
|
||||||
|
if (Number.isNaN(userId)) return;
|
||||||
|
|
||||||
|
const loadRecent = async () => {
|
||||||
|
setRecentActivityLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getUserRecentActivity(userId);
|
||||||
|
const items = res.data?.data?.items ?? [];
|
||||||
|
setRecentActivityItems(items);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load recent activity", err);
|
||||||
|
setRecentActivityItems([]);
|
||||||
|
} finally {
|
||||||
|
setRecentActivityLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadRecent();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
const progressMetrics = useMemo(() => {
|
const progressMetrics = useMemo(() => {
|
||||||
if (progressSummary) {
|
if (progressSummary) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -198,13 +258,6 @@ export function UserDetailPage() {
|
||||||
const fullName = `${user.first_name} ${user.last_name}`;
|
const fullName = `${user.first_name} ${user.last_name}`;
|
||||||
const initials = `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`.toUpperCase();
|
const initials = `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`.toUpperCase();
|
||||||
|
|
||||||
const recentActivities = [
|
|
||||||
{ type: "completed", text: "Completed Unit 4: Business Emails", time: "Today, 10:27 AM" },
|
|
||||||
{ type: "completed", text: "Completed Unit 3: Formal Writing", time: "Yesterday, 3:45 PM" },
|
|
||||||
{ type: "started", text: "Started Learning Path: Business English", time: "Jan 15, 2025" },
|
|
||||||
{ type: "joined", text: "Joined Yimaru", time: "Jan 10, 2025" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const infoFields = [
|
const infoFields = [
|
||||||
{ icon: Phone, label: "Phone", value: user.phone_number },
|
{ icon: Phone, label: "Phone", value: user.phone_number },
|
||||||
{ icon: Mail, label: "Email", value: user.email },
|
{ icon: Mail, label: "Email", value: user.email },
|
||||||
|
|
@ -566,37 +619,49 @@ export function UserDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{recentActivityLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-10 text-sm text-grayScale-400">
|
||||||
|
<SpinnerIcon className="h-5 w-5" />
|
||||||
|
Loading activity…
|
||||||
|
</div>
|
||||||
|
) : recentActivityItems.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-grayScale-400">No recent activity yet.</p>
|
||||||
|
) : (
|
||||||
<div className="relative space-y-0">
|
<div className="relative space-y-0">
|
||||||
{recentActivities.map((activity, index) => {
|
{recentActivityItems.map((item, index) => {
|
||||||
const Icon = activityIcons[activity.type] ?? CheckCircle2;
|
const vk = visualActivityKind(item.kind);
|
||||||
const isLast = index === recentActivities.length - 1;
|
const Icon = activityIcons[vk];
|
||||||
|
const isLast = index === recentActivityItems.length - 1;
|
||||||
return (
|
return (
|
||||||
<div key={index} className="relative flex gap-4 pb-5 last:pb-0">
|
<div key={item.id} className="relative flex gap-4 pb-5 last:pb-0">
|
||||||
{!isLast && (
|
{!isLast && (
|
||||||
<div className="absolute left-[15px] top-8 bottom-0 w-px bg-grayScale-200" />
|
<div className="absolute bottom-0 left-[15px] top-8 w-px bg-grayScale-200" />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||||
activity.type === "completed"
|
vk === "completed"
|
||||||
? "bg-mint-100 text-mint-500"
|
? "bg-mint-100 text-mint-500"
|
||||||
: activity.type === "started"
|
: vk === "started"
|
||||||
? "bg-brand-100/50 text-brand-500"
|
? "bg-brand-100/50 text-brand-500"
|
||||||
: "bg-grayScale-100 text-grayScale-400"
|
: vk === "joined"
|
||||||
|
? "bg-grayScale-100 text-grayScale-400"
|
||||||
|
: "bg-grayScale-100 text-grayScale-500",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 pt-1">
|
<div className="min-w-0 flex-1 pt-1">
|
||||||
<div className="text-sm font-medium text-grayScale-600">
|
<div className="text-sm font-medium text-grayScale-600">{item.headline}</div>
|
||||||
{activity.text}
|
<div className="mt-0.5 text-xs text-grayScale-400">
|
||||||
|
{formatActivityOccurredAt(item.occurred_at)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs text-grayScale-400">{activity.time}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { Link } from "react-router-dom"
|
|
||||||
import {
|
|
||||||
Users,
|
|
||||||
UserX,
|
|
||||||
UserCheck,
|
|
||||||
TrendingUp,
|
|
||||||
ArrowRight,
|
|
||||||
List,
|
|
||||||
UsersRound,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../components/ui/card"
|
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
|
||||||
import { getDashboard } from "../../api/analytics.api"
|
|
||||||
import type { DashboardUsers } from "../../types/analytics.types"
|
|
||||||
|
|
||||||
export function UserManagementDashboard() {
|
|
||||||
const [stats, setStats] = useState<DashboardUsers | null>(null)
|
|
||||||
const [statsLoading, setStatsLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchStats = async () => {
|
|
||||||
try {
|
|
||||||
const res = await getDashboard()
|
|
||||||
const usersData = (res.data as any)?.users ?? (res.data as any)?.data?.users ?? null
|
|
||||||
setStats(usersData)
|
|
||||||
} catch {
|
|
||||||
// silently fail — cards will show "—"
|
|
||||||
} finally {
|
|
||||||
setStatsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchStats()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const formatNum = (n: number) => n.toLocaleString()
|
|
||||||
const activeUsers =
|
|
||||||
stats?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Page Header */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-grayScale-600">User Management</h1>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Manage users, groups, and registrations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stat Cards */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<Card className="border-none bg-brand-50 shadow-sm">
|
|
||||||
<CardContent className="flex items-center gap-4 p-5">
|
|
||||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
|
||||||
<Users className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-white/80">Total Users</p>
|
|
||||||
<p className="text-2xl font-bold text-white">
|
|
||||||
{statsLoading ? <SpinnerIcon className="h-5 w-5" /> : stats ? formatNum(stats.total_users) : "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-none bg-brand-50 shadow-sm">
|
|
||||||
<CardContent className="flex items-center gap-4 p-5">
|
|
||||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
|
||||||
<UserCheck className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-white/80">Active Users</p>
|
|
||||||
<p className="text-2xl font-bold text-white">
|
|
||||||
{statsLoading ? (
|
|
||||||
<SpinnerIcon className="h-5 w-5" />
|
|
||||||
) : activeUsers !== null ? (
|
|
||||||
formatNum(activeUsers)
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-none bg-brand-50 shadow-sm sm:col-span-2 lg:col-span-1">
|
|
||||||
<CardContent className="flex items-center gap-4 p-5">
|
|
||||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
|
||||||
<TrendingUp className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-white/80">New This Month</p>
|
|
||||||
<p className="text-2xl font-bold text-white">
|
|
||||||
{statsLoading ? (
|
|
||||||
<SpinnerIcon className="h-5 w-5" />
|
|
||||||
) : stats ? (
|
|
||||||
formatNum(stats.new_month)
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Cards */}
|
|
||||||
<div>
|
|
||||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-600">Quick Actions</h2>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<Link to="/users/deletion-requests" className="group">
|
|
||||||
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
|
|
||||||
<UserX className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
|
||||||
Deletion Requests
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-sm text-grayScale-400">
|
|
||||||
Review account deletion requests and user deletion states.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
|
||||||
View requests
|
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/users/groups" className="group">
|
|
||||||
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
|
|
||||||
<UsersRound className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
|
||||||
User Groups
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-sm text-grayScale-400">
|
|
||||||
Manage groups, roles, and permission settings.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
|
||||||
Manage groups
|
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/users/list" className="group sm:col-span-2 lg:col-span-1">
|
|
||||||
<Card className="h-full border border-grayScale-100 shadow-sm transition-all duration-200 group-hover:border-brand-200 group-hover:shadow-md">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-brand-100 text-brand-600 transition-colors group-hover:bg-brand-500 group-hover:text-white">
|
|
||||||
<List className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
|
||||||
User List
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-sm text-grayScale-400">
|
|
||||||
Browse, search, and manage all registered users.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
|
||||||
View all users
|
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,110 @@
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight, Search, UserCheck, Users, X } from "lucide-react"
|
import { ChevronDown, ChevronLeft, ChevronRight, Search, TrendingUp, UserCheck, Users, X } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { getDashboard } from "../../api/analytics.api"
|
||||||
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
|
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
|
||||||
|
import type { DashboardUsers } from "../../types/analytics.types"
|
||||||
import { mapUserApiToUser } from "../../types/user.types"
|
import { mapUserApiToUser } from "../../types/user.types"
|
||||||
import { useUsersStore } from "../../zustand/userStore"
|
import { useUsersStore } from "../../zustand/userStore"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import axios from "axios"
|
||||||
|
import { USER_FILTER_COUNTRIES, USER_FILTER_ETHIOPIA_REGIONS } from "../../data/userFilterLocations"
|
||||||
|
|
||||||
|
function formatJoinedAt(iso: string): string {
|
||||||
|
if (!iso?.trim()) return "—"
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return "—"
|
||||||
|
return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert `<input type="datetime-local" />` value to RFC3339 for GET /users. */
|
||||||
|
function toRfc3339FromDatetimeLocal(value: string): string | undefined {
|
||||||
|
const t = value?.trim()
|
||||||
|
if (!t) return undefined
|
||||||
|
const d = new Date(t)
|
||||||
|
if (Number.isNaN(d.getTime())) return undefined
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Portaled menu — native `<select>` lists break inside `overflow-y-auto` shells (e.g. app main). */
|
||||||
|
function UserListFilterDropdown({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
allLabel,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
allLabel: string
|
||||||
|
options: readonly string[]
|
||||||
|
onSelect: (next: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={id} className="text-xs font-medium text-grayScale-500">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<DropdownMenu.Root modal={false}>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600",
|
||||||
|
"outline-none focus-visible:ring-1 focus-visible:ring-brand-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{value || allLabel}</span>
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-grayScale-400" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
collisionPadding={12}
|
||||||
|
className="z-[200] max-h-60 min-w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-md border border-grayScale-200 bg-white p-1 shadow-lg"
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer rounded px-2 py-2 text-sm text-grayScale-700 outline-none data-[highlighted]:bg-grayScale-100",
|
||||||
|
!value && "bg-grayScale-50 font-medium",
|
||||||
|
)}
|
||||||
|
onSelect={() => onSelect("")}
|
||||||
|
>
|
||||||
|
{allLabel}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={opt}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer rounded px-2 py-2 text-sm text-grayScale-700 outline-none data-[highlighted]:bg-grayScale-100",
|
||||||
|
value === opt && "bg-brand-50 font-medium text-brand-700",
|
||||||
|
)}
|
||||||
|
onSelect={() => onSelect(opt)}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function UsersListPage() {
|
export function UsersListPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -36,19 +131,45 @@ export function UsersListPage() {
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [roleFilter, setRoleFilter] = useState("")
|
const [roleFilter, setRoleFilter] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState("")
|
const [statusFilter, setStatusFilter] = useState("")
|
||||||
|
const [createdAfterLocal, setCreatedAfterLocal] = useState("")
|
||||||
|
const [createdBeforeLocal, setCreatedBeforeLocal] = useState("")
|
||||||
|
const [countryFilter, setCountryFilter] = useState("")
|
||||||
|
const [regionFilter, setRegionFilter] = useState("")
|
||||||
|
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [userSummary, setUserSummary] = useState<DashboardUsers | null>(null)
|
||||||
|
const [userSummaryLoading, setUserSummaryLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSummary = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getDashboard()
|
||||||
|
setUserSummary(res.data.users ?? null)
|
||||||
|
} catch {
|
||||||
|
setUserSummary(null)
|
||||||
|
} finally {
|
||||||
|
setUserSummaryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchSummary()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getUsers(
|
const res = await getUsers({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
page_size: pageSize,
|
||||||
roleFilter || undefined,
|
role: roleFilter || undefined,
|
||||||
statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
search || undefined,
|
query: search || undefined,
|
||||||
)
|
created_after: toRfc3339FromDatetimeLocal(createdAfterLocal),
|
||||||
|
created_before: toRfc3339FromDatetimeLocal(createdBeforeLocal),
|
||||||
|
country: countryFilter.trim() || undefined,
|
||||||
|
region: regionFilter.trim() || undefined,
|
||||||
|
subscription_status: subscriptionStatusFilter || undefined,
|
||||||
|
})
|
||||||
const apiUsers = res.data.data.users
|
const apiUsers = res.data.data.users
|
||||||
|
|
||||||
const mapped = apiUsers.map(mapUserApiToUser)
|
const mapped = apiUsers.map(mapUserApiToUser)
|
||||||
|
|
@ -64,13 +185,30 @@ export function UsersListPage() {
|
||||||
console.error("Failed to fetch users:", error)
|
console.error("Failed to fetch users:", error)
|
||||||
setUsers([])
|
setUsers([])
|
||||||
setTotal(0)
|
setTotal(0)
|
||||||
|
const msg = axios.isAxiosError(error)
|
||||||
|
? (error.response?.data as { message?: string } | undefined)?.message
|
||||||
|
: undefined
|
||||||
|
toast.error(typeof msg === "string" && msg.trim() ? msg : "Failed to fetch users")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
}, [page, pageSize, roleFilter, statusFilter, search, setUsers, setTotal])
|
}, [
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
roleFilter,
|
||||||
|
statusFilter,
|
||||||
|
search,
|
||||||
|
createdAfterLocal,
|
||||||
|
createdBeforeLocal,
|
||||||
|
countryFilter,
|
||||||
|
regionFilter,
|
||||||
|
subscriptionStatusFilter,
|
||||||
|
setUsers,
|
||||||
|
setTotal,
|
||||||
|
])
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const safePage = Math.min(page, pageCount)
|
const safePage = Math.min(page, pageCount)
|
||||||
|
|
@ -99,7 +237,10 @@ export function UsersListPage() {
|
||||||
const allSelected = users.length > 0 && selectedIds.size === users.length
|
const allSelected = users.length > 0 && selectedIds.size === users.length
|
||||||
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
|
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
|
||||||
const endEntry = Math.min(safePage * pageSize, total)
|
const endEntry = Math.min(safePage * pageSize, total)
|
||||||
const activeUsersOnPage = users.filter((u) => (u.status || "").toUpperCase() === "ACTIVE").length
|
|
||||||
|
const formatSummaryNum = (n: number) => n.toLocaleString()
|
||||||
|
const activeUsersTotal =
|
||||||
|
userSummary?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null
|
||||||
|
|
||||||
const getPageNumbers = () => {
|
const getPageNumbers = () => {
|
||||||
const pages: (number | string)[] = []
|
const pages: (number | string)[] = []
|
||||||
|
|
@ -168,6 +309,29 @@ export function UsersListPage() {
|
||||||
navigate(`/users/${userId}`)
|
navigate(`/users/${userId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearExtraFilters = () => {
|
||||||
|
setCreatedAfterLocal("")
|
||||||
|
setCreatedBeforeLocal("")
|
||||||
|
setCountryFilter("")
|
||||||
|
setRegionFilter("")
|
||||||
|
setSubscriptionStatusFilter("")
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContactDetails = (phone: string | undefined, email: string | undefined) => {
|
||||||
|
const hasPhone = Boolean(phone?.trim())
|
||||||
|
const hasEmail = Boolean(email?.trim())
|
||||||
|
if (!hasPhone && !hasEmail) {
|
||||||
|
return <span className="text-grayScale-400">—</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-sm text-grayScale-600">
|
||||||
|
{hasPhone ? <div className="tabular-nums">{phone!.trim()}</div> : null}
|
||||||
|
{hasEmail ? <div className="break-all text-grayScale-500">{email!.trim()}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -176,35 +340,67 @@ export function UsersListPage() {
|
||||||
<p className="text-sm text-grayScale-400">View and manage all registered users.</p>
|
<p className="text-sm text-grayScale-400">View and manage all registered users.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats cards (match UserLogPage approach) */}
|
{/* Platform-wide user summary (same metrics as former User Management dashboard) */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
<Card className="border-none bg-brand-50 shadow-sm">
|
||||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
<Users className="h-5 w-5" />
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
||||||
|
<Users className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-2xl font-bold text-grayScale-600">{total}</p>
|
<p className="text-sm font-medium text-white/80">Total Users</p>
|
||||||
<p className="text-xs text-grayScale-400">Total Users</p>
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{userSummaryLoading ? (
|
||||||
|
<SpinnerIcon className="h-5 w-5" />
|
||||||
|
) : userSummary ? (
|
||||||
|
formatSummaryNum(userSummary.total_users)
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none bg-brand-50 shadow-sm">
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
||||||
|
<UserCheck className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
<div className="min-w-0">
|
||||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
|
<p className="text-sm font-medium text-white/80">Active Users</p>
|
||||||
<UserCheck className="h-5 w-5" />
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{userSummaryLoading ? (
|
||||||
|
<SpinnerIcon className="h-5 w-5" />
|
||||||
|
) : activeUsersTotal !== null ? (
|
||||||
|
formatSummaryNum(activeUsersTotal)
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</CardContent>
|
||||||
<p className="text-2xl font-bold text-grayScale-600">{activeUsersOnPage}</p>
|
</Card>
|
||||||
<p className="text-xs text-grayScale-400">Active In Current Page</p>
|
|
||||||
</div>
|
<Card className="border-none bg-brand-50 shadow-sm sm:col-span-2 lg:col-span-1">
|
||||||
</div>
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
|
||||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
|
<TrendingUp className="h-6 w-6" />
|
||||||
<Search className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-bold text-grayScale-600">{users.length}</p>
|
|
||||||
<p className="text-xs text-grayScale-400">Showing Results</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white/80">New This Month</p>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{userSummaryLoading ? (
|
||||||
|
<SpinnerIcon className="h-5 w-5" />
|
||||||
|
) : userSummary ? (
|
||||||
|
formatSummaryNum(userSummary.new_month)
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border">
|
<div className="bg-white rounded-xl border">
|
||||||
|
|
@ -225,7 +421,10 @@ export function UsersListPage() {
|
||||||
<div className="relative w-full sm:w-auto">
|
<div className="relative w-full sm:w-auto">
|
||||||
<select
|
<select
|
||||||
value={roleFilter}
|
value={roleFilter}
|
||||||
onChange={(e) => setRoleFilter(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setRoleFilter(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
>
|
>
|
||||||
<option value="">All roles</option>
|
<option value="">All roles</option>
|
||||||
|
|
@ -239,7 +438,10 @@ export function UsersListPage() {
|
||||||
<div className="relative w-full sm:w-auto">
|
<div className="relative w-full sm:w-auto">
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
|
|
@ -252,6 +454,96 @@ export function UsersListPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 border-t border-grayScale-100 pt-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="filter-created-after" className="text-xs font-medium text-grayScale-500">
|
||||||
|
Created on or after
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filter-created-after"
|
||||||
|
type="datetime-local"
|
||||||
|
value={createdAfterLocal}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCreatedAfterLocal(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="h-9 w-full rounded-md border border-grayScale-200 bg-white px-2 text-sm text-grayScale-700 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="filter-created-before" className="text-xs font-medium text-grayScale-500">
|
||||||
|
Created on or before
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filter-created-before"
|
||||||
|
type="datetime-local"
|
||||||
|
value={createdBeforeLocal}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCreatedBeforeLocal(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="h-9 w-full rounded-md border border-grayScale-200 bg-white px-2 text-sm text-grayScale-700 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UserListFilterDropdown
|
||||||
|
id="filter-country"
|
||||||
|
label="Country"
|
||||||
|
value={countryFilter}
|
||||||
|
allLabel="All countries"
|
||||||
|
options={USER_FILTER_COUNTRIES}
|
||||||
|
onSelect={(next) => {
|
||||||
|
setCountryFilter(next)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<UserListFilterDropdown
|
||||||
|
id="filter-region"
|
||||||
|
label="Region (Ethiopia)"
|
||||||
|
value={regionFilter}
|
||||||
|
allLabel="All regions"
|
||||||
|
options={USER_FILTER_ETHIOPIA_REGIONS}
|
||||||
|
onSelect={(next) => {
|
||||||
|
setRegionFilter(next)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="filter-subscription-status" className="text-xs font-medium text-grayScale-500">
|
||||||
|
Subscription status
|
||||||
|
</label>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<select
|
||||||
|
id="filter-subscription-status"
|
||||||
|
value={subscriptionStatusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSubscriptionStatusFilter(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="h-9 w-full appearance-none rounded-md border border-grayScale-200 bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="ACTIVE">Active</option>
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="Unsubscribed">Unsubscribed</option>
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
Dates are sent as RFC3339 (UTC). Country and region filters use the lists above; the API matches
|
||||||
|
case-insensitively.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearExtraFilters}
|
||||||
|
className="shrink-0 text-sm font-medium text-brand-600 hover:text-brand-700"
|
||||||
|
>
|
||||||
|
Clear date, location & subscription filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
|
|
@ -267,10 +559,11 @@ export function UsersListPage() {
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>USER</TableHead>
|
<TableHead>USER</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Role</TableHead>
|
<TableHead className="hidden md:table-cell min-w-[10rem]">Contact details</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Phone</TableHead>
|
|
||||||
<TableHead className="hidden md:table-cell">Country</TableHead>
|
<TableHead className="hidden md:table-cell">Country</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Region</TableHead>
|
<TableHead className="hidden md:table-cell">Region</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell whitespace-nowrap">Joined at</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell max-w-[12rem]">Subscription status</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -278,13 +571,13 @@ export function UsersListPage() {
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="py-12 text-center">
|
<TableCell colSpan={8} className="py-12 text-center">
|
||||||
<p className="text-sm text-grayScale-400">Loading users...</p>
|
<p className="text-sm text-grayScale-400">Loading users...</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="py-16 text-center">
|
<TableCell colSpan={8} className="py-16 text-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
|
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
|
||||||
<Users className="h-7 w-7 text-grayScale-400" />
|
<Users className="h-7 w-7 text-grayScale-400" />
|
||||||
|
|
@ -322,16 +615,31 @@ export function UsersListPage() {
|
||||||
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
|
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div className="font-medium text-grayScale-600">
|
||||||
<div className="font-medium text-grayScale-600">{u.firstName} {u.lastName}</div>
|
{u.firstName} {u.lastName}
|
||||||
<div className="text-xs text-grayScale-400">{u.email || u.phoneNumber || "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.role || "-"}</TableCell>
|
<TableCell className="hidden md:table-cell align-top">
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
{renderContactDetails(u.phoneNumber, u.email)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
|
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
|
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell text-sm text-grayScale-500 whitespace-nowrap">
|
||||||
|
{formatJoinedAt(u.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell align-top text-sm text-grayScale-600">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
u.subscriptionStatus === "—" ||
|
||||||
|
u.subscriptionStatus.toLowerCase() === "unsubscribed"
|
||||||
|
? "text-grayScale-400"
|
||||||
|
: undefined,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{u.subscriptionStatus}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,34 @@ export interface DashboardPayments {
|
||||||
revenue_last_30_days: DateRevenue[]
|
revenue_last_30_days: DateRevenue[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardCoursesLms {
|
||||||
|
programs: number
|
||||||
|
courses: number
|
||||||
|
modules: number
|
||||||
|
lessons: number
|
||||||
|
lessons_with_video: number
|
||||||
|
practices: number
|
||||||
|
practices_at_course: number
|
||||||
|
practices_at_module: number
|
||||||
|
practices_at_lesson: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardCoursesExamPrep {
|
||||||
|
catalog_courses: number
|
||||||
|
units: number
|
||||||
|
unit_modules: number
|
||||||
|
lessons: number
|
||||||
|
lessons_with_video: number
|
||||||
|
lesson_practices: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardCourses {
|
export interface DashboardCourses {
|
||||||
total_categories: number
|
total_categories: number
|
||||||
total_courses: number
|
total_courses: number
|
||||||
total_sub_courses: number
|
total_sub_courses: number
|
||||||
total_videos: number
|
total_videos: number
|
||||||
|
lms?: DashboardCoursesLms
|
||||||
|
exam_prep?: DashboardCoursesExamPrep
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardContent {
|
export interface DashboardContent {
|
||||||
|
|
@ -88,8 +111,34 @@ export interface DashboardTeam {
|
||||||
by_status: LabelCount[]
|
by_status: LabelCount[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DashboardDateFilterMode = "all_time" | "year" | "year_month" | "custom"
|
||||||
|
|
||||||
|
export interface DashboardDateFilter {
|
||||||
|
mode: DashboardDateFilterMode
|
||||||
|
year?: number
|
||||||
|
month?: number
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
range_start?: string
|
||||||
|
range_end?: string
|
||||||
|
series_start?: string
|
||||||
|
series_end?: string
|
||||||
|
ref_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardFilterMode = DashboardDateFilterMode
|
||||||
|
|
||||||
|
export interface DashboardFilters {
|
||||||
|
mode: DashboardFilterMode
|
||||||
|
year?: number
|
||||||
|
month?: number
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
generated_at: string
|
generated_at: string
|
||||||
|
date_filter?: DashboardDateFilter
|
||||||
users: DashboardUsers
|
users: DashboardUsers
|
||||||
subscriptions: DashboardSubscriptions
|
subscriptions: DashboardSubscriptions
|
||||||
payments: DashboardPayments
|
payments: DashboardPayments
|
||||||
|
|
|
||||||
21
src/types/subscription.types.ts
Normal file
21
src/types/subscription.types.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export type SubscriptionPlanDurationUnit = "MONTH" | "YEAR" | "WEEK" | "DAY" | string
|
||||||
|
|
||||||
|
export interface SubscriptionPlan {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
duration_value: number
|
||||||
|
duration_unit: SubscriptionPlanDurationUnit
|
||||||
|
price: number
|
||||||
|
currency: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionPlansListResponse {
|
||||||
|
message?: string
|
||||||
|
data: SubscriptionPlan[]
|
||||||
|
success?: boolean
|
||||||
|
status_code?: number
|
||||||
|
metadata?: unknown
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,40 @@
|
||||||
// This matches the API response 1:1
|
// This matches the API response 1:1 (GET /users); many fields are optional on partial profiles.
|
||||||
export interface UserApiDTO {
|
export interface UserApiDTO {
|
||||||
id: number
|
id: number
|
||||||
first_name: string
|
first_name?: string
|
||||||
last_name: string
|
last_name?: string
|
||||||
gender: string
|
gender?: string
|
||||||
birth_day: string | null
|
birth_day?: string | null
|
||||||
|
|
||||||
email: string
|
email?: string
|
||||||
phone_number?: string
|
phone_number?: string
|
||||||
role: string
|
role: string
|
||||||
|
|
||||||
age_group: string
|
age_group?: string
|
||||||
education_level: string
|
education_level?: string
|
||||||
country: string
|
country?: string
|
||||||
region: string
|
region?: string
|
||||||
|
|
||||||
nick_name: string
|
nick_name?: string
|
||||||
occupation: string
|
occupation?: string
|
||||||
learning_goal: string
|
learning_goal?: string
|
||||||
language_goal: string
|
language_goal?: string
|
||||||
language_challange: string
|
language_challange?: string
|
||||||
favoutite_topic: string
|
favoutite_topic?: string
|
||||||
|
|
||||||
email_verified: boolean
|
email_verified?: boolean
|
||||||
phone_verified: boolean
|
phone_verified?: boolean
|
||||||
status: string
|
status: string
|
||||||
|
|
||||||
profile_completed: boolean
|
profile_completed?: boolean
|
||||||
profile_picture_url: string
|
profile_picture_url?: string
|
||||||
preferred_language: string
|
preferred_language?: string
|
||||||
|
profile_completion_percentage?: number
|
||||||
|
|
||||||
created_at: string
|
created_at: string
|
||||||
|
updated_at?: string
|
||||||
|
/** Billing / plan state for list UI (e.g. "Unsubscribed", "Active"). */
|
||||||
|
subscription_status?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetUsersResponse {
|
export interface GetUsersResponse {
|
||||||
|
|
@ -55,20 +59,26 @@ export interface User {
|
||||||
country: string
|
country: string
|
||||||
lastLogin: string | null
|
lastLogin: string | null
|
||||||
status: string
|
status: string
|
||||||
|
/** From API `subscription_status` (e.g. "Unsubscribed"). */
|
||||||
|
subscriptionStatus: string
|
||||||
|
/** ISO 8601 from API `created_at`. */
|
||||||
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
export const mapUserApiToUser = (u: UserApiDTO): User => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
firstName: u.first_name,
|
firstName: u.first_name ?? "",
|
||||||
lastName: u.last_name,
|
lastName: u.last_name ?? "",
|
||||||
nickName: u.nick_name,
|
nickName: u.nick_name ?? "",
|
||||||
email: u.email,
|
email: u.email ?? "",
|
||||||
phoneNumber: u.phone_number ?? "",
|
phoneNumber: u.phone_number ?? "",
|
||||||
role: u.role,
|
role: u.role,
|
||||||
region: u.region,
|
region: u.region ?? "",
|
||||||
country: u.country,
|
country: u.country ?? "",
|
||||||
lastLogin: null,
|
lastLogin: null,
|
||||||
status: u.status,
|
status: u.status,
|
||||||
|
subscriptionStatus: u.subscription_status?.trim() ? u.subscription_status.trim() : "—",
|
||||||
|
createdAt: u.created_at ?? "",
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface UserProfileData {
|
export interface UserProfileData {
|
||||||
|
|
@ -115,6 +125,27 @@ export interface UserProfileResponse {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /admin/users/:user_id/recent-activity */
|
||||||
|
export interface UserRecentActivityItem {
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
occurred_at: string
|
||||||
|
headline: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRecentActivityData {
|
||||||
|
user_id: number
|
||||||
|
items: UserRecentActivityItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRecentActivityResponse {
|
||||||
|
message?: string
|
||||||
|
data?: UserRecentActivityData
|
||||||
|
success?: boolean
|
||||||
|
status_code?: number
|
||||||
|
metadata?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
export interface UserSummary {
|
||||||
total_users: number
|
total_users: number
|
||||||
active_users: number
|
active_users: number
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user