Vitron-Front/app/routes/admin.sellers.tsx
Arda Samadi 935c337df0 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>
2026-07-25 12:17:22 +03:30

194 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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