feat(admin): monitoring boards UI — orders/sellers/users/products (slice 1)

- Reusable admin primitives: AdminTable, AdminPagination, AdminToolbar,
  AdminSearch/Select, StatusBadge + fa number/date/toman + useDebouncedValue.
- Four board routes with search, filters, paging:
  orders (read-only), sellers (approve/reject/verify), users (ban/unban),
  products (active toggle). Wired into the admin sidebar nav + hooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-07-25 12:17:22 +03:30
parent f8bf002b39
commit 935c337df0
7 changed files with 1061 additions and 5 deletions

View File

@ -0,0 +1,248 @@
import { useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, Search, Loader2 } from "lucide-react";
import { cn } from "~/lib/utils";
/* ------------------------------ helpers ------------------------------ */
export const faNum = (n?: number | string | null) =>
n === null || n === undefined || n === ""
? "—"
: Number(n).toLocaleString("fa-IR");
export const faToman = (n?: number | null) =>
n === null || n === undefined ? "—" : `${Math.round(n).toLocaleString("fa-IR")} تومان`;
export const faDate = (iso?: string | null) => {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("fa-IR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return "—";
}
};
/** Debounce a rapidly-changing value (e.g. a search box). */
export function useDebouncedValue<T>(value: T, delay = 350): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
/* ---------------------------- status badge --------------------------- */
type Tone = "green" | "red" | "yellow" | "blue" | "gray";
const TONE: Record<Tone, string> = {
green: "bg-GREEN/10 text-GREEN",
red: "bg-RED/10 text-RED",
yellow: "bg-yellow-500/10 text-yellow-600",
blue: "bg-VITROWN_BLUE/10 text-VITROWN_BLUE",
gray: "bg-WHITE3 text-BLACK2",
};
export function StatusBadge({ label, tone }: { label: string; tone: Tone }) {
return (
<span
className={cn(
"inline-block px-2 py-0.5 rounded-full text-[11px] font-semibold whitespace-nowrap",
TONE[tone]
)}
>
{label}
</span>
);
}
/* ------------------------------- table ------------------------------- */
export interface Column<T> {
header: string;
render: (row: T) => React.ReactNode;
className?: string;
}
export function AdminTable<T>({
columns,
rows,
isLoading,
getRowKey,
emptyText = "موردی یافت نشد",
}: {
columns: Column<T>[];
rows: T[];
isLoading?: boolean;
getRowKey: (row: T) => string;
emptyText?: string;
}) {
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right border-collapse">
<thead>
<tr className="bg-WHITE2 text-GRAY text-[12px]">
{columns.map((c, i) => (
<th
key={i}
className={cn("px-3 py-3 font-bold whitespace-nowrap", c.className)}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={columns.length} className="py-12 text-center">
<Loader2 className="inline h-6 w-6 animate-spin text-GRAY" />
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="py-12 text-center text-GRAY text-[13px]"
>
{emptyText}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={getRowKey(row)}
className="border-t border-WHITE3 hover:bg-WHITE2/60 transition-colors text-[13px]"
>
{columns.map((c, i) => (
<td
key={i}
className={cn("px-3 py-3 align-middle", c.className)}
>
{c.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
/* ---------------------------- pagination ----------------------------- */
export function AdminPagination({
page,
pageSize,
count,
onPageChange,
}: {
page: number;
pageSize: number;
count: number;
onPageChange: (page: number) => void;
}) {
const totalPages = Math.max(1, Math.ceil(count / pageSize));
const from = count === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(count, page * pageSize);
const btn =
"h-8 w-8 grid place-items-center rounded-lg border border-WHITE3 text-BLACK2 disabled:opacity-40 hover:bg-WHITE2 transition-colors cursor-pointer disabled:cursor-default";
return (
<div className="flex items-center justify-between mt-3 text-[13px] text-GRAY">
<span>
{faNum(from)}{faNum(to)} از {faNum(count)}
</span>
<div className="flex items-center gap-1.5">
<button
className={btn}
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="صفحه قبل"
>
<ChevronRight size={16} />
</button>
<span className="tabular-nums px-1">
{faNum(page)} / {faNum(totalPages)}
</span>
<button
className={btn}
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="صفحه بعد"
>
<ChevronLeft size={16} />
</button>
</div>
</div>
);
}
/* --------------------------- toolbar bits ---------------------------- */
export function AdminSearch({
value,
onChange,
placeholder = "جستجو…",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
<Search
size={16}
className="absolute right-3 top-1/2 -translate-y-1/2 text-GRAY"
/>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-WHITE3 bg-WHITE pr-9 pl-3 py-2 text-[13px] outline-none focus:border-VITROWN_BLUE"
/>
</div>
);
}
export function AdminSelect({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13px] outline-none focus:border-VITROWN_BLUE cursor-pointer"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
export function AdminToolbar({ children }: { children: React.ReactNode }) {
return <div className="flex flex-wrap items-center gap-2 mb-3">{children}</div>;
}
export function AdminPageTitle({ children }: { children: React.ReactNode }) {
return (
<h1 className="text-[22px] lg:text-[26px] font-extrabold mb-4">{children}</h1>
);
}

View File

@ -1,4 +1,9 @@
import { useQuery } from "@tanstack/react-query"; import {
useQuery,
useMutation,
useQueryClient,
keepPreviousData,
} from "@tanstack/react-query";
import { useAuthToken } from "~/hooks/use-root-data"; import { useAuthToken } from "~/hooks/use-root-data";
import { getApiBaseUrl } from "~/utils/api-config"; import { getApiBaseUrl } from "~/utils/api-config";
@ -57,6 +62,25 @@ async function adminGet<T>(path: string, token: string): Promise<T> {
return res.json(); return res.json();
} }
async function adminPatch<T>(
path: string,
body: Record<string, unknown>,
token: string
): Promise<T> {
const res = await fetch(`${getApiBaseUrl()}/api/adminpanel/v1/${path}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Admin update failed (${res.status})`);
}
return res.json();
}
export function useAdminOverview() { export function useAdminOverview() {
const token = useAuthToken(); const token = useAuthToken();
return useQuery({ return useQuery({
@ -66,3 +90,117 @@ export function useAdminOverview() {
queryFn: () => adminGet<AdminOverview>("overview/", token as string), queryFn: () => adminGet<AdminOverview>("overview/", token as string),
}); });
} }
/* ------------------------------ boards ------------------------------ */
export interface Paginated<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export type AdminListParams = Record<
string,
string | number | boolean | undefined
>;
function toQuery(params: AdminListParams): string {
const usp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== "" && v !== null) usp.set(k, String(v));
});
const s = usp.toString();
return s ? `?${s}` : "";
}
function useAdminList<T>(key: string, params: AdminListParams) {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", key, params],
enabled: !!token,
placeholderData: keepPreviousData,
queryFn: () =>
adminGet<Paginated<T>>(`${key}/${toQuery(params)}`, token as string),
});
}
export interface AdminOrderRow {
id: string;
createdAt: string;
status: string;
paymentStatus: string;
totalPrice: number;
userPhone: string | null;
userName: string | null;
sellerName: string | null;
sellerUsername: string | null;
}
export interface AdminSellerRow {
id: string;
username: string;
storeName: string;
storeLogo: string | null;
status: string;
isVerified: boolean;
platformFee: number;
productCount: number;
followerCount: number;
reviewAverage: number;
createdAt: string;
userPhone: string | null;
}
export interface AdminUserRow {
id: string;
phoneNumber: string;
username: string | null;
email: string | null;
isActive: boolean;
isSeller: boolean;
isStaff: boolean;
isSuperuser: boolean;
otpVerified: boolean;
createdAt: string;
}
export interface AdminProductRow {
id: string;
title: string;
basePrice: number;
isActive: boolean;
createdAt: string;
sellerName: string | null;
sellerUsername: string | null;
categoryName: string | null;
primaryImage: string | null;
}
export const useAdminOrders = (params: AdminListParams) =>
useAdminList<AdminOrderRow>("orders", params);
export const useAdminSellers = (params: AdminListParams) =>
useAdminList<AdminSellerRow>("sellers", params);
export const useAdminUsers = (params: AdminListParams) =>
useAdminList<AdminUserRow>("users", params);
export const useAdminProducts = (params: AdminListParams) =>
useAdminList<AdminProductRow>("products", params);
/* ----------------------------- actions ------------------------------ */
function useAdminUpdate(resource: "sellers" | "users" | "products") {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
adminPatch(`${resource}/${id}/`, data, token as string),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", resource] });
qc.invalidateQueries({ queryKey: ["admin", "overview"] });
},
});
}
export const useAdminSellerUpdate = () => useAdminUpdate("sellers");
export const useAdminUserUpdate = () => useAdminUpdate("users");
export const useAdminProductUpdate = () => useAdminUpdate("products");

150
app/routes/admin.orders.tsx Normal file
View File

@ -0,0 +1,150 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { useAdminOrders } from "~/requestHandler/use-admin-hooks";
import type { AdminOrderRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "سفارش‌ها | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" | "blue" | "gray" }> = {
pending: { label: "در انتظار", tone: "yellow" },
confirmed: { label: "تأییدشده", tone: "blue" },
shipped: { label: "ارسال‌شده", tone: "blue" },
delivered: { label: "تحویل‌شده", tone: "green" },
cancelled: { label: "لغوشده", tone: "red" },
};
const PAYMENT: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
paid: { label: "پرداخت‌شده", tone: "green" },
pending: { label: "در انتظار", tone: "yellow" },
failed: { label: "ناموفق", tone: "red" },
};
export default function AdminOrders() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [payment, setPayment] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useAdminOrders({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
payment_status: payment,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<AdminOrderRow>[] = [
{
header: "کد",
render: (o) => <span className="tabular-nums text-GRAY">{o.id.slice(0, 8)}</span>,
},
{
header: "خریدار",
render: (o) => (
<div className="min-w-[120px]">
<p className="font-semibold">{o.userName || "—"}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{o.userPhone || ""}
</p>
</div>
),
},
{
header: "فروشگاه",
render: (o) => <span>{o.sellerName || o.sellerUsername || "—"}</span>,
},
{
header: "مبلغ",
render: (o) => <span className="tabular-nums whitespace-nowrap">{faToman(o.totalPrice)}</span>,
},
{
header: "پرداخت",
render: (o) => {
const p = PAYMENT[o.paymentStatus] || { label: o.paymentStatus, tone: "gray" as const };
return <StatusBadge label={p.label} tone={p.tone} />;
},
},
{
header: "وضعیت",
render: (o) => {
const s = STATUS[o.status] || { label: o.status, tone: "gray" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{
header: "تاریخ",
render: (o) => <span className="text-GRAY whitespace-nowrap">{faDate(o.createdAt)}</span>,
},
];
return (
<div>
<AdminPageTitle>سفارشها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی خریدار یا فروشگاه…"
/>
<AdminSelect
value={status}
onChange={(v) => reset(() => setStatus(v))}
options={[
{ value: "", label: "همه وضعیت‌ها" },
{ value: "pending", label: "در انتظار" },
{ value: "confirmed", label: "تأییدشده" },
{ value: "shipped", label: "ارسال‌شده" },
{ value: "delivered", label: "تحویل‌شده" },
{ value: "cancelled", label: "لغوشده" },
]}
/>
<AdminSelect
value={payment}
onChange={(v) => reset(() => setPayment(v))}
options={[
{ value: "", label: "همه پرداخت‌ها" },
{ value: "paid", label: "پرداخت‌شده" },
{ value: "pending", label: "در انتظار" },
{ value: "failed", label: "ناموفق" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(o) => o.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -0,0 +1,160 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Link } from "@remix-run/react";
import { Loader2, Eye, EyeOff } from "lucide-react";
import {
useAdminProducts,
useAdminProductUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminProductRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "محصولات | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
export default function AdminProducts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminProductUpdate();
const { data, isLoading } = useAdminProducts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminProductRow>[] = [
{
header: "محصول",
render: (p) => (
<Link
to={`/product/${p.id}`}
className="flex items-center gap-2 min-w-[180px] hover:opacity-80"
>
<div className="h-10 w-10 rounded-lg bg-WHITE3 overflow-hidden shrink-0">
{p.primaryImage && (
<img src={p.primaryImage} alt="" className="h-full w-full object-cover" />
)}
</div>
<p className="font-semibold truncate max-w-[220px]">{p.title}</p>
</Link>
),
},
{
header: "فروشگاه",
render: (p) => <span>{p.sellerName || p.sellerUsername || "—"}</span>,
},
{
header: "دسته",
render: (p) => <span className="text-GRAY">{p.categoryName || "—"}</span>,
},
{
header: "قیمت",
render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.basePrice)}</span>,
},
{
header: "وضعیت",
render: (p) =>
p.isActive ? (
<StatusBadge label="فعال" tone="green" />
) : (
<StatusBadge label="غیرفعال" tone="gray" />
),
},
{
header: "تاریخ",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (p) => {
const busy = pendingId === p.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex justify-end">
{p.isActive ? (
<button
className={`${actBtn} bg-WHITE2 text-BLACK2 hover:bg-WHITE3`}
onClick={() => update.mutate({ id: p.id, data: { isActive: false } })}
>
<EyeOff size={13} /> غیرفعال
</button>
) : (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: p.id, data: { isActive: true } })}
>
<Eye size={13} /> فعال
</button>
)}
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>محصولات</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی عنوان یا فروشگاه…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", label: "غیرفعال" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(p) => p.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -0,0 +1,193 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Check, X, BadgeCheck, Loader2 } from "lucide-react";
import {
useAdminSellers,
useAdminSellerUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminSellerRow } from "~/requestHandler/use-admin-hooks";
import SellerLogo from "~/components/SellerLogo";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "فروشگاه‌ها | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
approved: { label: "تأییدشده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
export default function AdminSellers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [verified, setVerified] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminSellerUpdate();
const { data, isLoading } = useAdminSellers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
is_verified: verified,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminSellerRow>[] = [
{
header: "فروشگاه",
render: (s) => (
<div className="flex items-center gap-2 min-w-[160px]">
<SellerLogo size="sm" src={s.storeLogo || undefined} alt={s.storeName} />
<div className="min-w-0">
<p className="font-semibold truncate">{s.storeName}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{s.username}</p>
</div>
</div>
),
},
{
header: "تلفن",
render: (s) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{s.userPhone || "—"}
</span>
),
},
{
header: "وضعیت",
render: (s) => {
const st = STATUS[s.status] || { label: s.status, tone: "yellow" as const };
return (
<div className="flex items-center gap-1.5">
<StatusBadge label={st.label} tone={st.tone} />
{s.isVerified && <BadgeCheck size={15} className="text-VITROWN_BLUE" />}
</div>
);
},
},
{
header: "کمیسیون",
render: (s) => <span className="tabular-nums">{faNum(s.platformFee)}٪</span>,
},
{
header: "محصولات",
render: (s) => <span className="tabular-nums">{faNum(s.productCount)}</span>,
},
{
header: "دنبال‌کننده",
render: (s) => <span className="tabular-nums">{faNum(s.followerCount)}</span>,
},
{
header: "تاریخ",
render: (s) => <span className="text-GRAY whitespace-nowrap">{faDate(s.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (s) => {
const busy = pendingId === s.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex items-center gap-1.5 justify-end">
{s.status !== "approved" && (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: s.id, data: { status: "approved" } })}
>
<Check size={13} /> تأیید
</button>
)}
{s.status !== "rejected" && (
<button
className={`${actBtn} bg-RED/10 text-RED hover:bg-RED/20`}
onClick={() => update.mutate({ id: s.id, data: { status: "rejected" } })}
>
<X size={13} /> رد
</button>
)}
<button
className={`${actBtn} bg-WHITE2 text-BLACK2 hover:bg-WHITE3`}
onClick={() => update.mutate({ id: s.id, data: { isVerified: !s.isVerified } })}
>
{s.isVerified ? "لغو احراز" : "احراز هویت"}
</button>
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>فروشگاهها</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی نام یا آیدی فروشگاه…"
/>
<AdminSelect
value={status}
onChange={(v) => reset(() => setStatus(v))}
options={[
{ value: "", label: "همه وضعیت‌ها" },
{ value: "pending", label: "در انتظار" },
{ value: "approved", label: "تأییدشده" },
{ value: "rejected", label: "ردشده" },
]}
/>
<AdminSelect
value={verified}
onChange={(v) => reset(() => setVerified(v))}
options={[
{ value: "", label: "احراز: همه" },
{ value: "true", label: "احرازشده" },
{ value: "false", label: "احرازنشده" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(s) => s.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}

View File

@ -23,10 +23,10 @@ interface NavItem {
const NAV: NavItem[] = [ const NAV: NavItem[] = [
{ label: "نمای کلی", to: "/admin", icon: LayoutDashboard, exact: true }, { label: "نمای کلی", to: "/admin", icon: LayoutDashboard, exact: true },
{ label: "سفارش‌ها", to: "/admin/orders", icon: ClipboardList, soon: true }, { label: "سفارش‌ها", to: "/admin/orders", icon: ClipboardList },
{ label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store, soon: true }, { label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store },
{ label: "کاربران", to: "/admin/users", icon: Users, soon: true }, { label: "کاربران", to: "/admin/users", icon: Users },
{ label: "محصولات", to: "/admin/products", icon: Package, soon: true }, { label: "محصولات", to: "/admin/products", icon: Package },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark, soon: true }, { label: "دفتر مالی", to: "/admin/ledger", icon: Landmark, soon: true },
]; ];

167
app/routes/admin.users.tsx Normal file
View File

@ -0,0 +1,167 @@
import type { MetaFunction } from "@remix-run/node";
import { useState } from "react";
import { Loader2, Ban, RotateCcw } from "lucide-react";
import {
useAdminUsers,
useAdminUserUpdate,
} from "~/requestHandler/use-admin-hooks";
import type { AdminUserRow } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
export const meta: MetaFunction = () => [
{ title: "کاربران | پنل مدیریت" },
{ name: "robots", content: "noindex, nofollow" },
];
const PAGE_SIZE = 25;
export default function AdminUsers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [active, setActive] = useState("");
const [role, setRole] = useState("");
const debouncedSearch = useDebouncedValue(search);
const update = useAdminUserUpdate();
const { data, isLoading } = useAdminUsers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
is_active: active,
is_seller: role === "seller" ? "true" : undefined,
is_staff: role === "staff" ? "true" : undefined,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const pendingId = update.isPending ? update.variables?.id : null;
const actBtn =
"inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] font-semibold transition-colors disabled:opacity-40 cursor-pointer";
const columns: Column<AdminUserRow>[] = [
{
header: "تلفن",
render: (u) => (
<span className="tabular-nums font-semibold" dir="ltr">
{u.phoneNumber}
</span>
),
},
{
header: "نام کاربری",
render: (u) => <span>{u.username || "—"}</span>,
},
{
header: "نقش",
render: (u) => (
<div className="flex items-center gap-1 flex-wrap">
{u.isSuperuser && <StatusBadge label="سوپرادمین" tone="red" />}
{u.isStaff && !u.isSuperuser && <StatusBadge label="ادمین" tone="blue" />}
{u.isSeller && <StatusBadge label="فروشنده" tone="gray" />}
{!u.isSeller && !u.isStaff && !u.isSuperuser && (
<span className="text-GRAY text-[12px]">کاربر</span>
)}
</div>
),
},
{
header: "وضعیت",
render: (u) =>
u.isActive ? (
<StatusBadge label="فعال" tone="green" />
) : (
<StatusBadge label="مسدود" tone="red" />
),
},
{
header: "عضویت",
render: (u) => <span className="text-GRAY whitespace-nowrap">{faDate(u.createdAt)}</span>,
},
{
header: "عملیات",
className: "text-left",
render: (u) => {
const busy = pendingId === u.id;
if (busy)
return <Loader2 size={16} className="animate-spin text-GRAY inline" />;
return (
<div className="flex justify-end">
{u.isActive ? (
<button
className={`${actBtn} bg-RED/10 text-RED hover:bg-RED/20`}
onClick={() => update.mutate({ id: u.id, data: { isActive: false } })}
>
<Ban size={13} /> مسدود
</button>
) : (
<button
className={`${actBtn} bg-GREEN/10 text-GREEN hover:bg-GREEN/20`}
onClick={() => update.mutate({ id: u.id, data: { isActive: true } })}
>
<RotateCcw size={13} /> فعالسازی
</button>
)}
</div>
);
},
},
];
return (
<div>
<AdminPageTitle>کاربران</AdminPageTitle>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی تلفن، نام کاربری یا ایمیل…"
/>
<AdminSelect
value={active}
onChange={(v) => reset(() => setActive(v))}
options={[
{ value: "", label: "همه" },
{ value: "true", label: "فعال" },
{ value: "false", label: "مسدود" },
]}
/>
<AdminSelect
value={role}
onChange={(v) => reset(() => setRole(v))}
options={[
{ value: "", label: "همه نقش‌ها" },
{ value: "seller", label: "فروشنده" },
{ value: "staff", label: "ادمین" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(u) => u.id}
/>
<AdminPagination
page={page}
pageSize={PAGE_SIZE}
count={data?.count || 0}
onPageChange={setPage}
/>
</div>
);
}