UAT fixes stage 1

This commit is contained in:
Yared Yemane 2026-05-18 08:44:51 -07:00
parent 2b556d9d09
commit 385f58fd22
17 changed files with 1622 additions and 437 deletions

View File

@ -1,5 +1,39 @@
import http from "./http"; import http from "./http";
import type { DashboardResponse } from "../types/analytics.types"; import type { DashboardData, DashboardFilters, DashboardResponse } from "../types/analytics.types";
export const getDashboard = () => function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
http.get<DashboardResponse>("/analytics/dashboard"); if (!filters || filters.mode === "all_time") {
return {};
}
if (filters.mode === "year" && filters.year != null) {
return { year: filters.year };
}
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
return { year: filters.year, month: filters.month };
}
if (filters.mode === "custom" && filters.from && filters.to) {
return { from: filters.from, to: filters.to };
}
return {};
}
function unwrapDashboardResponse(body: DashboardResponse | DashboardData): DashboardData {
if (body && typeof body === "object" && "data" in body && body.data) {
return body.data;
}
return body as DashboardData;
}
export const getDashboard = (filters?: DashboardFilters) =>
http
.get<DashboardResponse | DashboardData>("/analytics/dashboard", {
params: buildDashboardQueryParams(filters),
})
.then((res) => ({
...res,
data: unwrapDashboardResponse(res.data),
}));

View 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[]),
},
}))

View File

@ -6,23 +6,46 @@ import {
type UserSummaryResponse, type UserSummaryResponse,
type GetDeletionRequestsParams, type GetDeletionRequestsParams,
type GetDeletionRequestsResponse, type GetDeletionRequestsResponse,
type UserRecentActivityResponse,
} from "../types/user.types"; } from "../types/user.types";
export const getUsers = ( /** Query params for GET /users (RFC3339 for created_*; subscription_status: ACTIVE | PENDING | Unsubscribed). */
page?: number, export interface GetUsersParams {
pageSize?: number, page?: number
role?: string, page_size?: number
status?: string, role?: string
query?: string, status?: string
) => query?: string
created_before?: string
created_after?: string
country?: string
region?: string
subscription_status?: string
}
function buildGetUsersQuery(params: GetUsersParams): Record<string, string | number> {
const q: Record<string, string | number> = {}
const addString = (key: string, value: string | undefined) => {
const v = value?.trim()
if (!v) return
q[key] = v
}
if (params.page !== undefined) q.page = params.page
if (params.page_size !== undefined) q.page_size = params.page_size
addString("role", params.role)
addString("status", params.status)
addString("query", params.query)
addString("created_before", params.created_before)
addString("created_after", params.created_after)
addString("country", params.country)
addString("region", params.region)
addString("subscription_status", params.subscription_status)
return q
}
export const getUsers = (params: GetUsersParams = {}) =>
http.get<GetUsersResponse>("/users", { http.get<GetUsersResponse>("/users", {
params: { params: buildGetUsersQuery(params),
role,
status,
query,
page,
page_size: pageSize,
},
}); });
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING"; export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
@ -38,6 +61,9 @@ export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
export const getUserById = (id: number) => export const getUserById = (id: number) =>
http.get<UserProfileResponse>(`/user/single/${id}`); http.get<UserProfileResponse>(`/user/single/${id}`);
export const getUserRecentActivity = (userId: number) =>
http.get<UserRecentActivityResponse>(`/admin/users/${userId}/recent-activity`);
export const getMyProfile = () => export const getMyProfile = () =>
http.get<UserProfileResponse>("/team/me"); http.get<UserProfileResponse>("/team/me");

View File

@ -37,7 +37,6 @@ import { CreateNotificationPage } from "../pages/notifications/CreateNotificatio
import { UserDetailPage } from "../pages/user-management/UserDetailPage"; import { UserDetailPage } from "../pages/user-management/UserDetailPage";
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"; import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
import { UsersListPage } from "../pages/user-management/UsersListPage"; import { UsersListPage } from "../pages/user-management/UsersListPage";
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage"; import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage"; import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout"; import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
@ -78,7 +77,7 @@ export function AppRoutes() {
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users" element={<UserManagementLayout />}> <Route path="/users" element={<UserManagementLayout />}>
<Route index element={<UserManagementDashboard />} /> <Route index element={<Navigate to="list" replace />} />
<Route path="list" element={<UsersListPage />} /> <Route path="list" element={<UsersListPage />} />
<Route path="deletion-requests" element={<DeletionRequestsPage />} /> <Route path="deletion-requests" element={<DeletionRequestsPage />} />
<Route path="groups" element={<UserGroupsPage />} /> <Route path="groups" element={<UserGroupsPage />} />

View 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>
)
}

View 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>
)
}

View File

@ -0,0 +1,228 @@
/**
* Static options for GET /users filters (`country`, `region`).
* Country: common English short names (ISO-style), sorted AZ.
* 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, AZ (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
View 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"
}
}

View File

@ -1,7 +1,7 @@
import { import {
// Activity, // Activity,
BadgeCheck, BadgeCheck,
BookOpen, Video,
// Coins, // Coins,
DollarSign, DollarSign,
HelpCircle, HelpCircle,
@ -11,14 +11,13 @@ import {
// TrendingUp, // TrendingUp,
Users, Users,
Bell, Bell,
CreditCard,
UsersRound, UsersRound,
} from "lucide-react" } from "lucide-react"
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
import { import {
Area, Area,
AreaChart, AreaChart,
Bar,
BarChart,
CartesianGrid, CartesianGrid,
Cell, Cell,
Pie, Pie,
@ -28,15 +27,21 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts" } from "recharts"
import { RevenueTrendCard } from "../components/dashboard/RevenueTrendCard"
import { StatCard } from "../components/dashboard/StatCard" import { StatCard } from "../components/dashboard/StatCard"
import alertSrc from "../assets/Alert.svg" import alertSrc from "../assets/Alert.svg"
import { Badge } from "../components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { cn } from "../lib/utils" import { cn } from "../lib/utils"
import { getTeamMemberById } from "../api/team.api" import { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api" import { getDashboard } from "../api/analytics.api"
import { getSubscriptionPlans } from "../api/subscription-plans.api"
import { getRatings } from "../api/courses.api" import { getRatings } from "../api/courses.api"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import type { DashboardData } from "../types/analytics.types" import { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
import { getPrimaryQuestionTypeSummary, getSeriesPeriodLabel, getVideoLessonsSummary } from "../lib/analytics"
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
import type { SubscriptionPlan } from "../types/subscription.types"
import type { Rating } from "../types/course.types" import type { Rating } from "../types/course.types"
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"] const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
@ -46,6 +51,19 @@ function formatDate(dateStr: string) {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
} }
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
function formatPlanDuration(plan: SubscriptionPlan): string {
const v = plan.duration_value
const u = plan.duration_unit.toUpperCase()
const word =
u === "MONTH" ? "month" : u === "YEAR" ? "year" : u === "WEEK" ? "week" : u === "DAY" ? "day" : plan.duration_unit
if (u === "MONTH" || u === "YEAR" || u === "WEEK" || u === "DAY") {
return `${v} ${v === 1 ? word : `${word}s`}`
}
return `${v} ${word}`
}
export function DashboardPage() { export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("") const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
@ -53,6 +71,9 @@ export function DashboardPage() {
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary") const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
const [appRatings, setAppRatings] = useState<Rating[]>([]) const [appRatings, setAppRatings] = useState<Rating[]>([])
const [appRatingsLoading, setAppRatingsLoading] = useState(true) const [appRatingsLoading, setAppRatingsLoading] = useState(true)
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([])
const [subscriptionPlansLoading, setSubscriptionPlansLoading] = useState(true)
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
@ -70,17 +91,10 @@ export function DashboardPage() {
} }
} }
const fetchDashboard = async () => { fetchUser()
try { }, [])
const res = await getDashboard()
setDashboard(res.data as unknown as DashboardData)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
useEffect(() => {
const fetchAppRatings = async () => { const fetchAppRatings = async () => {
try { try {
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 }) const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
@ -92,23 +106,49 @@ export function DashboardPage() {
} }
} }
fetchUser()
fetchDashboard()
fetchAppRatings() fetchAppRatings()
}, []) }, [])
useEffect(() => {
const fetchPlans = async () => {
setSubscriptionPlansLoading(true)
try {
const res = await getSubscriptionPlans()
setSubscriptionPlans(res.data.data)
} catch (err) {
console.error(err)
setSubscriptionPlans([])
} finally {
setSubscriptionPlansLoading(false)
}
}
fetchPlans()
}, [])
useEffect(() => {
const fetchDashboard = async () => {
setLoading(true)
try {
const res = await getDashboard(filters)
setDashboard(res.data)
} catch (err) {
console.error(err)
setDashboard(null)
} finally {
setLoading(false)
}
}
fetchDashboard()
}, [filters])
const registrationData = const registrationData =
dashboard?.users.registrations_last_30_days.map((d) => ({ dashboard?.users.registrations_last_30_days.map((d) => ({
date: formatDate(d.date), date: formatDate(d.date),
count: d.count, count: d.count,
})) ?? [] })) ?? []
const revenueData =
dashboard?.payments.revenue_last_30_days.map((d) => ({
date: formatDate(d.date),
revenue: d.revenue,
})) ?? []
const subscriptionStatusData = const subscriptionStatusData =
dashboard?.subscriptions.by_status.map((s, i) => ({ dashboard?.subscriptions.by_status.map((s, i) => ({
name: s.label, name: s.label,
@ -123,9 +163,14 @@ export function DashboardPage() {
color: PIE_COLORS[i % PIE_COLORS.length], color: PIE_COLORS[i % PIE_COLORS.length],
})) ?? [] })) ?? []
const seriesPeriodLabel = dashboard ? getSeriesPeriodLabel(dashboard.date_filter) : "Last 30 Days"
return ( return (
<div className="mx-auto w-full max-w-6xl"> <div className="mx-auto w-full max-w-6xl">
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div> <div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold text-grayScale-500">Dashboard</div>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
</div>
<div className="mb-5 text-2xl font-semibold tracking-tight"> <div className="mb-5 text-2xl font-semibold tracking-tight">
Welcome, {userFirstName || localStorage.getItem("user_first_name")} Welcome, {userFirstName || localStorage.getItem("user_first_name")}
</div> </div>
@ -216,18 +261,21 @@ export function DashboardPage() {
{activeStatTab === "secondary" && ( {activeStatTab === "secondary" && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
icon={BookOpen} icon={Video}
label="Courses" label="Videos"
value={dashboard.courses.total_courses.toLocaleString()} value={dashboard.courses.total_videos.toLocaleString()}
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`} deltaLabel={getVideoLessonsSummary(
deltaPositive dashboard.courses.lms?.lessons_with_video,
dashboard.courses.exam_prep?.lessons_with_video,
)}
deltaPositive={dashboard.courses.total_videos > 0}
/> />
<StatCard <StatCard
icon={HelpCircle} icon={HelpCircle}
label="Questions" label="Questions"
value={dashboard.content.total_questions.toLocaleString()} value={dashboard.content.total_questions.toLocaleString()}
deltaLabel={`${dashboard.content.total_question_sets} question sets`} deltaLabel={getPrimaryQuestionTypeSummary(dashboard.content.questions_by_type)}
deltaPositive deltaPositive={dashboard.content.total_questions > 0}
/> />
<StatCard <StatCard
icon={Bell} icon={Bell}
@ -261,7 +309,7 @@ export function DashboardPage() {
</div> </div>
</div> </div>
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500"> <div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
Last 30 Days {seriesPeriodLabel}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@ -357,76 +405,69 @@ export function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Revenue Chart */} <RevenueTrendCard />
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle>Revenue Trend</CardTitle>
<div className="mt-2 text-2xl font-semibold tracking-tight">
ETB {dashboard.payments.total_revenue.toLocaleString()}
</div>
<div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
</div>
</div>
</CardHeader>
<CardContent className="h-[220px] p-6 pt-2">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
<Tooltip
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}}
/>
<Bar dataKey="revenue" radius={[10, 10, 0, 0]} fill="#9E2891" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div> </div>
{/* Users by Role / Region / Knowledge Level */} {/* Subscription plans (from catalog API) */}
<div className="grid gap-4 lg:grid-cols-3"> <Card className="shadow-none">
{[ <CardHeader className="pb-2">
{ title: "Users by Role", data: dashboard.users.by_role }, <div className="flex items-center gap-2">
{ title: "Users by Region", data: dashboard.users.by_region }, <CreditCard className="h-5 w-5 text-brand-500" />
{ title: "Users by Knowledge Level", data: dashboard.users.by_knowledge_level }, <CardTitle>Subscription plans</CardTitle>
].map(({ title, data }) => ( </div>
<Card key={title} className="shadow-none"> <p className="text-sm text-grayScale-500">Available billing plans for learners.</p>
<CardHeader className="pb-2"> </CardHeader>
<CardTitle>{title}</CardTitle> <CardContent className="p-6 pt-2">
</CardHeader> {subscriptionPlansLoading ? (
<CardContent className="p-6 pt-2"> <div className="flex items-center justify-center py-10">
{data.length > 0 ? ( <img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
<div className="space-y-3"> </div>
{data.map((item, i) => ( ) : subscriptionPlans.length === 0 ? (
<div key={item.label} className="flex items-center justify-between gap-3 text-sm"> <div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
<div className="flex items-center gap-2"> No subscription plans found
<span </div>
className="h-2.5 w-2.5 rounded-full" ) : (
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }} <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
/> {subscriptionPlans.map((plan) => (
<span className="text-grayScale-600">{item.label}</span> <div
key={plan.id}
className="flex flex-col rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-4"
>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-grayScale-700">{plan.name}</h3>
<Badge variant={plan.is_active ? "success" : "secondary"}>
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</div>
{plan.description ? (
<p className="mt-2 line-clamp-2 text-sm text-grayScale-500">{plan.description}</p>
) : null}
<div className="mt-4 flex flex-wrap items-end justify-between gap-2 border-t border-grayScale-200 pt-4">
<div>
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">Price</div>
<div className="text-lg font-semibold text-brand-600">
{plan.currency}{" "}
{Number.isInteger(plan.price)
? plan.price.toLocaleString()
: plan.price.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}
</div> </div>
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
</div> </div>
))} <div className="text-right">
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">
Billing
</div>
<div className="text-sm font-semibold text-grayScale-600">{formatPlanDuration(plan)}</div>
</div>
</div>
</div> </div>
) : ( ))}
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400"> </div>
No data available )}
</div> </CardContent>
)} </Card>
</CardContent>
</Card>
))}
</div>
{/* App Ratings */} {/* App Ratings */}
<Card className="shadow-none"> <Card className="shadow-none">

View File

@ -39,7 +39,13 @@ import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { getDashboard } from "../../api/analytics.api" import { getDashboard } from "../../api/analytics.api"
import type { DashboardData, LabelCount } from "../../types/analytics.types" import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../components/analytics/AnalyticsTimeRangeFilter"
import {
getPrimaryQuestionTypeSummary,
getSeriesPeriodLabel,
getVideoLessonsSummary,
} from "../../lib/analytics"
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"] const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
@ -285,18 +291,21 @@ function Section({
) )
} }
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
export function AnalyticsPage() { export function AnalyticsPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key") const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
const fetchData = async () => { const fetchData = async (nextFilters: DashboardFilters = filters) => {
setLoading(true) setLoading(true)
setError(false) setError(false)
try { try {
const res = await getDashboard() const res = await getDashboard(nextFilters)
setDashboard(res.data as unknown as DashboardData) setDashboard(res.data)
} catch { } catch {
setError(true) setError(true)
} finally { } finally {
@ -305,10 +314,11 @@ export function AnalyticsPage() {
} }
useEffect(() => { useEffect(() => {
fetchData() fetchData(filters)
}, []) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters])
if (loading) { if (!dashboard && loading) {
return ( return (
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4"> <div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
@ -323,11 +333,14 @@ export function AnalyticsPage() {
if (error || !dashboard) { if (error || !dashboard) {
return ( return (
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4"> <div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
</div>
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24"> <div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24">
<img src={alertSrc} alt="" className="h-12 w-12" /> <img src={alertSrc} alt="" className="h-12 w-12" />
<span className="text-sm text-destructive">Failed to load analytics data.</span> <span className="text-sm text-destructive">Failed to load analytics data.</span>
<Button variant="outline" size="sm" onClick={fetchData}> <Button variant="outline" size="sm" onClick={() => fetchData(filters)}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Retry Retry
</Button> </Button>
@ -337,6 +350,9 @@ export function AnalyticsPage() {
} }
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
const seriesPeriodLabel = getSeriesPeriodLabel(dashboard.date_filter)
const lms = courses.lms
const examPrep = courses.exam_prep
const registrationData = users.registrations_last_30_days.map((d) => ({ const registrationData = users.registrations_last_30_days.map((d) => ({
date: formatDate(d.date), date: formatDate(d.date),
@ -387,15 +403,25 @@ export function AnalyticsPage() {
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1> <h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span> <span className="text-xs text-grayScale-400">
<Button variant="outline" size="sm" onClick={fetchData}> {getDashboardFilterLabel(filters)} · Generated {generatedAt}
<RefreshCw className="mr-2 h-3.5 w-3.5" /> </span>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
<Button variant="outline" size="sm" onClick={() => fetchData(filters)} disabled={loading}>
<RefreshCw className={cn("mr-2 h-3.5 w-3.5", loading && "animate-spin")} />
Refresh Refresh
</Button> </Button>
</div> </div>
</div> </div>
{loading && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500">
<img src={spinnerSrc} alt="" className="h-4 w-4 animate-spin" />
Updating analytics for {getDashboardFilterLabel(filters)}
</div>
)}
{/* Summary Tabs */} {/* Summary Tabs */}
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm"> <div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm">
<div className="-mb-px flex gap-6"> <div className="-mb-px flex gap-6">
@ -483,7 +509,7 @@ export function AnalyticsPage() {
<Section <Section
title="Content & Platform" title="Content & Platform"
icon={BookOpen} icon={BookOpen}
count={courses.total_courses + content.total_questions} count={courses.total_videos + content.total_questions}
defaultOpen defaultOpen
> >
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -491,28 +517,29 @@ export function AnalyticsPage() {
icon={FolderOpen} icon={FolderOpen}
label="Categories" label="Categories"
value={courses.total_categories.toLocaleString()} value={courses.total_categories.toLocaleString()}
sub={`${courses.total_courses} courses`} sub={`${courses.total_courses} courses · ${courses.total_sub_courses} modules`}
trend="neutral" trend="neutral"
/> />
<KpiCard <KpiCard
icon={BookOpen} icon={BookOpen}
label="Sub-Courses" label="LMS Programs"
value={courses.total_sub_courses.toLocaleString()} value={(lms?.programs ?? 0).toLocaleString()}
sub={`across ${courses.total_courses} courses`} sub={`${lms?.courses ?? 0} courses · ${lms?.practices ?? 0} practices`}
trend="neutral" trend="neutral"
/> />
<KpiCard <KpiCard
icon={Video} icon={Video}
label="Videos" label="Videos"
value={courses.total_videos.toLocaleString()} value={courses.total_videos.toLocaleString()}
trend="neutral" sub={getVideoLessonsSummary(lms?.lessons_with_video, examPrep?.lessons_with_video)}
trend={courses.total_videos > 0 ? "up" : "neutral"}
/> />
<KpiCard <KpiCard
icon={HelpCircle} icon={HelpCircle}
label="Questions" label="Questions"
value={content.total_questions.toLocaleString()} value={content.total_questions.toLocaleString()}
sub={`${content.total_question_sets} question sets`} sub={getPrimaryQuestionTypeSummary(content.questions_by_type)}
trend="neutral" trend={content.total_questions > 0 ? "up" : "neutral"}
/> />
</div> </div>
</Section> </Section>
@ -573,7 +600,7 @@ export function AnalyticsPage() {
</Badge> </Badge>
</div> </div>
</div> </div>
<Badge variant="secondary">Last 30 Days</Badge> <Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-[280px] p-6 pt-2"> <CardContent className="h-[280px] p-6 pt-2">
@ -603,10 +630,10 @@ export function AnalyticsPage() {
</Card> </Card>
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} /> <BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
<BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} /> <BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} />
<BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} /> <BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} />
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
</div> </div>
</Section> </Section>
@ -625,7 +652,7 @@ export function AnalyticsPage() {
+{subscriptions.new_today} today · +{subscriptions.new_week} this week +{subscriptions.new_today} today · +{subscriptions.new_week} this week
</div> </div>
</div> </div>
<Badge variant="secondary">Last 30 Days</Badge> <Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-[240px] p-6 pt-2"> <CardContent className="h-[240px] p-6 pt-2">
@ -664,7 +691,7 @@ export function AnalyticsPage() {
</div> </div>
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div> <div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
</div> </div>
<Badge variant="secondary">Last 30 Days</Badge> <Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-[240px] p-6 pt-2"> <CardContent className="h-[240px] p-6 pt-2">
@ -728,6 +755,43 @@ export function AnalyticsPage() {
</div> </div>
</Section> </Section>
{/* ─── Course Management ─── */}
{(lms || examPrep) && (
<Section title="Course Management" icon={BookOpen} count={courses.total_videos} defaultOpen={false}>
<div className="grid items-start gap-4 lg:grid-cols-2">
{lms && (
<BreakdownList
title="LMS"
data={[
{ label: "Programs", count: lms.programs },
{ label: "Courses", count: lms.courses },
{ label: "Modules", count: lms.modules },
{ label: "Lessons", count: lms.lessons },
{ label: "Lessons with video", count: lms.lessons_with_video },
{ label: "Practices", count: lms.practices },
{ label: "Practices at course", count: lms.practices_at_course },
{ label: "Practices at module", count: lms.practices_at_module },
{ label: "Practices at lesson", count: lms.practices_at_lesson },
]}
/>
)}
{examPrep && (
<BreakdownList
title="Exam prep"
data={[
{ label: "Catalog courses", count: examPrep.catalog_courses },
{ label: "Units", count: examPrep.units },
{ label: "Unit modules", count: examPrep.unit_modules },
{ label: "Lessons", count: examPrep.lessons },
{ label: "Lessons with video", count: examPrep.lessons_with_video },
{ label: "Lesson practices", count: examPrep.lesson_practices },
]}
/>
)}
</div>
</Section>
)}
{/* ─── Content Breakdown ─── */} {/* ─── Content Breakdown ─── */}
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}> <Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
<div className="grid items-start gap-4 sm:grid-cols-2"> <div className="grid items-start gap-4 sm:grid-cols-2">

View File

@ -363,7 +363,7 @@ export function NotificationsPage() {
if (needsUsers) { if (needsUsers) {
tasks.push( tasks.push(
getUsers(1, 20) getUsers({ page: 1, page_size: 20 })
.then(async (res) => { .then(async (res) => {
const firstBatch = res.data?.data?.users ?? [] const firstBatch = res.data?.data?.users ?? []
const total = res.data?.data?.total ?? firstBatch.length const total = res.data?.data?.total ?? firstBatch.length
@ -376,7 +376,7 @@ export function NotificationsPage() {
const remainingRequests: Array<ReturnType<typeof getUsers>> = [] const remainingRequests: Array<ReturnType<typeof getUsers>> = []
for (let page = 2; page <= totalPages; page += 1) { for (let page = 2; page <= totalPages; page += 1) {
remainingRequests.push(getUsers(page, pageSize)) remainingRequests.push(getUsers({ page, page_size: pageSize }))
} }
try { try {

View File

@ -24,7 +24,7 @@ import { Separator } from "../../components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { useUsersStore } from "../../zustand/userStore"; import { useUsersStore } from "../../zustand/userStore";
import { getUserById } from "../../api/users.api"; import { getUserById, getUserRecentActivity } from "../../api/users.api";
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api"; import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api";
import { import {
getAdminLearnerCourseProgress, getAdminLearnerCourseProgress,
@ -42,12 +42,48 @@ import { Select } from "../../components/ui/select";
import { SpinnerIcon } from "../../components/ui/spinner-icon"; import { SpinnerIcon } from "../../components/ui/spinner-icon";
import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types"; import type { LearnerCourseProgressItem, LearnerCourseProgressSummary } from "../../types/progress.types";
import type { Course } from "../../types/course.types"; import type { Course } from "../../types/course.types";
import type { UserRecentActivityItem } from "../../types/user.types";
const activityIcons: Record<string, typeof CheckCircle2> = { const activityIcons = {
completed: CheckCircle2, completed: CheckCircle2,
started: PlayCircle, started: PlayCircle,
joined: UserPlus, joined: UserPlus,
}; default: BookOpen,
} as const;
function visualActivityKind(kind: string): keyof typeof activityIcons {
const k = kind.toLowerCase();
if (k === "completed" || k === "complete") return "completed";
if (k === "started" || k === "start") return "started";
if (k === "joined" || k === "join") return "joined";
return "default";
}
/** Matches Recent Activity mock: "Today, 10:27 AM" / "Yesterday, 3:45 PM" / "Jan 10, 2025". */
function formatActivityOccurredAt(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const now = new Date();
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const dayDiff = Math.round((startToday - startThat) / 86_400_000);
const timePart = d.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
if (dayDiff === 0) return `Today, ${timePart}`;
if (dayDiff === 1) return `Yesterday, ${timePart}`;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
type CourseOption = Course & { category_name: string }; type CourseOption = Course & { category_name: string };
@ -62,6 +98,8 @@ export function UserDetailPage() {
const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null); const [progressSummary, setProgressSummary] = useState<LearnerCourseProgressSummary | null>(null);
const [loadingProgress, setLoadingProgress] = useState(false); const [loadingProgress, setLoadingProgress] = useState(false);
const [progressError, setProgressError] = useState<string | null>(null); const [progressError, setProgressError] = useState<string | null>(null);
const [recentActivityItems, setRecentActivityItems] = useState<UserRecentActivityItem[]>([]);
const [recentActivityLoading, setRecentActivityLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@ -149,6 +187,28 @@ export function UserDetailPage() {
loadProgress(); loadProgress();
}, [id, selectedProgressCourseId]); }, [id, selectedProgressCourseId]);
useEffect(() => {
if (!id) return;
const userId = Number(id);
if (Number.isNaN(userId)) return;
const loadRecent = async () => {
setRecentActivityLoading(true);
try {
const res = await getUserRecentActivity(userId);
const items = res.data?.data?.items ?? [];
setRecentActivityItems(items);
} catch (err) {
console.error("Failed to load recent activity", err);
setRecentActivityItems([]);
} finally {
setRecentActivityLoading(false);
}
};
loadRecent();
}, [id]);
const progressMetrics = useMemo(() => { const progressMetrics = useMemo(() => {
if (progressSummary) { if (progressSummary) {
return { return {
@ -198,13 +258,6 @@ export function UserDetailPage() {
const fullName = `${user.first_name} ${user.last_name}`; const fullName = `${user.first_name} ${user.last_name}`;
const initials = `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`.toUpperCase(); const initials = `${user.first_name?.[0] ?? ""}${user.last_name?.[0] ?? ""}`.toUpperCase();
const recentActivities = [
{ type: "completed", text: "Completed Unit 4: Business Emails", time: "Today, 10:27 AM" },
{ type: "completed", text: "Completed Unit 3: Formal Writing", time: "Yesterday, 3:45 PM" },
{ type: "started", text: "Started Learning Path: Business English", time: "Jan 15, 2025" },
{ type: "joined", text: "Joined Yimaru", time: "Jan 10, 2025" },
];
const infoFields = [ const infoFields = [
{ icon: Phone, label: "Phone", value: user.phone_number }, { icon: Phone, label: "Phone", value: user.phone_number },
{ icon: Mail, label: "Email", value: user.email }, { icon: Mail, label: "Email", value: user.email },
@ -566,37 +619,49 @@ export function UserDetailPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="relative space-y-0"> {recentActivityLoading ? (
{recentActivities.map((activity, index) => { <div className="flex items-center justify-center gap-2 py-10 text-sm text-grayScale-400">
const Icon = activityIcons[activity.type] ?? CheckCircle2; <SpinnerIcon className="h-5 w-5" />
const isLast = index === recentActivities.length - 1; Loading activity
return ( </div>
<div key={index} className="relative flex gap-4 pb-5 last:pb-0"> ) : recentActivityItems.length === 0 ? (
{!isLast && ( <p className="py-8 text-center text-sm text-grayScale-400">No recent activity yet.</p>
<div className="absolute left-[15px] top-8 bottom-0 w-px bg-grayScale-200" /> ) : (
)} <div className="relative space-y-0">
<div {recentActivityItems.map((item, index) => {
className={cn( const vk = visualActivityKind(item.kind);
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full", const Icon = activityIcons[vk];
activity.type === "completed" const isLast = index === recentActivityItems.length - 1;
? "bg-mint-100 text-mint-500" return (
: activity.type === "started" <div key={item.id} className="relative flex gap-4 pb-5 last:pb-0">
? "bg-brand-100/50 text-brand-500" {!isLast && (
: "bg-grayScale-100 text-grayScale-400" <div className="absolute bottom-0 left-[15px] top-8 w-px bg-grayScale-200" />
)} )}
> <div
<Icon className="h-4 w-4" /> className={cn(
</div> "relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
<div className="min-w-0 flex-1 pt-1"> vk === "completed"
<div className="text-sm font-medium text-grayScale-600"> ? "bg-mint-100 text-mint-500"
{activity.text} : 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>
<div className="mt-0.5 text-xs text-grayScale-400">{activity.time}</div>
</div> </div>
</div> );
); })}
})} </div>
</div> )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -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>
)
}

View File

@ -1,15 +1,110 @@
import { ChevronDown, ChevronLeft, ChevronRight, Search, UserCheck, Users, X } from "lucide-react" import { ChevronDown, ChevronLeft, ChevronRight, Search, TrendingUp, UserCheck, Users, X } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { getDashboard } from "../../api/analytics.api"
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api" import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
import type { DashboardUsers } from "../../types/analytics.types"
import { mapUserApiToUser } from "../../types/user.types" import { mapUserApiToUser } from "../../types/user.types"
import { useUsersStore } from "../../zustand/userStore" import { useUsersStore } from "../../zustand/userStore"
import { toast } from "sonner" import { toast } from "sonner"
import axios from "axios"
import { USER_FILTER_COUNTRIES, USER_FILTER_ETHIOPIA_REGIONS } from "../../data/userFilterLocations"
function formatJoinedAt(iso: string): string {
if (!iso?.trim()) return "—"
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return "—"
return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
}
/** Convert `<input type="datetime-local" />` value to RFC3339 for GET /users. */
function toRfc3339FromDatetimeLocal(value: string): string | undefined {
const t = value?.trim()
if (!t) return undefined
const d = new Date(t)
if (Number.isNaN(d.getTime())) return undefined
return d.toISOString()
}
/** Portaled menu — native `<select>` lists break inside `overflow-y-auto` shells (e.g. app main). */
function UserListFilterDropdown({
id,
label,
value,
allLabel,
options,
onSelect,
}: {
id: string
label: string
value: string
allLabel: string
options: readonly string[]
onSelect: (next: string) => void
}) {
return (
<div className="flex flex-col gap-1">
<label htmlFor={id} className="text-xs font-medium text-grayScale-500">
{label}
</label>
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild>
<button
type="button"
id={id}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-grayScale-200 bg-white px-3 text-left text-sm text-grayScale-600",
"outline-none focus-visible:ring-1 focus-visible:ring-brand-500",
)}
>
<span className="min-w-0 truncate">{value || allLabel}</span>
<ChevronDown className="h-4 w-4 shrink-0 text-grayScale-400" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="bottom"
align="start"
sideOffset={4}
collisionPadding={12}
className="z-[200] max-h-60 min-w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-md border border-grayScale-200 bg-white p-1 shadow-lg"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenu.Item
className={cn(
"cursor-pointer rounded px-2 py-2 text-sm text-grayScale-700 outline-none data-[highlighted]:bg-grayScale-100",
!value && "bg-grayScale-50 font-medium",
)}
onSelect={() => onSelect("")}
>
{allLabel}
</DropdownMenu.Item>
{options.map((opt) => (
<DropdownMenu.Item
key={opt}
className={cn(
"cursor-pointer rounded px-2 py-2 text-sm text-grayScale-700 outline-none data-[highlighted]:bg-grayScale-100",
value === opt && "bg-brand-50 font-medium text-brand-700",
)}
onSelect={() => onSelect(opt)}
>
{opt}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
)
}
export function UsersListPage() { export function UsersListPage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -36,19 +131,45 @@ export function UsersListPage() {
} | null>(null) } | null>(null)
const [roleFilter, setRoleFilter] = useState("") const [roleFilter, setRoleFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("") const [statusFilter, setStatusFilter] = useState("")
const [createdAfterLocal, setCreatedAfterLocal] = useState("")
const [createdBeforeLocal, setCreatedBeforeLocal] = useState("")
const [countryFilter, setCountryFilter] = useState("")
const [regionFilter, setRegionFilter] = useState("")
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [userSummary, setUserSummary] = useState<DashboardUsers | null>(null)
const [userSummaryLoading, setUserSummaryLoading] = useState(true)
useEffect(() => {
const fetchSummary = async () => {
try {
const res = await getDashboard()
setUserSummary(res.data.users ?? null)
} catch {
setUserSummary(null)
} finally {
setUserSummaryLoading(false)
}
}
fetchSummary()
}, [])
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await getUsers( const res = await getUsers({
page, page,
pageSize, page_size: pageSize,
roleFilter || undefined, role: roleFilter || undefined,
statusFilter || undefined, status: statusFilter || undefined,
search || undefined, query: search || undefined,
) created_after: toRfc3339FromDatetimeLocal(createdAfterLocal),
created_before: toRfc3339FromDatetimeLocal(createdBeforeLocal),
country: countryFilter.trim() || undefined,
region: regionFilter.trim() || undefined,
subscription_status: subscriptionStatusFilter || undefined,
})
const apiUsers = res.data.data.users const apiUsers = res.data.data.users
const mapped = apiUsers.map(mapUserApiToUser) const mapped = apiUsers.map(mapUserApiToUser)
@ -64,13 +185,30 @@ export function UsersListPage() {
console.error("Failed to fetch users:", error) console.error("Failed to fetch users:", error)
setUsers([]) setUsers([])
setTotal(0) setTotal(0)
const msg = axios.isAxiosError(error)
? (error.response?.data as { message?: string } | undefined)?.message
: undefined
toast.error(typeof msg === "string" && msg.trim() ? msg : "Failed to fetch users")
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
fetchUsers() fetchUsers()
}, [page, pageSize, roleFilter, statusFilter, search, setUsers, setTotal]) }, [
page,
pageSize,
roleFilter,
statusFilter,
search,
createdAfterLocal,
createdBeforeLocal,
countryFilter,
regionFilter,
subscriptionStatusFilter,
setUsers,
setTotal,
])
const pageCount = Math.max(1, Math.ceil(total / pageSize)) const pageCount = Math.max(1, Math.ceil(total / pageSize))
const safePage = Math.min(page, pageCount) const safePage = Math.min(page, pageCount)
@ -99,7 +237,10 @@ export function UsersListPage() {
const allSelected = users.length > 0 && selectedIds.size === users.length const allSelected = users.length > 0 && selectedIds.size === users.length
const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1 const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1
const endEntry = Math.min(safePage * pageSize, total) const endEntry = Math.min(safePage * pageSize, total)
const activeUsersOnPage = users.filter((u) => (u.status || "").toUpperCase() === "ACTIVE").length
const formatSummaryNum = (n: number) => n.toLocaleString()
const activeUsersTotal =
userSummary?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null
const getPageNumbers = () => { const getPageNumbers = () => {
const pages: (number | string)[] = [] const pages: (number | string)[] = []
@ -168,6 +309,29 @@ export function UsersListPage() {
navigate(`/users/${userId}`) navigate(`/users/${userId}`)
} }
const clearExtraFilters = () => {
setCreatedAfterLocal("")
setCreatedBeforeLocal("")
setCountryFilter("")
setRegionFilter("")
setSubscriptionStatusFilter("")
setPage(1)
}
const renderContactDetails = (phone: string | undefined, email: string | undefined) => {
const hasPhone = Boolean(phone?.trim())
const hasEmail = Boolean(email?.trim())
if (!hasPhone && !hasEmail) {
return <span className="text-grayScale-400"></span>
}
return (
<div className="space-y-1 text-sm text-grayScale-600">
{hasPhone ? <div className="tabular-nums">{phone!.trim()}</div> : null}
{hasEmail ? <div className="break-all text-grayScale-500">{email!.trim()}</div> : null}
</div>
)
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
@ -176,35 +340,67 @@ export function UsersListPage() {
<p className="text-sm text-grayScale-400">View and manage all registered users.</p> <p className="text-sm text-grayScale-400">View and manage all registered users.</p>
</div> </div>
{/* Stats cards (match UserLogPage approach) */} {/* Platform-wide user summary (same metrics as former User Management dashboard) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center gap-4 rounded-xl border bg-white p-4"> <Card className="border-none bg-brand-50 shadow-sm">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600"> <CardContent className="flex items-center gap-4 p-5">
<Users className="h-5 w-5" /> <div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
</div> <Users className="h-6 w-6" />
<div> </div>
<p className="text-2xl font-bold text-grayScale-600">{total}</p> <div className="min-w-0">
<p className="text-xs text-grayScale-400">Total Users</p> <p className="text-sm font-medium text-white/80">Total Users</p>
</div> <p className="text-2xl font-bold text-white">
</div> {userSummaryLoading ? (
<div className="flex items-center gap-4 rounded-xl border bg-white p-4"> <SpinnerIcon className="h-5 w-5" />
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600"> ) : userSummary ? (
<UserCheck className="h-5 w-5" /> formatSummaryNum(userSummary.total_users)
</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> </p>
</div> </div>
</div> </CardContent>
<div className="flex items-center gap-4 rounded-xl border bg-white p-4"> </Card>
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
<Search className="h-5 w-5" /> <Card className="border-none bg-brand-50 shadow-sm">
</div> <CardContent className="flex items-center gap-4 p-5">
<div> <div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-600">
<p className="text-2xl font-bold text-grayScale-600">{users.length}</p> <UserCheck className="h-6 w-6" />
<p className="text-xs text-grayScale-400">Showing Results</p> </div>
</div> <div className="min-w-0">
</div> <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>
<div className="bg-white rounded-xl border"> <div className="bg-white rounded-xl border">
@ -225,7 +421,10 @@ export function UsersListPage() {
<div className="relative w-full sm:w-auto"> <div className="relative w-full sm:w-auto">
<select <select
value={roleFilter} value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)} onChange={(e) => {
setRoleFilter(e.target.value)
setPage(1)
}}
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500" className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
> >
<option value="">All roles</option> <option value="">All roles</option>
@ -239,7 +438,10 @@ export function UsersListPage() {
<div className="relative w-full sm:w-auto"> <div className="relative w-full sm:w-auto">
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500" className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
> >
<option value="">All statuses</option> <option value="">All statuses</option>
@ -252,6 +454,96 @@ export function UsersListPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4 grid gap-3 border-t border-grayScale-100 pt-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<div className="flex flex-col gap-1">
<label htmlFor="filter-created-after" className="text-xs font-medium text-grayScale-500">
Created on or after
</label>
<input
id="filter-created-after"
type="datetime-local"
value={createdAfterLocal}
onChange={(e) => {
setCreatedAfterLocal(e.target.value)
setPage(1)
}}
className="h-9 w-full rounded-md border border-grayScale-200 bg-white px-2 text-sm text-grayScale-700 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="filter-created-before" className="text-xs font-medium text-grayScale-500">
Created on or before
</label>
<input
id="filter-created-before"
type="datetime-local"
value={createdBeforeLocal}
onChange={(e) => {
setCreatedBeforeLocal(e.target.value)
setPage(1)
}}
className="h-9 w-full rounded-md border border-grayScale-200 bg-white px-2 text-sm text-grayScale-700 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<UserListFilterDropdown
id="filter-country"
label="Country"
value={countryFilter}
allLabel="All countries"
options={USER_FILTER_COUNTRIES}
onSelect={(next) => {
setCountryFilter(next)
setPage(1)
}}
/>
<UserListFilterDropdown
id="filter-region"
label="Region (Ethiopia)"
value={regionFilter}
allLabel="All regions"
options={USER_FILTER_ETHIOPIA_REGIONS}
onSelect={(next) => {
setRegionFilter(next)
setPage(1)
}}
/>
<div className="flex flex-col gap-1">
<label htmlFor="filter-subscription-status" className="text-xs font-medium text-grayScale-500">
Subscription status
</label>
<div className="relative w-full">
<select
id="filter-subscription-status"
value={subscriptionStatusFilter}
onChange={(e) => {
setSubscriptionStatusFilter(e.target.value)
setPage(1)
}}
className="h-9 w-full appearance-none rounded-md border border-grayScale-200 bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">All</option>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="Unsubscribed">Unsubscribed</option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-grayScale-400">
Dates are sent as RFC3339 (UTC). Country and region filters use the lists above; the API matches
case-insensitively.
</p>
<button
type="button"
onClick={clearExtraFilters}
className="shrink-0 text-sm font-medium text-brand-600 hover:text-brand-700"
>
Clear date, location & subscription filters
</button>
</div>
</div> </div>
{/* Table */} {/* Table */}
@ -267,10 +559,11 @@ export function UsersListPage() {
/> />
</TableHead> </TableHead>
<TableHead>USER</TableHead> <TableHead>USER</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead> <TableHead className="hidden md:table-cell min-w-[10rem]">Contact details</TableHead>
<TableHead className="hidden md:table-cell">Phone</TableHead>
<TableHead className="hidden md:table-cell">Country</TableHead> <TableHead className="hidden md:table-cell">Country</TableHead>
<TableHead className="hidden md:table-cell">Region</TableHead> <TableHead className="hidden md:table-cell">Region</TableHead>
<TableHead className="hidden md:table-cell whitespace-nowrap">Joined at</TableHead>
<TableHead className="hidden lg:table-cell max-w-[12rem]">Subscription status</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -278,13 +571,13 @@ export function UsersListPage() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-12 text-center"> <TableCell colSpan={8} className="py-12 text-center">
<p className="text-sm text-grayScale-400">Loading users...</p> <p className="text-sm text-grayScale-400">Loading users...</p>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-16 text-center"> <TableCell colSpan={8} className="py-16 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100"> <div className="flex h-14 w-14 items-center justify-center rounded-full bg-grayScale-100">
<Users className="h-7 w-7 text-grayScale-400" /> <Users className="h-7 w-7 text-grayScale-400" />
@ -322,16 +615,31 @@ export function UsersListPage() {
{`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()} {`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div className="font-medium text-grayScale-600">
<div className="font-medium text-grayScale-600">{u.firstName} {u.lastName}</div> {u.firstName} {u.lastName}
<div className="text-xs text-grayScale-400">{u.email || u.phoneNumber || "-"}</div>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.role || "-"}</TableCell> <TableCell className="hidden md:table-cell align-top">
<TableCell className="hidden md:table-cell text-grayScale-500">{u.phoneNumber || "-"}</TableCell> {renderContactDetails(u.phoneNumber, u.email)}
</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell> <TableCell className="hidden md:table-cell text-grayScale-500">{u.country || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell> <TableCell className="hidden md:table-cell text-grayScale-500">{u.region || "-"}</TableCell>
<TableCell className="hidden md:table-cell text-sm text-grayScale-500 whitespace-nowrap">
{formatJoinedAt(u.createdAt)}
</TableCell>
<TableCell className="hidden lg:table-cell align-top text-sm text-grayScale-600">
<span
className={cn(
u.subscriptionStatus === "—" ||
u.subscriptionStatus.toLowerCase() === "unsubscribed"
? "text-grayScale-400"
: undefined,
)}
>
{u.subscriptionStatus}
</span>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell onClick={(e) => e.stopPropagation()}>
<button <button
type="button" type="button"

View File

@ -52,11 +52,34 @@ export interface DashboardPayments {
revenue_last_30_days: DateRevenue[] revenue_last_30_days: DateRevenue[]
} }
export interface DashboardCoursesLms {
programs: number
courses: number
modules: number
lessons: number
lessons_with_video: number
practices: number
practices_at_course: number
practices_at_module: number
practices_at_lesson: number
}
export interface DashboardCoursesExamPrep {
catalog_courses: number
units: number
unit_modules: number
lessons: number
lessons_with_video: number
lesson_practices: number
}
export interface DashboardCourses { export interface DashboardCourses {
total_categories: number total_categories: number
total_courses: number total_courses: number
total_sub_courses: number total_sub_courses: number
total_videos: number total_videos: number
lms?: DashboardCoursesLms
exam_prep?: DashboardCoursesExamPrep
} }
export interface DashboardContent { export interface DashboardContent {
@ -88,8 +111,34 @@ export interface DashboardTeam {
by_status: LabelCount[] by_status: LabelCount[]
} }
export type DashboardDateFilterMode = "all_time" | "year" | "year_month" | "custom"
export interface DashboardDateFilter {
mode: DashboardDateFilterMode
year?: number
month?: number
from?: string
to?: string
range_start?: string
range_end?: string
series_start?: string
series_end?: string
ref_date?: string
}
export type DashboardFilterMode = DashboardDateFilterMode
export interface DashboardFilters {
mode: DashboardFilterMode
year?: number
month?: number
from?: string
to?: string
}
export interface DashboardData { export interface DashboardData {
generated_at: string generated_at: string
date_filter?: DashboardDateFilter
users: DashboardUsers users: DashboardUsers
subscriptions: DashboardSubscriptions subscriptions: DashboardSubscriptions
payments: DashboardPayments payments: DashboardPayments

View 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
}

View File

@ -1,36 +1,40 @@
// This matches the API response 1:1 // This matches the API response 1:1 (GET /users); many fields are optional on partial profiles.
export interface UserApiDTO { export interface UserApiDTO {
id: number id: number
first_name: string first_name?: string
last_name: string last_name?: string
gender: string gender?: string
birth_day: string | null birth_day?: string | null
email: string email?: string
phone_number?: string phone_number?: string
role: string role: string
age_group: string age_group?: string
education_level: string education_level?: string
country: string country?: string
region: string region?: string
nick_name: string nick_name?: string
occupation: string occupation?: string
learning_goal: string learning_goal?: string
language_goal: string language_goal?: string
language_challange: string language_challange?: string
favoutite_topic: string favoutite_topic?: string
email_verified: boolean email_verified?: boolean
phone_verified: boolean phone_verified?: boolean
status: string status: string
profile_completed: boolean profile_completed?: boolean
profile_picture_url: string profile_picture_url?: string
preferred_language: string preferred_language?: string
profile_completion_percentage?: number
created_at: string created_at: string
updated_at?: string
/** Billing / plan state for list UI (e.g. "Unsubscribed", "Active"). */
subscription_status?: string
} }
export interface GetUsersResponse { export interface GetUsersResponse {
@ -55,20 +59,26 @@ export interface User {
country: string country: string
lastLogin: string | null lastLogin: string | null
status: string status: string
/** From API `subscription_status` (e.g. "Unsubscribed"). */
subscriptionStatus: string
/** ISO 8601 from API `created_at`. */
createdAt: string
} }
export const mapUserApiToUser = (u: UserApiDTO): User => ({ export const mapUserApiToUser = (u: UserApiDTO): User => ({
id: u.id, id: u.id,
firstName: u.first_name, firstName: u.first_name ?? "",
lastName: u.last_name, lastName: u.last_name ?? "",
nickName: u.nick_name, nickName: u.nick_name ?? "",
email: u.email, email: u.email ?? "",
phoneNumber: u.phone_number ?? "", phoneNumber: u.phone_number ?? "",
role: u.role, role: u.role,
region: u.region, region: u.region ?? "",
country: u.country, country: u.country ?? "",
lastLogin: null, lastLogin: null,
status: u.status, status: u.status,
subscriptionStatus: u.subscription_status?.trim() ? u.subscription_status.trim() : "—",
createdAt: u.created_at ?? "",
}) })
export interface UserProfileData { export interface UserProfileData {
@ -115,6 +125,27 @@ export interface UserProfileResponse {
timestamp: string timestamp: string
} }
/** GET /admin/users/:user_id/recent-activity */
export interface UserRecentActivityItem {
id: string
kind: string
occurred_at: string
headline: string
}
export interface UserRecentActivityData {
user_id: number
items: UserRecentActivityItem[]
}
export interface UserRecentActivityResponse {
message?: string
data?: UserRecentActivityData
success?: boolean
status_code?: number
metadata?: unknown
}
export interface UserSummary { export interface UserSummary {
total_users: number total_users: number
active_users: number active_users: number