From ee566a8d0546255ed2d6c60b30031920cf61e7be Mon Sep 17 00:00:00 2001 From: MihuNt3r Date: Sat, 20 Jun 2026 13:37:06 +0300 Subject: [PATCH 1/3] Add admin UI for platform statistics and business verification - Add Statistics page showing aggregated platform metrics (users, account status, guest and business activity) - Add Verify Businesses page with paginated pending list and an approve/reject review flow (rejection requires a reason) - Wire up /admin/statistics and /admin/businesses routes and add sidebar nav links - Map shadcn design tokens via @theme inline so popover/muted/etc. utilities render correctly (fixes transparent dialog surface) Co-Authored-By: Claude Opus 4.8 --- frontend/src/App.css | 30 ++ frontend/src/App.tsx | 18 + frontend/src/api/client.ts | 29 ++ frontend/src/components/ui/Sidebar.tsx | 8 +- .../Admin/AdminPendingBusinessesPage.tsx | 308 ++++++++++++++++++ .../pages/guest/Admin/AdminStatisticsPage.tsx | 246 ++++++++++++++ frontend/src/types/api.ts | 51 +++ 7 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/guest/Admin/AdminPendingBusinessesPage.tsx create mode 100644 frontend/src/pages/guest/Admin/AdminStatisticsPage.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 9ed2d357..1dde7dcb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5,6 +5,36 @@ @custom-variant dark (&:is(.dark *)); +/* + * Map the design tokens below into Tailwind's color namespace so shadcn + * utilities (bg-popover, bg-muted, text-muted-foreground, border-border, …) + * are generated. Uses `inline` + var() so the .dark overrides keep working. + */ +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + :root { --background: #F9F7F2; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6c288224..8b9e0404 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,8 @@ import { ProfileCreatePage } from "@/pages/guest/UserProfile/ProfileCreatePage"; import { CreatePost } from "@/pages/guest/CreatePost/CreatePost"; import { AdminUsersPage } from "@/pages/guest/Admin/AdminUsersPage"; import { AdminUserDetailPage } from "@/pages/guest/Admin/AdminUserDetailPage"; +import { AdminStatisticsPage } from "@/pages/guest/Admin/AdminStatisticsPage"; +import { AdminPendingBusinessesPage } from "@/pages/guest/Admin/AdminPendingBusinessesPage"; import { ForbiddenPage } from "@/pages/guest/Forbidden/ForbiddenPage"; import { isUserRole } from "@/utils/auth"; @@ -166,6 +168,22 @@ function App() { } /> + + + + } + /> + + + + } + /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 11883166..5e2bc026 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -15,6 +15,10 @@ import type { FullUserDetails, AdminUsersParams, CollectionItem, + PlatformStatistics, + PaginatedPendingBusinesses, + ReviewBusinessStatus, + PaginationParams, } from "@/types/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; @@ -552,6 +556,31 @@ export const apiClient = { return res.data; }, + adminGetStatistics: async () => { + const res = await apiRoot.get("/admin/statistics"); + return res.data; + }, + + adminGetPendingBusinesses: async (params: PaginationParams = {}) => { + const res = await apiRoot.get( + "/admin/businesses/pending", + { params } + ); + return res.data; + }, + + adminReviewBusiness: async ( + orgUnitId: number, + status: ReviewBusinessStatus, + comment?: string + ) => { + const res = await apiRoot.patch<{ message: string }>( + `/admin/businesses/${orgUnitId}/review`, + { status, ...(comment ? { comment } : {}) } + ); + return res.data; + }, + updateUserStatus: async (userId: string, status: string) => { const res = await apiRoot.put<{ message: string }>( `/users/${userId}/status`, diff --git a/frontend/src/components/ui/Sidebar.tsx b/frontend/src/components/ui/Sidebar.tsx index 7dfcfe84..57acc5ba 100644 --- a/frontend/src/components/ui/Sidebar.tsx +++ b/frontend/src/components/ui/Sidebar.tsx @@ -183,9 +183,15 @@ export function Sidebar() { {isAdminOrModerator() ? ( <> - + Admin Users + + Statistics + + + Verify Businesses + ) : null} diff --git a/frontend/src/pages/guest/Admin/AdminPendingBusinessesPage.tsx b/frontend/src/pages/guest/Admin/AdminPendingBusinessesPage.tsx new file mode 100644 index 00000000..963aaec4 --- /dev/null +++ b/frontend/src/pages/guest/Admin/AdminPendingBusinessesPage.tsx @@ -0,0 +1,308 @@ +import { useEffect, useState } from "react"; +import { Building2, CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { toast } from "sonner"; +import { apiClient } from "@/api/client"; +import type { PendingBusinessListItem } from "@/types/api"; +import { PageLayout } from "@/components/layout/PageLayout"; +import { pageBtnPrimary, pageEmpty, pageLoader } from "@/components/layout/pageStyles"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +const PAGE_SIZE = 10; + +function businessInitial(name: string) { + return name.trim().charAt(0).toUpperCase() || "?"; +} + +export function AdminPendingBusinessesPage() { + const [businesses, setBusinesses] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [page, setPage] = useState(0); + const [actingId, setActingId] = useState(null); + + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectComment, setRejectComment] = useState(""); + + useEffect(() => { + let mounted = true; + const load = async () => { + setLoading(true); + setError(""); + try { + const data = await apiClient.adminGetPendingBusinesses({ + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + }); + if (mounted) { + setBusinesses(data.items ?? []); + setTotalCount(data.total_count ?? 0); + } + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: string } }; message?: string }; + if (mounted) { + setError( + e?.response?.data?.error || e?.message || "Failed to load pending businesses." + ); + } + } finally { + if (mounted) setLoading(false); + } + }; + void load(); + return () => { + mounted = false; + }; + }, [page]); + + const removeFromList = (id: number) => { + setBusinesses((prev) => prev.filter((b) => b.id !== id)); + setTotalCount((prev) => Math.max(0, prev - 1)); + }; + + const handleApprove = async (business: PendingBusinessListItem) => { + setActingId(business.id); + try { + await apiClient.adminReviewBusiness(business.id, "verified"); + toast.success(`"${business.name}" has been verified.`); + removeFromList(business.id); + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: string } }; message?: string }; + toast.error(e?.response?.data?.error || e?.message || "Failed to verify business."); + } finally { + setActingId(null); + } + }; + + const handleReject = async () => { + if (!rejectTarget) return; + const comment = rejectComment.trim(); + if (!comment) return; + setActingId(rejectTarget.id); + try { + await apiClient.adminReviewBusiness(rejectTarget.id, "rejected", comment); + toast.success(`"${rejectTarget.name}" has been rejected.`); + removeFromList(rejectTarget.id); + setRejectTarget(null); + setRejectComment(""); + } catch (err: unknown) { + const e = err as { response?: { data?: { error?: string } }; message?: string }; + toast.error(e?.response?.data?.error || e?.message || "Failed to reject business."); + } finally { + setActingId(null); + } + }; + + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const currentPage = page + 1; + + return ( + +
+

+ Admin β€” Verify Businesses{" "} + 🏒 +

+

+ Review and approve business accounts awaiting verification +

+
+ +
+ + {totalCount} pending + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( +
+ +
+ ) : businesses.length === 0 ? ( +
+

+ No businesses awaiting review +

+

All caught up β€” nothing to verify right now.

+
+ ) : ( +
+ {businesses.map((business) => { + const busy = actingId === business.id; + return ( + + +
+
+ {business.avatar ? ( + + ) : ( + businessInitial(business.name) + )} +
+
+

+ {business.name} +

+

+ + Org #{business.id} +

+
+
+ + {business.description ? ( +

+ {business.description} +

+ ) : ( +

No description provided.

+ )} + +
+ + +
+
+
+ ); + })} +
+ )} + + {totalPages > 1 ? ( +
+ +

+ Page {currentPage} of {totalPages} +

+ +
+ ) : null} + + { + if (!open) { + setRejectTarget(null); + setRejectComment(""); + } + }} + > + + + Reject business + + Rejecting {rejectTarget?.name}{" "} + requires a reason. It will be recorded with the review. + + +
+