UAT fixes stage 1
This commit is contained in:
parent
2b556d9d09
commit
385f58fd22
|
|
@ -1,5 +1,39 @@
|
|||
import http from "./http";
|
||||
import type { DashboardResponse } from "../types/analytics.types";
|
||||
import type { DashboardData, DashboardFilters, DashboardResponse } from "../types/analytics.types";
|
||||
|
||||
export const getDashboard = () =>
|
||||
http.get<DashboardResponse>("/analytics/dashboard");
|
||||
function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
|
||||
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 GetDeletionRequestsParams,
|
||||
type GetDeletionRequestsResponse,
|
||||
type UserRecentActivityResponse,
|
||||
} from "../types/user.types";
|
||||
|
||||
export const getUsers = (
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
role?: string,
|
||||
status?: string,
|
||||
query?: string,
|
||||
) =>
|
||||
/** Query params for GET /users (RFC3339 for created_*; subscription_status: ACTIVE | PENDING | Unsubscribed). */
|
||||
export interface GetUsersParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
role?: 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", {
|
||||
params: {
|
||||
role,
|
||||
status,
|
||||
query,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
},
|
||||
params: buildGetUsersQuery(params),
|
||||
});
|
||||
|
||||
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
|
||||
|
|
@ -38,6 +61,9 @@ export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
|
|||
export const getUserById = (id: number) =>
|
||||
http.get<UserProfileResponse>(`/user/single/${id}`);
|
||||
|
||||
export const getUserRecentActivity = (userId: number) =>
|
||||
http.get<UserRecentActivityResponse>(`/admin/users/${userId}/recent-activity`);
|
||||
|
||||
export const getMyProfile = () =>
|
||||
http.get<UserProfileResponse>("/team/me");
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ import { CreateNotificationPage } from "../pages/notifications/CreateNotificatio
|
|||
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
|
||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
||||
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
||||
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
|
||||
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
|
||||
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
|
||||
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
|
||||
|
|
@ -78,7 +77,7 @@ export function AppRoutes() {
|
|||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UserManagementLayout />}>
|
||||
<Route index element={<UserManagementDashboard />} />
|
||||
<Route index element={<Navigate to="list" replace />} />
|
||||
<Route path="list" element={<UsersListPage />} />
|
||||
<Route path="deletion-requests" element={<DeletionRequestsPage />} />
|
||||
<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 {
|
||||
// Activity,
|
||||
BadgeCheck,
|
||||
BookOpen,
|
||||
Video,
|
||||
// Coins,
|
||||
DollarSign,
|
||||
HelpCircle,
|
||||
|
|
@ -11,14 +11,13 @@ import {
|
|||
// TrendingUp,
|
||||
Users,
|
||||
Bell,
|
||||
CreditCard,
|
||||
UsersRound,
|
||||
} from "lucide-react"
|
||||
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
|
|
@ -28,15 +27,21 @@ import {
|
|||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
import { RevenueTrendCard } from "../components/dashboard/RevenueTrendCard"
|
||||
import { StatCard } from "../components/dashboard/StatCard"
|
||||
import alertSrc from "../assets/Alert.svg"
|
||||
import { Badge } from "../components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
|
||||
import { cn } from "../lib/utils"
|
||||
import { getTeamMemberById } from "../api/team.api"
|
||||
import { getDashboard } from "../api/analytics.api"
|
||||
import { getSubscriptionPlans } from "../api/subscription-plans.api"
|
||||
import { getRatings } from "../api/courses.api"
|
||||
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"
|
||||
|
||||
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" })
|
||||
}
|
||||
|
||||
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() {
|
||||
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
|
|
@ -53,6 +71,9 @@ export function DashboardPage() {
|
|||
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
|
||||
const [appRatings, setAppRatings] = useState<Rating[]>([])
|
||||
const [appRatingsLoading, setAppRatingsLoading] = useState(true)
|
||||
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([])
|
||||
const [subscriptionPlansLoading, setSubscriptionPlansLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
|
|
@ -70,17 +91,10 @@ export function DashboardPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
setDashboard(res.data as unknown as DashboardData)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchUser()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAppRatings = async () => {
|
||||
try {
|
||||
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
|
||||
|
|
@ -92,23 +106,49 @@ export function DashboardPage() {
|
|||
}
|
||||
}
|
||||
|
||||
fetchUser()
|
||||
fetchDashboard()
|
||||
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 =
|
||||
dashboard?.users.registrations_last_30_days.map((d) => ({
|
||||
date: formatDate(d.date),
|
||||
count: d.count,
|
||||
})) ?? []
|
||||
|
||||
const revenueData =
|
||||
dashboard?.payments.revenue_last_30_days.map((d) => ({
|
||||
date: formatDate(d.date),
|
||||
revenue: d.revenue,
|
||||
})) ?? []
|
||||
|
||||
const subscriptionStatusData =
|
||||
dashboard?.subscriptions.by_status.map((s, i) => ({
|
||||
name: s.label,
|
||||
|
|
@ -123,9 +163,14 @@ export function DashboardPage() {
|
|||
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||
})) ?? []
|
||||
|
||||
const seriesPeriodLabel = dashboard ? getSeriesPeriodLabel(dashboard.date_filter) : "Last 30 Days"
|
||||
|
||||
return (
|
||||
<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">
|
||||
Welcome, {userFirstName || localStorage.getItem("user_first_name")}
|
||||
</div>
|
||||
|
|
@ -216,18 +261,21 @@ export function DashboardPage() {
|
|||
{activeStatTab === "secondary" && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={BookOpen}
|
||||
label="Courses"
|
||||
value={dashboard.courses.total_courses.toLocaleString()}
|
||||
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`}
|
||||
deltaPositive
|
||||
icon={Video}
|
||||
label="Videos"
|
||||
value={dashboard.courses.total_videos.toLocaleString()}
|
||||
deltaLabel={getVideoLessonsSummary(
|
||||
dashboard.courses.lms?.lessons_with_video,
|
||||
dashboard.courses.exam_prep?.lessons_with_video,
|
||||
)}
|
||||
deltaPositive={dashboard.courses.total_videos > 0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={HelpCircle}
|
||||
label="Questions"
|
||||
value={dashboard.content.total_questions.toLocaleString()}
|
||||
deltaLabel={`${dashboard.content.total_question_sets} question sets`}
|
||||
deltaPositive
|
||||
deltaLabel={getPrimaryQuestionTypeSummary(dashboard.content.questions_by_type)}
|
||||
deltaPositive={dashboard.content.total_questions > 0}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Bell}
|
||||
|
|
@ -261,7 +309,7 @@ export function DashboardPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
|
||||
Last 30 Days
|
||||
{seriesPeriodLabel}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -357,76 +405,69 @@ export function DashboardPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revenue Chart */}
|
||||
<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>
|
||||
<RevenueTrendCard />
|
||||
</div>
|
||||
|
||||
{/* Users by Role / Region / Knowledge Level */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{[
|
||||
{ title: "Users by Role", data: dashboard.users.by_role },
|
||||
{ title: "Users by Region", data: dashboard.users.by_region },
|
||||
{ title: "Users by Knowledge Level", data: dashboard.users.by_knowledge_level },
|
||||
].map(({ title, data }) => (
|
||||
<Card key={title} className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 pt-2">
|
||||
{data.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.map((item, i) => (
|
||||
<div key={item.label} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||
/>
|
||||
<span className="text-grayScale-600">{item.label}</span>
|
||||
{/* Subscription plans (from catalog API) */}
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<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>
|
||||
<CardContent className="p-6 pt-2">
|
||||
{subscriptionPlansLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : subscriptionPlans.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
|
||||
No subscription plans found
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{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>
|
||||
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
|
||||
</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 className="flex items-center justify-center py-6 text-sm text-grayScale-400">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* App Ratings */}
|
||||
<Card className="shadow-none">
|
||||
|
|
|
|||
|
|
@ -39,7 +39,13 @@ import { Badge } from "../../components/ui/badge"
|
|||
import { Button } from "../../components/ui/button"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { getDashboard } from "../../api/analytics.api"
|
||||
import type { DashboardData, LabelCount } from "../../types/analytics.types"
|
||||
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"]
|
||||
|
||||
|
|
@ -285,18 +291,21 @@ function Section({
|
|||
)
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
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)
|
||||
setError(false)
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
setDashboard(res.data as unknown as DashboardData)
|
||||
const res = await getDashboard(nextFilters)
|
||||
setDashboard(res.data)
|
||||
} catch {
|
||||
setError(true)
|
||||
} finally {
|
||||
|
|
@ -305,10 +314,11 @@ export function AnalyticsPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
fetchData(filters)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters])
|
||||
|
||||
if (loading) {
|
||||
if (!dashboard && loading) {
|
||||
return (
|
||||
<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>
|
||||
|
|
@ -323,11 +333,14 @@ export function AnalyticsPage() {
|
|||
if (error || !dashboard) {
|
||||
return (
|
||||
<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">
|
||||
<img src={alertSrc} alt="" className="h-12 w-12" />
|
||||
<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" />
|
||||
Retry
|
||||
</Button>
|
||||
|
|
@ -337,6 +350,9 @@ export function AnalyticsPage() {
|
|||
}
|
||||
|
||||
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) => ({
|
||||
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>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||
<RefreshCw className="mr-2 h-3.5 w-3.5" />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs text-grayScale-400">
|
||||
{getDashboardFilterLabel(filters)} · Generated {generatedAt}
|
||||
</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
|
||||
</Button>
|
||||
</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 */}
|
||||
<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">
|
||||
|
|
@ -483,7 +509,7 @@ export function AnalyticsPage() {
|
|||
<Section
|
||||
title="Content & Platform"
|
||||
icon={BookOpen}
|
||||
count={courses.total_courses + content.total_questions}
|
||||
count={courses.total_videos + content.total_questions}
|
||||
defaultOpen
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
|
@ -491,28 +517,29 @@ export function AnalyticsPage() {
|
|||
icon={FolderOpen}
|
||||
label="Categories"
|
||||
value={courses.total_categories.toLocaleString()}
|
||||
sub={`${courses.total_courses} courses`}
|
||||
sub={`${courses.total_courses} courses · ${courses.total_sub_courses} modules`}
|
||||
trend="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={BookOpen}
|
||||
label="Sub-Courses"
|
||||
value={courses.total_sub_courses.toLocaleString()}
|
||||
sub={`across ${courses.total_courses} courses`}
|
||||
label="LMS Programs"
|
||||
value={(lms?.programs ?? 0).toLocaleString()}
|
||||
sub={`${lms?.courses ?? 0} courses · ${lms?.practices ?? 0} practices`}
|
||||
trend="neutral"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Video}
|
||||
label="Videos"
|
||||
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
|
||||
icon={HelpCircle}
|
||||
label="Questions"
|
||||
value={content.total_questions.toLocaleString()}
|
||||
sub={`${content.total_question_sets} question sets`}
|
||||
trend="neutral"
|
||||
sub={getPrimaryQuestionTypeSummary(content.questions_by_type)}
|
||||
trend={content.total_questions > 0 ? "up" : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
@ -573,7 +600,7 @@ export function AnalyticsPage() {
|
|||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">Last 30 Days</Badge>
|
||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px] p-6 pt-2">
|
||||
|
|
@ -603,10 +630,10 @@ export function AnalyticsPage() {
|
|||
</Card>
|
||||
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
|
||||
<BreakdownList title="Users by 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 Age Group" data={users.by_age_group} total={users.total_users} />
|
||||
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
|
||||
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
|
@ -625,7 +652,7 @@ export function AnalyticsPage() {
|
|||
+{subscriptions.new_today} today · +{subscriptions.new_week} this week
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">Last 30 Days</Badge>
|
||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[240px] p-6 pt-2">
|
||||
|
|
@ -664,7 +691,7 @@ export function AnalyticsPage() {
|
|||
</div>
|
||||
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
|
||||
</div>
|
||||
<Badge variant="secondary">Last 30 Days</Badge>
|
||||
<Badge variant="secondary">{seriesPeriodLabel}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[240px] p-6 pt-2">
|
||||
|
|
@ -728,6 +755,43 @@ export function AnalyticsPage() {
|
|||
</div>
|
||||
</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 ─── */}
|
||||
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ export function NotificationsPage() {
|
|||
|
||||
if (needsUsers) {
|
||||
tasks.push(
|
||||
getUsers(1, 20)
|
||||
getUsers({ page: 1, page_size: 20 })
|
||||
.then(async (res) => {
|
||||
const firstBatch = res.data?.data?.users ?? []
|
||||
const total = res.data?.data?.total ?? firstBatch.length
|
||||
|
|
@ -376,7 +376,7 @@ export function NotificationsPage() {
|
|||
|
||||
const remainingRequests: Array<ReturnType<typeof getUsers>> = []
|
||||
for (let page = 2; page <= totalPages; page += 1) {
|
||||
remainingRequests.push(getUsers(page, pageSize))
|
||||
remainingRequests.push(getUsers({ page, page_size: pageSize }))
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { Separator } from "../../components/ui/separator";
|
|||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useUsersStore } from "../../zustand/userStore";
|
||||
import { getUserById } from "../../api/users.api";
|
||||
import { getUserById, getUserRecentActivity } from "../../api/users.api";
|
||||
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
|
||||
import {
|
||||
getAdminLearnerCourseProgress,
|
||||
|
|
@ -42,12 +42,48 @@ import { Select } from "../../components/ui/select";
|
|||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.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,
|
||||
started: PlayCircle,
|
||||
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 };
|
||||
|
||||
|
|
@ -62,6 +98,8 @@ export function UserDetailPage() {
|
|||
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
|
||||
const [loadingProgress, setLoadingProgress] = useState(false);
|
||||
const [progressError, setProgressError] = useState<string | null>(null);
|
||||
const [recentActivityItems, setRecentActivityItems] = useState<UserRecentActivityItem[]>([]);
|
||||
const [recentActivityLoading, setRecentActivityLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
|
@ -149,6 +187,28 @@ export function UserDetailPage() {
|
|||
loadProgress();
|
||||
}, [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(() => {
|
||||
if (progressSummary) {
|
||||
return {
|
||||
|
|
@ -198,13 +258,6 @@ export function UserDetailPage() {
|
|||
const fullName = `${user.first_name} ${user.last_name}`;
|
||||
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 = [
|
||||
{ icon: Phone, label: "Phone", value: user.phone_number },
|
||||
{ icon: Mail, label: "Email", value: user.email },
|
||||
|
|
@ -566,37 +619,49 @@ export function UserDetailPage() {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative space-y-0">
|
||||
{recentActivities.map((activity, index) => {
|
||||
const Icon = activityIcons[activity.type] ?? CheckCircle2;
|
||||
const isLast = index === recentActivities.length - 1;
|
||||
return (
|
||||
<div key={index} className="relative flex gap-4 pb-5 last:pb-0">
|
||||
{!isLast && (
|
||||
<div className="absolute left-[15px] top-8 bottom-0 w-px bg-grayScale-200" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
activity.type === "completed"
|
||||
? "bg-mint-100 text-mint-500"
|
||||
: activity.type === "started"
|
||||
? "bg-brand-100/50 text-brand-500"
|
||||
: "bg-grayScale-100 text-grayScale-400"
|
||||
{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">
|
||||
{recentActivityItems.map((item, index) => {
|
||||
const vk = visualActivityKind(item.kind);
|
||||
const Icon = activityIcons[vk];
|
||||
const isLast = index === recentActivityItems.length - 1;
|
||||
return (
|
||||
<div key={item.id} className="relative flex gap-4 pb-5 last:pb-0">
|
||||
{!isLast && (
|
||||
<div className="absolute bottom-0 left-[15px] top-8 w-px bg-grayScale-200" />
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<div className="text-sm font-medium text-grayScale-600">
|
||||
{activity.text}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
vk === "completed"
|
||||
? "bg-mint-100 text-mint-500"
|
||||
: vk === "started"
|
||||
? "bg-brand-100/50 text-brand-500"
|
||||
: vk === "joined"
|
||||
? "bg-grayScale-100 text-grayScale-400"
|
||||
: "bg-grayScale-100 text-grayScale-500",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<div className="text-sm font-medium text-grayScale-600">{item.headline}</div>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 { useNavigate } from "react-router-dom"
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { getDashboard } from "../../api/analytics.api"
|
||||
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
|
||||
import type { DashboardUsers } from "../../types/analytics.types"
|
||||
import { mapUserApiToUser } from "../../types/user.types"
|
||||
import { useUsersStore } from "../../zustand/userStore"
|
||||
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() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -36,19 +131,45 @@ export function UsersListPage() {
|
|||
} | null>(null)
|
||||
const [roleFilter, setRoleFilter] = 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 [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(() => {
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getUsers(
|
||||
const res = await getUsers({
|
||||
page,
|
||||
pageSize,
|
||||
roleFilter || undefined,
|
||||
statusFilter || undefined,
|
||||
search || undefined,
|
||||
)
|
||||
page_size: pageSize,
|
||||
role: roleFilter || undefined,
|
||||
status: statusFilter || 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 mapped = apiUsers.map(mapUserApiToUser)
|
||||
|
|
@ -64,13 +185,30 @@ export function UsersListPage() {
|
|||
console.error("Failed to fetch users:", error)
|
||||
setUsers([])
|
||||
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 {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 safePage = Math.min(page, pageCount)
|
||||
|
|
@ -99,7 +237,10 @@ export function UsersListPage() {
|
|||
const allSelected = users.length > 0 && selectedIds.size === users.length
|
||||
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
|
||||
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 pages: (number | string)[] = []
|
||||
|
|
@ -168,6 +309,29 @@ export function UsersListPage() {
|
|||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
|
|
@ -176,35 +340,67 @@ export function UsersListPage() {
|
|||
<p className="text-sm text-grayScale-400">View and manage all registered users.</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards (match UserLogPage approach) */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-grayScale-600">{total}</p>
|
||||
<p className="text-xs text-grayScale-400">Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
|
||||
<UserCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-grayScale-600">{activeUsersOnPage}</p>
|
||||
<p className="text-xs text-grayScale-400">Active In Current Page</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
|
||||
<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>
|
||||
{/* Platform-wide user summary (same metrics as former User Management dashboard) */}
|
||||
<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">
|
||||
{userSummaryLoading ? (
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
) : userSummary ? (
|
||||
formatSummaryNum(userSummary.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">
|
||||
{userSummaryLoading ? (
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
) : activeUsersTotal !== null ? (
|
||||
formatSummaryNum(activeUsersTotal)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</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">
|
||||
{userSummaryLoading ? (
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
) : userSummary ? (
|
||||
formatSummaryNum(userSummary.new_month)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border">
|
||||
|
|
@ -225,7 +421,10 @@ export function UsersListPage() {
|
|||
<div className="relative w-full sm:w-auto">
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">All roles</option>
|
||||
|
|
@ -239,7 +438,10 @@ export function UsersListPage() {
|
|||
<div className="relative w-full sm:w-auto">
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
|
|
@ -252,6 +454,96 @@ export function UsersListPage() {
|
|||
</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>
|
||||
|
||||
{/* Table */}
|
||||
|
|
@ -267,10 +559,11 @@ export function UsersListPage() {
|
|||
/>
|
||||
</TableHead>
|
||||
<TableHead>USER</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Role</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Phone</TableHead>
|
||||
<TableHead className="hidden md:table-cell min-w-[10rem]">Contact details</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Country</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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -278,13 +571,13 @@ export function UsersListPage() {
|
|||
<TableBody>
|
||||
{loading ? (
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<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 h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
|
||||
<Users className="h-7 w-7 text-grayScale-400" />
|
||||
|
|
@ -322,16 +615,31 @@ export function UsersListPage() {
|
|||
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium text-grayScale-600">{u.firstName} {u.lastName}</div>
|
||||
<div className="text-xs text-grayScale-400">{u.email || u.phoneNumber || "-"}</div>
|
||||
<div className="font-medium text-grayScale-600">
|
||||
{u.firstName} {u.lastName}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.role || "-"}</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell>
|
||||
<TableCell className="hidden md:table-cell align-top">
|
||||
{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.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()}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -52,11 +52,34 @@ export interface DashboardPayments {
|
|||
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 {
|
||||
total_categories: number
|
||||
total_courses: number
|
||||
total_sub_courses: number
|
||||
total_videos: number
|
||||
lms?: DashboardCoursesLms
|
||||
exam_prep?: DashboardCoursesExamPrep
|
||||
}
|
||||
|
||||
export interface DashboardContent {
|
||||
|
|
@ -88,8 +111,34 @@ export interface DashboardTeam {
|
|||
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 {
|
||||
generated_at: string
|
||||
date_filter?: DashboardDateFilter
|
||||
users: DashboardUsers
|
||||
subscriptions: DashboardSubscriptions
|
||||
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 {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
gender: string
|
||||
birth_day: string | null
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
gender?: string
|
||||
birth_day?: string | null
|
||||
|
||||
email: string
|
||||
email?: string
|
||||
phone_number?: string
|
||||
role: string
|
||||
|
||||
age_group: string
|
||||
education_level: string
|
||||
country: string
|
||||
region: string
|
||||
age_group?: string
|
||||
education_level?: string
|
||||
country?: string
|
||||
region?: string
|
||||
|
||||
nick_name: string
|
||||
occupation: string
|
||||
learning_goal: string
|
||||
language_goal: string
|
||||
language_challange: string
|
||||
favoutite_topic: string
|
||||
nick_name?: string
|
||||
occupation?: string
|
||||
learning_goal?: string
|
||||
language_goal?: string
|
||||
language_challange?: string
|
||||
favoutite_topic?: string
|
||||
|
||||
email_verified: boolean
|
||||
phone_verified: boolean
|
||||
email_verified?: boolean
|
||||
phone_verified?: boolean
|
||||
status: string
|
||||
|
||||
profile_completed: boolean
|
||||
profile_picture_url: string
|
||||
preferred_language: string
|
||||
profile_completed?: boolean
|
||||
profile_picture_url?: string
|
||||
preferred_language?: string
|
||||
profile_completion_percentage?: number
|
||||
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
/** Billing / plan state for list UI (e.g. "Unsubscribed", "Active"). */
|
||||
subscription_status?: string
|
||||
}
|
||||
|
||||
export interface GetUsersResponse {
|
||||
|
|
@ -55,20 +59,26 @@ export interface User {
|
|||
country: string
|
||||
lastLogin: string | null
|
||||
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 => ({
|
||||
id: u.id,
|
||||
firstName: u.first_name,
|
||||
lastName: u.last_name,
|
||||
nickName: u.nick_name,
|
||||
email: u.email,
|
||||
firstName: u.first_name ?? "",
|
||||
lastName: u.last_name ?? "",
|
||||
nickName: u.nick_name ?? "",
|
||||
email: u.email ?? "",
|
||||
phoneNumber: u.phone_number ?? "",
|
||||
role: u.role,
|
||||
region: u.region,
|
||||
country: u.country,
|
||||
region: u.region ?? "",
|
||||
country: u.country ?? "",
|
||||
lastLogin: null,
|
||||
status: u.status,
|
||||
subscriptionStatus: u.subscription_status?.trim() ? u.subscription_status.trim() : "—",
|
||||
createdAt: u.created_at ?? "",
|
||||
})
|
||||
|
||||
export interface UserProfileData {
|
||||
|
|
@ -115,6 +125,27 @@ export interface UserProfileResponse {
|
|||
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 {
|
||||
total_users: number
|
||||
active_users: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user