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:
parent
31bc9f3c7b
commit
0360780411
@ -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} />
|
||||
|
||||
45
app/components/admin/KpiCard.tsx
Normal file
45
app/components/admin/KpiCard.tsx
Normal 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;
|
||||
61
app/components/admin/OrdersBarChart.tsx
Normal file
61
app/components/admin/OrdersBarChart.tsx
Normal 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;
|
||||
23
app/hooks/useRequireAdmin.ts
Normal file
23
app/hooks/useRequireAdmin.ts
Normal 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 };
|
||||
}
|
||||
68
app/requestHandler/use-admin-hooks.ts
Normal file
68
app/requestHandler/use-admin-hooks.ts
Normal 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
153
app/routes/admin._index.tsx
Normal 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
144
app/routes/admin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -30,6 +30,7 @@ const protectedRoutes = [
|
||||
"/profile/setting",
|
||||
"/cart",
|
||||
"/store",
|
||||
"/admin",
|
||||
];
|
||||
export async function validateTokens(request: Request) {
|
||||
const session = await sessionStorage.getSession(
|
||||
|
||||
@ -6958,6 +6958,12 @@ export interface UserProfile {
|
||||
* @memberof UserProfile
|
||||
*/
|
||||
readonly isStaff?: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof UserProfile
|
||||
*/
|
||||
readonly isSuperuser?: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user