From 036078041133abe0ad73bab585b265aff097c707 Mon Sep 17 00:00:00 2001 From: Arda Samadi Date: Sat, 25 Jul 2026 11:53:08 +0330 Subject: [PATCH] feat(admin): admin-panel foundation shell + overview (slice 0) - /admin opted out of shopper chrome in MyLayout; /admin added to server protectedRoutes; useRequireAdmin() gates to superusers (UserProfile.isSuperuser). - Admin shell (admin.tsx) with sidebar nav (Overview live; other sections stubbed) and a mobile top bar; Overview dashboard (KPI cards + 14-day orders chart) via use-admin-hooks.ts hitting /api/adminpanel/v1/overview/. Co-Authored-By: Claude Opus 4.8 --- app/components/MyLayout.tsx | 7 +- app/components/admin/KpiCard.tsx | 45 +++++++ app/components/admin/OrdersBarChart.tsx | 61 ++++++++++ app/hooks/useRequireAdmin.ts | 23 ++++ app/requestHandler/use-admin-hooks.ts | 68 +++++++++++ app/routes/admin._index.tsx | 153 ++++++++++++++++++++++++ app/routes/admin.tsx | 144 ++++++++++++++++++++++ app/utils/token-manager.server.ts | 1 + src/api/types/models/index.ts | 8 +- 9 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 app/components/admin/KpiCard.tsx create mode 100644 app/components/admin/OrdersBarChart.tsx create mode 100644 app/hooks/useRequireAdmin.ts create mode 100644 app/requestHandler/use-admin-hooks.ts create mode 100644 app/routes/admin._index.tsx create mode 100644 app/routes/admin.tsx diff --git a/app/components/MyLayout.tsx b/app/components/MyLayout.tsx index 9725848..cfbbf4b 100644 --- a/app/components/MyLayout.tsx +++ b/app/components/MyLayout.tsx @@ -52,7 +52,10 @@ const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => { // Seller dashboard: full-width canvas + its own sidebar (store.$storeId.tsx), // no shopper header/footer. const isStore = p.startsWith("/store/"); - const expandOnDesktop = isDesktopReady || isAuth || isStore; + // Admin panel: full-screen own layout (admin.tsx sidebar), no shopper chrome + // and no shopper bottom nav. + const isAdmin = p === "/admin" || p.startsWith("/admin/"); + const expandOnDesktop = isDesktopReady || isAuth || isStore || isAdmin; const isThread = !!location.pathname.split("threads")[1]; return ( @@ -71,7 +74,7 @@ const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => { }`} > {children} - {isThread ? ( + {isThread || isAdmin ? ( <> ) : ( diff --git a/app/components/admin/KpiCard.tsx b/app/components/admin/KpiCard.tsx new file mode 100644 index 0000000..bbcbcf8 --- /dev/null +++ b/app/components/admin/KpiCard.tsx @@ -0,0 +1,45 @@ +import type { LucideIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; + +interface KpiCardProps { + label: string; + value: string | number; + sub?: string; + icon?: LucideIcon; + accent?: "default" | "blue" | "green" | "red"; +} + +const ACCENT: Record = { + default: "text-BLACK", + blue: "text-VITROWN_BLUE", + green: "text-GREEN", + red: "text-RED", +}; + +export function KpiCard({ + label, + value, + sub, + icon: Icon, + accent = "default", +}: KpiCardProps) { + return ( +
+
+ {label} + {Icon && } +
+

+ {value} +

+ {sub &&

{sub}

} +
+ ); +} + +export default KpiCard; diff --git a/app/components/admin/OrdersBarChart.tsx b/app/components/admin/OrdersBarChart.tsx new file mode 100644 index 0000000..9879f05 --- /dev/null +++ b/app/components/admin/OrdersBarChart.tsx @@ -0,0 +1,61 @@ +/** + * Dependency-free 14-day orders bar chart for the admin overview. + * (A richer charting lib can replace this in a later slice.) + */ +interface Point { + date: string; + orders: number; + revenue: number; +} + +function faNum(n: number): string { + return Math.round(n).toLocaleString("fa-IR"); +} + +function dayLabel(iso: string): string { + // day-of-month in Persian digits + try { + const d = new Date(iso); + return d.toLocaleDateString("fa-IR", { day: "numeric" }); + } catch { + return ""; + } +} + +export function OrdersBarChart({ data }: { data: Point[] }) { + const max = Math.max(1, ...data.map((d) => d.orders)); + + return ( +
+
+

سفارش‌های ۱۴ روز اخیر

+ + مجموع: {faNum(data.reduce((s, d) => s + d.orders, 0))} سفارش + +
+
+ {data.map((d) => ( +
+
+
+
+ + {dayLabel(d.date)} + +
+ ))} +
+
+ ); +} + +export default OrdersBarChart; diff --git a/app/hooks/useRequireAdmin.ts b/app/hooks/useRequireAdmin.ts new file mode 100644 index 0000000..67ef411 --- /dev/null +++ b/app/hooks/useRequireAdmin.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useNavigate } from "@remix-run/react"; +import { useServiceGetProfile } from "~/requestHandler/use-profile-hooks"; + +/** + * Gate a route to platform superusers. Non-admins (or unauthenticated) are + * bounced to the home page. Server-side, `/admin` is also in `protectedRoutes` + * so logged-out users never reach here — this adds the is-superuser check. + */ +export function useRequireAdmin() { + const navigate = useNavigate(); + const { data, isLoading, isError } = useServiceGetProfile(); + const isAdmin = !!data?.isSuperuser; + + useEffect(() => { + if (isLoading) return; + if (isError || !isAdmin) { + navigate("/", { replace: true }); + } + }, [isLoading, isError, isAdmin, navigate]); + + return { isLoading, isAdmin }; +} diff --git a/app/requestHandler/use-admin-hooks.ts b/app/requestHandler/use-admin-hooks.ts new file mode 100644 index 0000000..7d96e9d --- /dev/null +++ b/app/requestHandler/use-admin-hooks.ts @@ -0,0 +1,68 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuthToken } from "~/hooks/use-root-data"; +import { getApiBaseUrl } from "~/utils/api-config"; + +/* ------------------------------------------------------------------ * + * Admin panel data hooks (superuser only). The admin API is a new, + * fast-moving surface, so these call it directly with fetch (same + * pattern as use-seller-hooks' useMyStores) rather than the generated + * client. Responses are camelCased by the backend renderer. + * ------------------------------------------------------------------ */ + +export interface AdminOverview { + generatedAt: string; + users: { + total: number; + active: number; + sellers: number; + new7d: number; + new30d: number; + }; + sellers: { + total: number; + pending: number; + approved: number; + rejected: number; + verified: number; + }; + products: { total: number; active: number }; + orders: { + total: number; + pending: number; + confirmed: number; + shipped: number; + delivered: number; + cancelled: number; + paidCount: number; + revenueTotal: number; + ordersToday: number; + revenueToday: number; + }; + money: { + walletHeld: number; + sellerPayable: number; + payoutsPendingCount: number; + payoutsPendingAmount: number; + }; + ordersSeries: Array<{ date: string; orders: number; revenue: number }>; +} + +async function adminGet(path: string, token: string): Promise { + const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + throw new Error(`Admin request failed (${res.status})`); + } + return res.json(); +} + +export function useAdminOverview() { + const token = useAuthToken(); + return useQuery({ + queryKey: ["admin", "overview"], + enabled: !!token, + refetchInterval: 60_000, + queryFn: () => adminGet("overview/", token as string), + }); +} diff --git a/app/routes/admin._index.tsx b/app/routes/admin._index.tsx new file mode 100644 index 0000000..a2b5c27 --- /dev/null +++ b/app/routes/admin._index.tsx @@ -0,0 +1,153 @@ +import type { MetaFunction } from "@remix-run/node"; +import { + Loader2, + Wallet, + Landmark, + ShoppingBag, + TrendingUp, + Users as UsersIcon, + Store, + Package, + Clock, +} from "lucide-react"; +import { useAdminOverview } from "~/requestHandler/use-admin-hooks"; +import { KpiCard } from "~/components/admin/KpiCard"; +import { OrdersBarChart } from "~/components/admin/OrdersBarChart"; + +export const meta: MetaFunction = () => [ + { title: "نمای کلی | پنل مدیریت ویترون" }, + { name: "robots", content: "noindex, nofollow" }, +]; + +const faNum = (n?: number) => Math.round(n || 0).toLocaleString("fa-IR"); +const faToman = (n?: number) => `${faNum(n)} تومان`; + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export default function AdminOverview() { + const { data, isLoading, isError } = useAdminOverview(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +
+

خطا در بارگذاری آمار

+
+ ); + } + + const { users, sellers, products, orders, money, ordersSeries } = data; + + return ( +
+
+

نمای کلی

+ به‌روزرسانی خودکار هر دقیقه +
+ + {/* Money + today */} + امروز و مالی +
+ + + + 0 ? "red" : "default"} + /> + + +
+ + {/* Chart */} +
+ +
+ + {/* Orders status */} + سفارش‌ها +
+ + + + + + +
+ + {/* Sellers + Users + Products */} + فروشگاه‌ها و کاربران +
+ + 0 ? "red" : "default"} + /> + + + + +
+
+ ); +} diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx new file mode 100644 index 0000000..65c63cb --- /dev/null +++ b/app/routes/admin.tsx @@ -0,0 +1,144 @@ +import { Link, Outlet, useLocation } from "@remix-run/react"; +import { + LayoutDashboard, + ClipboardList, + Store, + Users, + Package, + Landmark, + ArrowRight, + ShieldCheck, + Loader2, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { useRequireAdmin } from "~/hooks/useRequireAdmin"; + +interface NavItem { + label: string; + to: string; + icon: LucideIcon; + exact?: boolean; + soon?: boolean; // planned but not built yet +} + +const NAV: NavItem[] = [ + { label: "نمای کلی", to: "/admin", icon: LayoutDashboard, exact: true }, + { label: "سفارش‌ها", to: "/admin/orders", icon: ClipboardList, soon: true }, + { label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store, soon: true }, + { label: "کاربران", to: "/admin/users", icon: Users, soon: true }, + { label: "محصولات", to: "/admin/products", icon: Package, soon: true }, + { label: "دفتر مالی", to: "/admin/ledger", icon: Landmark, soon: true }, +]; + +export default function AdminLayout() { + const location = useLocation(); + const { isLoading, isAdmin } = useRequireAdmin(); + + const isActive = (item: NavItem) => + item.exact + ? location.pathname === item.to + : location.pathname.startsWith(item.to); + + if (isLoading || !isAdmin) { + return ( +
+ +
+ ); + } + + const NavLinks = ({ onNavigate }: { onNavigate?: () => void }) => ( + <> + {NAV.map((n) => { + const active = isActive(n); + const cls = `flex items-center gap-3 px-3.5 py-3 rounded-xl text-[14.5px] font-semibold transition-colors ${ + active ? "bg-BLACK text-white" : "text-BLACK2 hover:bg-WHITE2" + } ${n.soon ? "opacity-45 cursor-not-allowed" : ""}`; + const inner = ( + <> + + {n.label} + {n.soon && ( + + به‌زودی + + )} + + ); + return n.soon ? ( +
+ {inner} +
+ ) : ( + + {inner} + + ); + })} + + + بازگشت به سایت + + + ); + + return ( +
+ {/* Desktop sidebar */} + + + {/* Mobile top bar + horizontal nav */} +
+
+
+ +
+

پنل مدیریت

+
+
+ {NAV.map((n) => { + const active = isActive(n); + const cls = `whitespace-nowrap px-3 py-1.5 rounded-full text-[13px] font-semibold ${ + active ? "bg-BLACK text-white" : "bg-WHITE2 text-BLACK2" + } ${n.soon ? "opacity-45" : ""}`; + return n.soon ? ( + + {n.label} + + ) : ( + + {n.label} + + ); + })} +
+
+ +
+ +
+
+ ); +} diff --git a/app/utils/token-manager.server.ts b/app/utils/token-manager.server.ts index 73983e7..d15e4ba 100644 --- a/app/utils/token-manager.server.ts +++ b/app/utils/token-manager.server.ts @@ -30,6 +30,7 @@ const protectedRoutes = [ "/profile/setting", "/cart", "/store", + "/admin", ]; export async function validateTokens(request: Request) { const session = await sessionStorage.getSession( diff --git a/src/api/types/models/index.ts b/src/api/types/models/index.ts index 59f4454..aa772d0 100644 --- a/src/api/types/models/index.ts +++ b/src/api/types/models/index.ts @@ -6959,7 +6959,13 @@ export interface UserProfile { */ readonly isStaff?: boolean; /** - * + * + * @type {boolean} + * @memberof UserProfile + */ + readonly isSuperuser?: boolean; + /** + * * @type {boolean} * @memberof UserProfile */