From 2c3f0da6f75454867c137e3faf040f724ff96793 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 29 May 2026 06:54:58 -0700 Subject: [PATCH] feat(admin): payments, settings tabs, theme, and navigation refresh Add admin payments with status, provider, and plan category filters. Introduce app versions and subscription plan management in settings, change-password security flow, and dark theme support. Reorganize sidebar, improve activity log actor details, analytics, and related UI polish. Co-authored-by: Cursor --- index.html | 21 + src/App.tsx | 33 +- src/api/analytics.api.ts | 1 + src/api/app-versions.api.ts | 117 ++++ src/api/payments.api.ts | 98 +++ src/api/subscription-plans.api.ts | 85 ++- src/api/team.api.ts | 6 + src/app/AppRoutes.tsx | 2 + src/components/sidebar/Sidebar.tsx | 101 ++- src/components/sidebar/SidebarNavGroup.tsx | 6 +- src/components/ui/button.tsx | 3 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/input.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/textarea.tsx | 2 +- src/contexts/ThemeContext.tsx | 76 +++ src/index.css | 109 +++- src/lib/activityLogActor.ts | 143 +++++ src/lib/appVersions.ts | 79 +++ src/lib/auth.ts | 9 + src/lib/payments.ts | 51 ++ src/lib/subscriptionPlans.ts | 61 ++ src/lib/theme.ts | 64 ++ src/main.tsx | 9 +- src/pages/DashboardPage.tsx | 14 +- src/pages/SettingsPage.tsx | 385 ++++++------ src/pages/analytics/AnalyticsPage.tsx | 5 + src/pages/auth/LoginPage.tsx | 11 +- .../ContentManagementLayout.tsx | 5 +- src/pages/payments/PaymentsPage.tsx | 589 ++++++++++++++++++ src/pages/settings/AppVersionsTab.tsx | 501 +++++++++++++++ src/pages/settings/SubscriptionPlansTab.tsx | 343 ++++++++++ .../components/CreateAppVersionDialog.tsx | 321 ++++++++++ .../CreateSubscriptionPlanDialog.tsx | 308 +++++++++ .../components/DeleteAppVersionDialog.tsx | 98 +++ .../DeleteSubscriptionPlanDialog.tsx | 98 +++ .../components/EditAppVersionDialog.tsx | 277 ++++++++ .../components/EditSubscriptionPlanDialog.tsx | 319 ++++++++++ .../settings/components/ThemeModePreview.tsx | 75 +++ src/pages/user-log/UserLogPage.tsx | 32 +- .../user-log/components/ActorHoverCard.tsx | 251 ++++++++ src/types/analytics.types.ts | 1 + src/types/app-version.types.ts | 58 ++ src/types/payment.types.ts | 61 ++ src/types/subscription.types.ts | 34 +- src/types/team.types.ts | 13 + tailwind.config.js | 16 +- 47 files changed, 4609 insertions(+), 288 deletions(-) create mode 100644 src/api/app-versions.api.ts create mode 100644 src/api/payments.api.ts create mode 100644 src/contexts/ThemeContext.tsx create mode 100644 src/lib/activityLogActor.ts create mode 100644 src/lib/appVersions.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/payments.ts create mode 100644 src/lib/subscriptionPlans.ts create mode 100644 src/lib/theme.ts create mode 100644 src/pages/payments/PaymentsPage.tsx create mode 100644 src/pages/settings/AppVersionsTab.tsx create mode 100644 src/pages/settings/SubscriptionPlansTab.tsx create mode 100644 src/pages/settings/components/CreateAppVersionDialog.tsx create mode 100644 src/pages/settings/components/CreateSubscriptionPlanDialog.tsx create mode 100644 src/pages/settings/components/DeleteAppVersionDialog.tsx create mode 100644 src/pages/settings/components/DeleteSubscriptionPlanDialog.tsx create mode 100644 src/pages/settings/components/EditAppVersionDialog.tsx create mode 100644 src/pages/settings/components/EditSubscriptionPlanDialog.tsx create mode 100644 src/pages/settings/components/ThemeModePreview.tsx create mode 100644 src/pages/user-log/components/ActorHoverCard.tsx create mode 100644 src/types/app-version.types.ts create mode 100644 src/types/payment.types.ts diff --git a/index.html b/index.html index 85a0344..30949b1 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,27 @@ yimaru-admin +
diff --git a/src/App.tsx b/src/App.tsx index 7835fb1..1b52c1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,29 @@ import { useEffect } from 'react' import { Toaster } from 'sonner' import { AppRoutes } from './app/AppRoutes' +import { useTheme } from './contexts/ThemeContext' const SESSION_KEY = 'yimaru_session_active' +function AppToaster() { + const { resolvedTheme } = useTheme() + return ( + + ) +} + export default function App() { useEffect(() => { if (!sessionStorage.getItem(SESSION_KEY)) { @@ -18,18 +38,7 @@ export default function App() { return ( <> - + ) } diff --git a/src/api/analytics.api.ts b/src/api/analytics.api.ts index 82cddd8..9be924f 100644 --- a/src/api/analytics.api.ts +++ b/src/api/analytics.api.ts @@ -143,6 +143,7 @@ function normalizeDashboardUsers(raw: unknown, root?: Record): by_knowledge_level: asLabelCounts( pickField(u, "by_knowledge_level", "byKnowledgeLevel", "ByKnowledgeLevel"), ), + by_country: asLabelCounts(pickField(u, "by_country", "byCountry", "ByCountry")), by_region: asLabelCounts(pickField(u, "by_region", "byRegion", "ByRegion")), registrations_last_30_days: asDateCounts( pickField( diff --git a/src/api/app-versions.api.ts b/src/api/app-versions.api.ts new file mode 100644 index 0000000..64dcd9b --- /dev/null +++ b/src/api/app-versions.api.ts @@ -0,0 +1,117 @@ +import http from "./http" +import type { + AppVersion, + AppVersionMutationResponse, + AppVersionsListData, + AppVersionsListResponse, + CreateAppVersionPayload, + UpdateAppVersionPayload, +} from "../types/app-version.types" + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function normalizeAppVersion(raw: unknown): AppVersion | null { + if (!isRecord(raw)) return null + const id = Number(raw.id) + if (!Number.isFinite(id)) return null + + return { + id, + platform: String(raw.platform ?? ""), + version_name: String(raw.version_name ?? ""), + version_code: Number(raw.version_code ?? 0), + update_type: String(raw.update_type ?? ""), + release_notes: String(raw.release_notes ?? ""), + store_url: String(raw.store_url ?? ""), + min_supported_version_code: Number(raw.min_supported_version_code ?? 0), + status: String(raw.status ?? ""), + created_at: String(raw.created_at ?? ""), + } +} + +export function parseAppVersionsList(body: unknown): AppVersionsListData { + const empty: AppVersionsListData = { versions: [], total_count: 0 } + + if (isRecord(body)) { + const data = body.data + if (isRecord(data) && Array.isArray(data.versions)) { + const versions = data.versions + .map(normalizeAppVersion) + .filter((v): v is AppVersion => v !== null) + const total_count = Number(data.total_count ?? versions.length) + return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length } + } + if (Array.isArray(data)) { + const versions = data.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null) + return { versions, total_count: versions.length } + } + if (Array.isArray(body.versions)) { + const versions = body.versions + .map(normalizeAppVersion) + .filter((v): v is AppVersion => v !== null) + const total_count = Number(body.total_count ?? versions.length) + return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length } + } + } + + if (Array.isArray(body)) { + const versions = body.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null) + return { versions, total_count: versions.length } + } + + return empty +} + +export function parseAppVersionMutation(body: unknown): AppVersion | null { + if (isRecord(body) && body.data != null) { + return normalizeAppVersion(body.data) + } + return normalizeAppVersion(body) +} + +export type GetAppVersionsParams = { + limit?: number + offset?: number +} + +export const getAppVersions = (params: GetAppVersionsParams = {}) => { + const limit = params.limit ?? 20 + const offset = params.offset ?? 0 + return http + .get("/admin/app-versions", { params: { limit, offset } }) + .then((res) => { + const parsed = parseAppVersionsList(res.data) + return { + ...res, + data: parsed, + message: isRecord(res.data) ? String(res.data.message ?? "") : undefined, + } + }) +} + +function mutationResult(res: { data: unknown }) { + const version = parseAppVersionMutation(res.data) + return { + ...res, + data: version, + message: isRecord(res.data) ? String(res.data.message ?? "") : undefined, + } +} + +export const createAppVersion = (payload: CreateAppVersionPayload) => + http + .post("/admin/app-versions", payload) + .then(mutationResult) + +export const updateAppVersion = (id: number, payload: UpdateAppVersionPayload) => + http + .put(`/admin/app-versions/${id}`, payload) + .then(mutationResult) + +export const deleteAppVersion = (id: number) => + http.delete<{ message?: string }>(`/admin/app-versions/${id}`).then((res) => ({ + ...res, + message: isRecord(res.data) ? String(res.data.message ?? "") : undefined, + })) diff --git a/src/api/payments.api.ts b/src/api/payments.api.ts new file mode 100644 index 0000000..860e0bd --- /dev/null +++ b/src/api/payments.api.ts @@ -0,0 +1,98 @@ +import http from "./http" +import type { GetPaymentsParams, Payment, PaymentsListData, PaymentsListResponse } from "../types/payment.types" + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function normalizePayment(raw: unknown): Payment | null { + if (!isRecord(raw)) return null + const id = Number(raw.id) + if (!Number.isFinite(id)) return null + + const paid_at = raw.paid_at + const expires_at = raw.expires_at + + return { + id, + user_id: Number(raw.user_id ?? 0), + plan_id: Number(raw.plan_id ?? 0), + subscription_id: Number(raw.subscription_id ?? 0), + session_id: String(raw.session_id ?? ""), + transaction_id: String(raw.transaction_id ?? ""), + nonce: String(raw.nonce ?? ""), + amount: Number(raw.amount ?? 0), + currency: String(raw.currency ?? "ETB"), + payment_method: String(raw.payment_method ?? ""), + status: String(raw.status ?? ""), + payment_url: String(raw.payment_url ?? ""), + plan_name: String(raw.plan_name ?? ""), + plan_category: String(raw.plan_category ?? ""), + user_email: String(raw.user_email ?? ""), + user_first_name: String(raw.user_first_name ?? ""), + user_last_name: String(raw.user_last_name ?? ""), + paid_at: paid_at == null || paid_at === "" ? null : String(paid_at), + expires_at: expires_at == null || expires_at === "" ? null : String(expires_at), + created_at: String(raw.created_at ?? ""), + updated_at: String(raw.updated_at ?? ""), + } +} + +export function parsePaymentsList(body: unknown): PaymentsListData { + const empty: PaymentsListData = { + payments: [], + total_count: 0, + limit: 0, + offset: 0, + } + + if (isRecord(body)) { + const data = body.data + if (isRecord(data) && Array.isArray(data.payments)) { + const payments = data.payments + .map(normalizePayment) + .filter((p): p is Payment => p !== null) + const total_count = Number(data.total_count ?? payments.length) + const limit = Number(data.limit ?? payments.length) + const offset = Number(data.offset ?? 0) + return { + payments, + total_count: Number.isFinite(total_count) ? total_count : payments.length, + limit: Number.isFinite(limit) ? limit : payments.length, + offset: Number.isFinite(offset) ? offset : 0, + } + } + if (Array.isArray(data)) { + const payments = data.map(normalizePayment).filter((p): p is Payment => p !== null) + return { payments, total_count: payments.length, limit: payments.length, offset: 0 } + } + } + + if (Array.isArray(body)) { + const payments = body.map(normalizePayment).filter((p): p is Payment => p !== null) + return { payments, total_count: payments.length, limit: payments.length, offset: 0 } + } + + return empty +} + +function buildQueryParams(params: GetPaymentsParams): Record { + const query: Record = { + limit: Math.min(100, Math.max(1, params.limit ?? 20)), + offset: Math.max(0, params.offset ?? 0), + } + if (params.status?.trim()) query.status = params.status.trim() + if (params.provider?.trim()) query.provider = params.provider.trim() + if (params.plan_category?.trim()) query.plan_category = params.plan_category.trim() + return query +} + +export const getPayments = (params: GetPaymentsParams = {}) => + http.get("/admin/payments", { params: buildQueryParams(params) }).then((res) => { + const parsed = parsePaymentsList(res.data) + return { + ...res, + data: parsed, + message: isRecord(res.data) ? String(res.data.message ?? "") : undefined, + } + }) diff --git a/src/api/subscription-plans.api.ts b/src/api/subscription-plans.api.ts index 7c3690f..7c5846f 100644 --- a/src/api/subscription-plans.api.ts +++ b/src/api/subscription-plans.api.ts @@ -1,11 +1,84 @@ import http from "./http" -import type { SubscriptionPlansListResponse, SubscriptionPlan } from "../types/subscription.types" +import type { + CreateSubscriptionPlanPayload, + SubscriptionPlan, + SubscriptionPlanMutationResponse, + SubscriptionPlansListResponse, + UpdateSubscriptionPlanPayload, +} from "../types/subscription.types" + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +function normalizeSubscriptionPlan(raw: unknown): SubscriptionPlan | null { + if (!isRecord(raw)) return null + const id = Number(raw.id) + if (!Number.isFinite(id)) return null + + return { + id, + name: String(raw.name ?? ""), + description: String(raw.description ?? ""), + category: String(raw.category ?? ""), + duration_value: Number(raw.duration_value ?? 0), + duration_unit: String(raw.duration_unit ?? "MONTH"), + price: Number(raw.price ?? 0), + currency: String(raw.currency ?? "ETB"), + is_active: Boolean(raw.is_active ?? true), + created_at: String(raw.created_at ?? ""), + } +} + +export function parseSubscriptionPlansList(body: unknown): SubscriptionPlan[] { + if (Array.isArray(body)) { + return body.map(normalizeSubscriptionPlan).filter((p): p is SubscriptionPlan => p !== null) + } + if (isRecord(body) && Array.isArray(body.data)) { + return body.data + .map(normalizeSubscriptionPlan) + .filter((p): p is SubscriptionPlan => p !== null) + } + return [] +} + +export function parseSubscriptionPlanMutation(body: unknown): SubscriptionPlan | null { + if (isRecord(body) && body.data != null) { + return normalizeSubscriptionPlan(body.data) + } + return normalizeSubscriptionPlan(body) +} export const getSubscriptionPlans = () => - http.get("/subscription-plans").then((res) => ({ + http.get("/subscription-plans").then((res) => { + const plans = parseSubscriptionPlansList(res.data) + return { + ...res, + data: plans, + } + }) + +function mutationResult(res: { data: unknown }) { + const plan = parseSubscriptionPlanMutation(res.data) + return { ...res, - data: { - ...res.data, - data: Array.isArray(res.data?.data) ? res.data.data : ([] as SubscriptionPlan[]), - }, + data: plan, + message: isRecord(res.data) ? String(res.data.message ?? "") : undefined, + } +} + +export const createSubscriptionPlan = (payload: CreateSubscriptionPlanPayload) => + http + .post("/subscription-plans", payload) + .then(mutationResult) + +export const updateSubscriptionPlan = (id: number, payload: UpdateSubscriptionPlanPayload) => + http + .put(`/subscription-plans/${id}`, payload) + .then(mutationResult) + +export const deleteSubscriptionPlan = (id: number) => + http.delete<{ message?: string }>(`/subscription-plans/${id}`).then((res) => ({ + ...res, + message: isRecord(res.data) ? String(res.data.message ?? "") : undefined, })) diff --git a/src/api/team.api.ts b/src/api/team.api.ts index c3f8814..93a9d31 100644 --- a/src/api/team.api.ts +++ b/src/api/team.api.ts @@ -7,6 +7,8 @@ import type { VerifyInvitationResponse, } from "../types/teamInvitation.types" import type { + ChangeTeamMemberPasswordRequest, + ChangeTeamMemberPasswordResponse, GetTeamMembersResponse, GetTeamMemberResponse, CreateTeamMemberRequest, @@ -33,6 +35,10 @@ export const updateTeamMemberStatus = (id: number, status: string) => export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) => http.put(`/team/members/${id}`, data) +/** POST /team/members/:id/change-password — change the signed-in member's password. */ +export const changeTeamMemberPassword = (id: number, data: ChangeTeamMemberPasswordRequest) => + http.post(`/team/members/${id}/change-password`, data) + /** POST /team/members/invite — send invitation email (permission: team.members.invite). */ export const inviteTeamMember = (data: InviteTeamMemberRequest) => http.post("/team/members/invite", data) diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 1d01c37..e51afaf 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -54,6 +54,7 @@ import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLan import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"; import { UserLogPage } from "../pages/user-log/UserLogPage"; import { IssuesPage } from "../pages/issues/IssuesPage"; +import { PaymentsPage } from "../pages/payments/PaymentsPage"; import { ProfilePage } from "../pages/ProfilePage"; import { SettingsPage } from "../pages/SettingsPage"; import { TeamManagementPage } from "../pages/team/TeamManagementPage"; @@ -255,6 +256,7 @@ export function AppRoutes() { path="/notifications/create" element={} /> + } /> } /> } /> } /> diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 4214b37..f91e488 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -6,12 +6,11 @@ import { ChevronRight, CircleAlert, ClipboardList, + CreditCard, LayoutDashboard, LogOut, - Shield, UserCircle2, Users, - Users2, Settings, X, } from "lucide-react"; @@ -33,32 +32,71 @@ type NavGroupItem = { kind: "group"; label: string; basePath: string; + activePaths?: string[]; icon: ComponentType<{ className?: string }>; children: { label: string; to: string; end?: boolean }[]; }; -type NavEntry = NavLinkItem | NavGroupItem; +type NavSectionItem = { + kind: "section"; + label: string; +}; + +type NavEntry = NavLinkItem | NavGroupItem | NavSectionItem; const navEntries: NavEntry[] = [ + { kind: "section", label: "Overview" }, { kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard }, - { kind: "link", label: "User Management", to: "/users", icon: Users }, - { kind: "link", label: "Role Management", to: "/roles", icon: Shield }, - { kind: "link", label: "Content Management", to: "/content", icon: BookOpen }, - { kind: "link", label: "New Content", to: "/new-content", icon: BookOpen }, + { kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 }, + + { kind: "section", label: "People" }, + { + kind: "group", + label: "Users & access", + basePath: "/users", + activePaths: ["/users", "/roles", "/team"], + icon: Users, + children: [ + { label: "All users", to: "/users/list" }, + { label: "Roles", to: "/roles" }, + { label: "Team members", to: "/team" }, + ], + }, + + { kind: "section", label: "Learning content" }, + { + kind: "group", + label: "Content", + basePath: "/content", + activePaths: ["/content", "/new-content"], + icon: BookOpen, + children: [ + { label: "Manage practices", to: "/content", end: true }, + { label: "New content", to: "/new-content", end: true }, + { label: "Reorder structure", to: "/new-content/reorder" }, + { label: "Question types", to: "/new-content/question-types" }, + ], + }, + + { kind: "section", label: "Communications" }, { kind: "group", label: "Notifications", basePath: "/notifications", icon: Bell, children: [ - { label: "My Notifications", to: "/notifications", end: true }, - { label: "Email Templates", to: "/notifications/email-templates" }, + { label: "Inbox", to: "/notifications", end: true }, + { label: "Email templates", to: "/notifications/email-templates" }, + { label: "Send notification", to: "/notifications/create" }, ], }, - { kind: "link", label: "User Log", to: "/user-log", icon: ClipboardList }, - { kind: "link", label: "Issue Reports", to: "/issues", icon: CircleAlert }, - { kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 }, - { kind: "link", label: "Team Management", to: "/team", icon: Users2 }, + + { kind: "section", label: "Operations" }, + { kind: "link", label: "Payments", to: "/payments", icon: CreditCard }, + { kind: "link", label: "User activity log", to: "/user-log", icon: ClipboardList }, + { kind: "link", label: "Issue reports", to: "/issues", icon: CircleAlert }, + + { kind: "section", label: "Account" }, { kind: "link", label: "Profile", to: "/profile", icon: UserCircle2 }, { kind: "link", label: "Settings", to: "/settings", icon: Settings }, ]; @@ -162,19 +200,50 @@ export function Sidebar({ -