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 <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-07-25 11:53:08 +03:30
parent 31bc9f3c7b
commit 0360780411
9 changed files with 507 additions and 3 deletions

View File

@ -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 ? (
<></>
) : (
<MobileBottomNavigation hideOnDesktop={expandOnDesktop} />

View File

@ -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<string, string> = {
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 (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<div className="flex items-center justify-between">
<span className="text-GRAY text-[13px]">{label}</span>
{Icon && <Icon size={18} className="text-GRAY shrink-0" />}
</div>
<p
className={cn(
"mt-2 text-[24px] font-extrabold tabular-nums leading-tight",
ACCENT[accent]
)}
>
{value}
</p>
{sub && <p className="text-[12px] text-GRAY mt-1">{sub}</p>}
</div>
);
}
export default KpiCard;

View File

@ -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 (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-[15px]">سفارشهای ۱۴ روز اخیر</h3>
<span className="text-[12px] text-GRAY">
مجموع: {faNum(data.reduce((s, d) => s + d.orders, 0))} سفارش
</span>
</div>
<div className="flex items-end gap-1.5 h-[160px]" dir="ltr">
{data.map((d) => (
<div
key={d.date}
className="flex-1 h-full flex flex-col items-center justify-end gap-1 group"
title={`${dayLabel(d.date)}${faNum(d.orders)} سفارش، ${faNum(
d.revenue
)} تومان`}
>
<div className="w-full flex-1 flex items-end">
<div
className="w-full rounded-t-md bg-VITROWN_BLUE/80 group-hover:bg-VITROWN_BLUE transition-colors min-h-[2px]"
style={{ height: `${(d.orders / max) * 100}%` }}
/>
</div>
<span className="text-[10px] text-GRAY tabular-nums">
{dayLabel(d.date)}
</span>
</div>
))}
</div>
</div>
);
}
export default OrdersBarChart;

View File

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

View File

@ -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<T>(path: string, token: string): Promise<T> {
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<AdminOverview>("overview/", token as string),
});
}

153
app/routes/admin._index.tsx Normal file
View File

@ -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 (
<h2 className="text-[15px] font-extrabold mt-7 mb-3 first:mt-0">
{children}
</h2>
);
}
export default function AdminOverview() {
const { data, isLoading, isError } = useAdminOverview();
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-RED text-[14px]">خطا در بارگذاری آمار</p>
</div>
);
}
const { users, sellers, products, orders, money, ordersSeries } = data;
return (
<div>
<div className="hidden lg:flex items-end justify-between mb-6">
<h1 className="text-[28px] font-extrabold">نمای کلی</h1>
<span className="text-[12px] text-GRAY">بهروزرسانی خودکار هر دقیقه</span>
</div>
{/* Money + today */}
<SectionTitle>امروز و مالی</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="درآمد امروز"
value={faToman(orders.revenueToday)}
icon={TrendingUp}
accent="green"
/>
<KpiCard
label="سفارش امروز"
value={faNum(orders.ordersToday)}
icon={ShoppingBag}
/>
<KpiCard
label="کل درآمد (پرداخت‌شده)"
value={faToman(orders.revenueTotal)}
sub={`${faNum(orders.paidCount)} سفارش پرداخت‌شده`}
icon={TrendingUp}
/>
<KpiCard
label="برداشت‌های در انتظار"
value={faNum(money.payoutsPendingCount)}
sub={faToman(money.payoutsPendingAmount)}
icon={Clock}
accent={money.payoutsPendingCount > 0 ? "red" : "default"}
/>
<KpiCard
label="موجودی کیف پول کاربران"
value={faToman(money.walletHeld)}
icon={Wallet}
/>
<KpiCard
label="مانده قابل پرداخت فروشندگان"
value={faToman(money.sellerPayable)}
icon={Landmark}
/>
</div>
{/* Chart */}
<div className="mt-4">
<OrdersBarChart data={ordersSeries} />
</div>
{/* Orders status */}
<SectionTitle>سفارشها</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-6 gap-3">
<KpiCard label="کل سفارش‌ها" value={faNum(orders.total)} icon={ShoppingBag} />
<KpiCard label="در انتظار" value={faNum(orders.pending)} />
<KpiCard label="تأییدشده" value={faNum(orders.confirmed)} />
<KpiCard label="ارسال‌شده" value={faNum(orders.shipped)} />
<KpiCard label="تحویل‌شده" value={faNum(orders.delivered)} accent="green" />
<KpiCard label="لغوشده" value={faNum(orders.cancelled)} accent="red" />
</div>
{/* Sellers + Users + Products */}
<SectionTitle>فروشگاهها و کاربران</SectionTitle>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="فروشگاه‌ها"
value={faNum(sellers.total)}
sub={`${faNum(sellers.verified)} تأییدشده`}
icon={Store}
/>
<KpiCard
label="در انتظار تأیید"
value={faNum(sellers.pending)}
icon={Clock}
accent={sellers.pending > 0 ? "red" : "default"}
/>
<KpiCard
label="کاربران"
value={faNum(users.total)}
sub={`${faNum(users.active)} فعال`}
icon={UsersIcon}
/>
<KpiCard
label="کاربران جدید (۳۰ روز)"
value={faNum(users.new30d)}
sub={`${faNum(users.new7d)} در ۷ روز`}
icon={UsersIcon}
/>
<KpiCard
label="محصولات"
value={faNum(products.total)}
sub={`${faNum(products.active)} فعال`}
icon={Package}
/>
<KpiCard label="فروشندگان" value={faNum(users.sellers)} icon={Store} />
</div>
</div>
);
}

144
app/routes/admin.tsx Normal file
View File

@ -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 (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
}
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.icon size={20} />
{n.label}
{n.soon && (
<span className="ms-auto text-[10px] text-GRAY font-normal">
بهزودی
</span>
)}
</>
);
return n.soon ? (
<div key={n.to} className={cls} aria-disabled>
{inner}
</div>
) : (
<Link
key={n.to}
to={n.to}
prefetch="intent"
onClick={onNavigate}
className={cls}
>
{inner}
</Link>
);
})}
<Link
to="/"
className="flex items-center gap-3 px-3.5 py-3 rounded-xl text-[14.5px] font-semibold text-BLACK2 hover:bg-WHITE2 transition-colors"
>
<ArrowRight size={20} />
بازگشت به سایت
</Link>
</>
);
return (
<div className="lg:max-w-[1440px] lg:mx-auto lg:w-full lg:px-8 lg:py-8 lg:grid lg:grid-cols-[248px_1fr] lg:gap-7 lg:items-start">
{/* Desktop sidebar */}
<aside className="hidden lg:block lg:sticky lg:top-6 border border-WHITE3 rounded-2xl overflow-hidden">
<div className="p-4 border-b border-WHITE3 flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-BLACK text-white grid place-items-center shrink-0">
<ShieldCheck size={20} />
</div>
<div className="min-w-0">
<p className="font-bold text-[15px] truncate">پنل مدیریت</p>
<p className="text-GRAY text-[12px]">ویترون</p>
</div>
</div>
<nav className="p-2.5 flex flex-col gap-0.5">
<NavLinks />
</nav>
</aside>
{/* Mobile top bar + horizontal nav */}
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)] border-b border-WHITE3">
<div className="flex items-center gap-2 px-4 h-[56px]">
<div className="h-8 w-8 rounded-lg bg-BLACK text-white grid place-items-center">
<ShieldCheck size={16} />
</div>
<p className="font-bold text-[15px]">پنل مدیریت</p>
</div>
<div className="flex gap-2 overflow-x-auto px-4 pb-2 hide-scrollbar">
{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 ? (
<span key={n.to} className={cls}>
{n.label}
</span>
) : (
<Link key={n.to} to={n.to} prefetch="intent" className={cls}>
{n.label}
</Link>
);
})}
</div>
</div>
<div className="min-w-0 px-4 py-4 lg:p-0">
<Outlet />
</div>
</div>
);
}

View File

@ -30,6 +30,7 @@ const protectedRoutes = [
"/profile/setting",
"/cart",
"/store",
"/admin",
];
export async function validateTokens(request: Request) {
const session = await sessionStorage.getSession(

View File

@ -6958,6 +6958,12 @@ export interface UserProfile {
* @memberof UserProfile
*/
readonly isStaff?: boolean;
/**
*
* @type {boolean}
* @memberof UserProfile
*/
readonly isSuperuser?: boolean;
/**
*
* @type {boolean}