From 385f58fd222d74591d3d37dec5e7a5b962f74587 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 18 May 2026 08:44:51 -0700 Subject: [PATCH] UAT fixes stage 1 --- src/api/analytics.api.ts | 40 +- src/api/subscription-plans.api.ts | 11 + src/api/users.api.ts | 54 ++- src/app/AppRoutes.tsx | 3 +- .../analytics/AnalyticsTimeRangeFilter.tsx | 272 ++++++++++++ src/components/dashboard/RevenueTrendCard.tsx | 130 ++++++ src/data/userFilterLocations.ts | 228 ++++++++++ src/lib/analytics.ts | 86 ++++ src/pages/DashboardPage.tsx | 233 +++++----- src/pages/analytics/AnalyticsPage.tsx | 116 +++-- src/pages/notifications/NotificationsPage.tsx | 4 +- src/pages/user-management/UserDetailPage.tsx | 141 ++++-- .../UserManagementDashboard.tsx | 180 -------- src/pages/user-management/UsersListPage.tsx | 406 +++++++++++++++--- src/types/analytics.types.ts | 49 +++ src/types/subscription.types.ts | 21 + src/types/user.types.ts | 85 ++-- 17 files changed, 1622 insertions(+), 437 deletions(-) create mode 100644 src/api/subscription-plans.api.ts create mode 100644 src/components/analytics/AnalyticsTimeRangeFilter.tsx create mode 100644 src/components/dashboard/RevenueTrendCard.tsx create mode 100644 src/data/userFilterLocations.ts create mode 100644 src/lib/analytics.ts delete mode 100644 src/pages/user-management/UserManagementDashboard.tsx create mode 100644 src/types/subscription.types.ts diff --git a/src/api/analytics.api.ts b/src/api/analytics.api.ts index 7f31690..67ec8b2 100644 --- a/src/api/analytics.api.ts +++ b/src/api/analytics.api.ts @@ -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("/analytics/dashboard"); +function buildDashboardQueryParams(filters?: DashboardFilters): Record { + 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("/analytics/dashboard", { + params: buildDashboardQueryParams(filters), + }) + .then((res) => ({ + ...res, + data: unwrapDashboardResponse(res.data), + })); diff --git a/src/api/subscription-plans.api.ts b/src/api/subscription-plans.api.ts new file mode 100644 index 0000000..7c3690f --- /dev/null +++ b/src/api/subscription-plans.api.ts @@ -0,0 +1,11 @@ +import http from "./http" +import type { SubscriptionPlansListResponse, SubscriptionPlan } from "../types/subscription.types" + +export const getSubscriptionPlans = () => + http.get("/subscription-plans").then((res) => ({ + ...res, + data: { + ...res.data, + data: Array.isArray(res.data?.data) ? res.data.data : ([] as SubscriptionPlan[]), + }, + })) diff --git a/src/api/users.api.ts b/src/api/users.api.ts index 9d191dd..691bf61 100644 --- a/src/api/users.api.ts +++ b/src/api/users.api.ts @@ -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 { + const q: Record = {} + 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("/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(`/user/single/${id}`); +export const getUserRecentActivity = (userId: number) => + http.get(`/admin/users/${userId}/recent-activity`); + export const getMyProfile = () => http.get("/team/me"); diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index bc97bb0..441ec61 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -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() { } /> } /> }> - } /> + } /> } /> } /> } /> diff --git a/src/components/analytics/AnalyticsTimeRangeFilter.tsx b/src/components/analytics/AnalyticsTimeRangeFilter.tsx new file mode 100644 index 0000000..ea5834c --- /dev/null +++ b/src/components/analytics/AnalyticsTimeRangeFilter.tsx @@ -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(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 ( +
+ + + {open && ( +
+ + +
+ + {yearOpen && ( +
+ {years.map((year) => ( + + ))} +
+ )} +
+ +
+ + {monthOpen && ( +
+
+ {years.map((year) => ( + + ))} +
+ {MONTH_LABELS.map((label, index) => { + const month = index + 1 + const isSelected = + value.mode === "year_month" && value.year === contextYear && value.month === month + + return ( + + ) + })} +
+ )} +
+ +
+ + {customOpen && ( +
+
+ + setCustomFrom(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + setCustomTo(e.target.value)} + className="h-8 text-xs" + /> +
+ +
+ )} +
+
+ )} +
+ ) +} diff --git a/src/components/dashboard/RevenueTrendCard.tsx b/src/components/dashboard/RevenueTrendCard.tsx new file mode 100644 index 0000000..beb9c03 --- /dev/null +++ b/src/components/dashboard/RevenueTrendCard.tsx @@ -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([]) + 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 ( + + +
+
+ Revenue Trend +
+ ETB {totalRevenue.toLocaleString()} +
+
Monthly · {year} (ETB)
+
+ +
+
+ + {loading ? ( +
+ +
+ ) : ( + + + + + + { + 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)", + }} + /> + + {chartData.map((entry) => ( + + ))} + + + {chartData.map((entry) => ( + + ))} + + + + )} +
+
+ ) +} diff --git a/src/data/userFilterLocations.ts b/src/data/userFilterLocations.ts new file mode 100644 index 0000000..4f2eb35 --- /dev/null +++ b/src/data/userFilterLocations.ts @@ -0,0 +1,228 @@ +/** + * Static options for GET /users filters (`country`, `region`). + * Country: common English short names (ISO-style), sorted A–Z. + * Region: Ethiopia — federal regions & chartered cities (typical `users.region` values). + */ + +const COUNTRY_NAMES_RAW = [ + "Afghanistan", + "Albania", + "Algeria", + "Andorra", + "Angola", + "Antigua and Barbuda", + "Argentina", + "Armenia", + "Australia", + "Austria", + "Azerbaijan", + "Bahamas", + "Bahrain", + "Bangladesh", + "Barbados", + "Belarus", + "Belgium", + "Belize", + "Benin", + "Bhutan", + "Bolivia", + "Bosnia and Herzegovina", + "Botswana", + "Brazil", + "Brunei", + "Bulgaria", + "Burkina Faso", + "Burundi", + "Cabo Verde", + "Cambodia", + "Cameroon", + "Canada", + "Central African Republic", + "Chad", + "Chile", + "China", + "Colombia", + "Comoros", + "Congo", + "Costa Rica", + "Croatia", + "Cuba", + "Cyprus", + "Czechia", + "Democratic Republic of the Congo", + "Denmark", + "Djibouti", + "Dominica", + "Dominican Republic", + "Ecuador", + "Egypt", + "El Salvador", + "Equatorial Guinea", + "Eritrea", + "Estonia", + "Eswatini", + "Ethiopia", + "Fiji", + "Finland", + "France", + "Gabon", + "Gambia", + "Georgia", + "Germany", + "Ghana", + "Greece", + "Grenada", + "Guatemala", + "Guinea", + "Guinea-Bissau", + "Guyana", + "Haiti", + "Honduras", + "Hungary", + "Iceland", + "India", + "Indonesia", + "Iran", + "Iraq", + "Ireland", + "Israel", + "Italy", + "Jamaica", + "Japan", + "Jordan", + "Kazakhstan", + "Kenya", + "Kiribati", + "Kuwait", + "Kyrgyzstan", + "Laos", + "Latvia", + "Lebanon", + "Lesotho", + "Liberia", + "Libya", + "Liechtenstein", + "Lithuania", + "Luxembourg", + "Madagascar", + "Malawi", + "Malaysia", + "Maldives", + "Mali", + "Malta", + "Marshall Islands", + "Mauritania", + "Mauritius", + "Mexico", + "Micronesia", + "Moldova", + "Monaco", + "Mongolia", + "Montenegro", + "Morocco", + "Mozambique", + "Myanmar", + "Namibia", + "Nauru", + "Nepal", + "Netherlands", + "New Zealand", + "Nicaragua", + "Niger", + "Nigeria", + "North Korea", + "North Macedonia", + "Norway", + "Oman", + "Pakistan", + "Palau", + "Panama", + "Papua New Guinea", + "Paraguay", + "Peru", + "Philippines", + "Poland", + "Portugal", + "Qatar", + "Romania", + "Russia", + "Rwanda", + "Saint Kitts and Nevis", + "Saint Lucia", + "Saint Vincent and the Grenadines", + "Samoa", + "San Marino", + "Sao Tome and Principe", + "Saudi Arabia", + "Senegal", + "Serbia", + "Seychelles", + "Sierra Leone", + "Singapore", + "Slovakia", + "Slovenia", + "Solomon Islands", + "Somalia", + "South Africa", + "South Korea", + "South Sudan", + "Spain", + "Sri Lanka", + "Sudan", + "Suriname", + "Sweden", + "Switzerland", + "Syria", + "Tajikistan", + "Tanzania", + "Thailand", + "Timor-Leste", + "Togo", + "Tonga", + "Trinidad and Tobago", + "Tunisia", + "Turkey", + "Turkmenistan", + "Tuvalu", + "Uganda", + "Ukraine", + "United Arab Emirates", + "United Kingdom", + "United States", + "Uruguay", + "Uzbekistan", + "Vanuatu", + "Vatican City", + "Venezuela", + "Vietnam", + "Yemen", + "Zambia", + "Zimbabwe", +] as const + +/** English short names, A–Z (for `` 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 ` 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" > @@ -239,7 +438,10 @@ export function UsersListPage() {
{ + 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" + /> +
+
+ + { + 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" + /> +
+ { + setCountryFilter(next) + setPage(1) + }} + /> + { + setRegionFilter(next) + setPage(1) + }} + /> +
+ +
+ + +
+
+ +
+

+ Dates are sent as RFC3339 (UTC). Country and region filters use the lists above; the API matches + case-insensitively. +

+ +
{/* Table */} @@ -267,10 +559,11 @@ export function UsersListPage() { /> USER - Role - Phone + Contact details Country Region + Joined at + Subscription status Status @@ -278,13 +571,13 @@ export function UsersListPage() { {loading ? ( - +

Loading users...

) : users.length === 0 ? ( - +
@@ -322,16 +615,31 @@ export function UsersListPage() { {`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()} -
-
{u.firstName} {u.lastName}
-
{u.email || u.phoneNumber || "-"}
+
+ {u.firstName} {u.lastName}
- {u.role || "-"} - {u.phoneNumber || "-"} + + {renderContactDetails(u.phoneNumber, u.email)} + {u.country || "-"} {u.region || "-"} + + {formatJoinedAt(u.createdAt)} + + + + {u.subscriptionStatus} + + e.stopPropagation()}>