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 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),
}));

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 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");

View File

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

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 {
// 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">
{/* Subscription plans (from catalog API) */}
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle>{title}</CardTitle>
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-brand-500" />
<CardTitle>Subscription plans</CardTitle>
</div>
<p className="text-sm text-grayScale-500">Available billing plans for learners.</p>
</CardHeader>
<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>
{subscriptionPlansLoading ? (
<div className="flex items-center justify-center py-10">
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
</div>
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
</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="flex items-center justify-center py-6 text-sm text-grayScale-400">
No data available
<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>
</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>
)}
</CardContent>
</Card>
))}
</div>
{/* App Ratings */}
<Card className="shadow-none">

View File

@ -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">

View File

@ -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 {

View File

@ -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>
{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">
{recentActivities.map((activity, index) => {
const Icon = activityIcons[activity.type] ?? CheckCircle2;
const isLast = index === recentActivities.length - 1;
{recentActivityItems.map((item, index) => {
const vk = visualActivityKind(item.kind);
const Icon = activityIcons[vk];
const isLast = index === recentActivityItems.length - 1;
return (
<div key={index} className="relative flex gap-4 pb-5 last:pb-0">
<div key={item.id} className="relative flex gap-4 pb-5 last:pb-0">
{!isLast && (
<div className="absolute left-[15px] top-8 bottom-0 w-px bg-grayScale-200" />
<div className="absolute bottom-0 left-[15px] top-8 w-px bg-grayScale-200" />
)}
<div
className={cn(
"relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
activity.type === "completed"
vk === "completed"
? "bg-mint-100 text-mint-500"
: activity.type === "started"
: vk === "started"
? "bg-brand-100/50 text-brand-500"
: "bg-grayScale-100 text-grayScale-400"
: vk === "joined"
? "bg-grayScale-100 text-grayScale-400"
: "bg-grayScale-100 text-grayScale-500",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 pt-1">
<div className="text-sm font-medium text-grayScale-600">
{activity.text}
<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 className="mt-0.5 text-xs text-grayScale-400">{activity.time}</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</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 { 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" />
{/* 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>
<p className="text-2xl font-bold text-grayScale-600">{total}</p>
<p className="text-xs text-grayScale-400">Total Users</p>
<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="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 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>
<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>
</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"

View File

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

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 {
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