feat(admin): ledger UI — overview/reconciliation, payouts, transactions, per-seller (slice 2a)

Ledger section with sub-tabs: overview (money held/owed, payouts by status, and
a reconciliation warning card), payouts + transactions lists, per-seller balances
list, and a per-seller detail showing recorded vs derived balance + discrepancy,
bank accounts and payout history. Enabled the Ledger nav item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-07-25 12:28:11 +03:30
parent 935c337df0
commit cedeef4746
8 changed files with 710 additions and 1 deletions

View File

@ -204,3 +204,101 @@ function useAdminUpdate(resource: "sellers" | "users" | "products") {
export const useAdminSellerUpdate = () => useAdminUpdate("sellers"); export const useAdminSellerUpdate = () => useAdminUpdate("sellers");
export const useAdminUserUpdate = () => useAdminUpdate("users"); export const useAdminUserUpdate = () => useAdminUpdate("users");
export const useAdminProductUpdate = () => useAdminUpdate("products"); export const useAdminProductUpdate = () => useAdminUpdate("products");
/* ------------------------------ ledger ------------------------------ */
export interface LedgerOverview {
walletHeld: number;
sellerPayable: number;
depositsTotal: number;
purchasesTotal: number;
payouts: {
pending: { amount: number; count: number };
completed: { amount: number; count: number };
rejected: { amount: number; count: number };
};
reconciliation: {
completedPayoutsNotDebited: number;
sellerPayableRecorded: number;
sellerPayableEstimatedTrue: number;
};
}
export interface LedgerTransactionRow {
id: string;
transactionType: string;
amount: number;
userPhone: string | null;
order: string | null;
authority: string | null;
createdAt: string;
}
export interface LedgerPayoutRow {
id: string;
sellerName: string | null;
sellerUsername: string | null;
amount: number;
status: string;
requestedAt: string;
processedAt: string | null;
iban: string | null;
bankName: string | null;
accountHolder: string | null;
shomareMarja: string | null;
shomareErja: string | null;
}
export interface LedgerSellerRow {
sellerId: string;
storeName: string | null;
username: string | null;
availableFunds: number;
completedPayouts: number;
pendingPayouts: number;
}
export interface LedgerSellerDetail {
seller: { id: string; storeName: string; username: string; platformFee: number };
recordedBalance: number;
accruedFromOrders: number;
completedPayouts: number;
pendingPayouts: number;
derivedBalance: number;
discrepancy: number;
bankAccounts: Array<{
id: string;
accountHolder: string;
iban: string;
cardNumber: string;
bankName: string;
isVerified: boolean;
}>;
payouts: LedgerPayoutRow[];
}
export function useLedgerOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ledger", "overview"],
enabled: !!token,
queryFn: () => adminGet<LedgerOverview>("ledger/overview/", token as string),
});
}
export const useLedgerTransactions = (params: AdminListParams) =>
useAdminList<LedgerTransactionRow>("ledger/transactions", params);
export const useLedgerPayouts = (params: AdminListParams) =>
useAdminList<LedgerPayoutRow>("ledger/payouts", params);
export const useLedgerSellers = (params: AdminListParams) =>
useAdminList<LedgerSellerRow>("ledger/sellers", params);
export function useLedgerSellerDetail(id: string | undefined) {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "ledger", "seller", id],
enabled: !!token && !!id,
queryFn: () =>
adminGet<LedgerSellerDetail>(`ledger/sellers/${id}/`, token as string),
});
}

View File

@ -0,0 +1,112 @@
import {
Loader2,
Wallet,
Landmark,
ArrowDownCircle,
ArrowUpCircle,
AlertTriangle,
Clock,
CheckCircle2,
} from "lucide-react";
import { useLedgerOverview } from "~/requestHandler/use-admin-hooks";
import { KpiCard } from "~/components/admin/KpiCard";
import { faToman, faNum } from "~/components/admin/DataTable";
export default function LedgerOverview() {
const { data, isLoading, isError } = useLedgerOverview();
if (isLoading)
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری دفتر مالی</p>;
const { payouts, reconciliation } = data;
const hasDiscrepancy = reconciliation.completedPayoutsNotDebited > 0;
return (
<div>
{/* Money held / owed */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard label="موجودی کیف پول کاربران" value={faToman(data.walletHeld)} icon={Wallet} />
<KpiCard label="مانده قابل پرداخت فروشندگان" value={faToman(data.sellerPayable)} icon={Landmark} />
<KpiCard label="کل واریزها (شارژ کیف پول)" value={faToman(data.depositsTotal)} icon={ArrowDownCircle} accent="green" />
<KpiCard label="کل خریدها" value={faToman(data.purchasesTotal)} icon={ArrowUpCircle} />
</div>
{/* Payouts by status */}
<h2 className="text-[15px] font-extrabold mt-7 mb-3">برداشتها</h2>
<div className="grid grid-cols-3 gap-3">
<KpiCard
label="در انتظار"
value={faToman(payouts.pending.amount)}
sub={`${faNum(payouts.pending.count)} درخواست`}
icon={Clock}
accent={payouts.pending.count > 0 ? "red" : "default"}
/>
<KpiCard
label="پرداخت‌شده"
value={faToman(payouts.completed.amount)}
sub={`${faNum(payouts.completed.count)} مورد`}
icon={CheckCircle2}
accent="green"
/>
<KpiCard
label="ردشده"
value={faToman(payouts.rejected.amount)}
sub={`${faNum(payouts.rejected.count)} مورد`}
/>
</div>
{/* Reconciliation warning */}
{hasDiscrepancy && (
<div className="mt-7 rounded-2xl border border-RED/30 bg-RED/5 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-RED shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-extrabold text-[15px] text-RED">
مغایرت حسابداری شناسایی شد
</h3>
<p className="text-[13px] text-BLACK2 mt-1 leading-6">
مبلغ{" "}
<b className="tabular-nums">
{faToman(reconciliation.completedPayoutsNotDebited)}
</b>{" "}
بهعنوان برداشت «پرداختشده» ثبت شده اما از مانده فروشندگان کسر
نشده است. به همین دلیل «مانده قابل پرداخت» فعلی احتمالاً بیش از
مقدار واقعی است.
</p>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">مانده ثبتشده</p>
<p className="font-bold tabular-nums mt-1">
{faToman(reconciliation.sellerPayableRecorded)}
</p>
</div>
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">برآورد مانده واقعی</p>
<p className="font-bold tabular-nums mt-1 text-GREEN">
{faToman(reconciliation.sellerPayableEstimatedTrue)}
</p>
</div>
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
<p className="text-[12px] text-GRAY">اختلاف</p>
<p className="font-bold tabular-nums mt-1 text-RED">
{faToman(reconciliation.completedPayoutsNotDebited)}
</p>
</div>
</div>
<p className="text-[12px] text-GRAY mt-3">
این مورد در فاز بعدی (دفتر روزنامهای و اصلاح جریان پول) رفع خواهد
شد.
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,112 @@
import { useState } from "react";
import {
useLedgerPayouts,
type LedgerPayoutRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
completed: { label: "پرداخت‌شده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
export default function LedgerPayouts() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerPayouts({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
status,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerPayoutRow>[] = [
{
header: "فروشگاه",
render: (p) => (
<div className="min-w-[130px]">
<p className="font-semibold">{p.sellerName || "—"}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{p.sellerUsername}</p>
</div>
),
},
{
header: "مبلغ",
render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span>,
},
{
header: "وضعیت",
render: (p) => {
const s = STATUS[p.status] || { label: p.status, tone: "yellow" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{
header: "بانک",
render: (p) => (
<div className="min-w-[140px] text-[11px] text-GRAY" dir="ltr">
<p>{p.bankName || "—"}</p>
<p className="tabular-nums">{p.iban || ""}</p>
</div>
),
},
{
header: "درخواست",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.requestedAt)}</span>,
},
{
header: "پرداخت",
render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.processedAt)}</span>,
},
];
return (
<div>
<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: "completed", label: "پرداخت‌شده" },
{ value: "rejected", 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,150 @@
import { Link, useParams } from "@remix-run/react";
import { Loader2, ArrowRight, BadgeCheck } from "lucide-react";
import { useLedgerSellerDetail } from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
StatusBadge,
faToman,
faNum,
faDate,
type Column,
} from "~/components/admin/DataTable";
import type { LedgerPayoutRow } from "~/requestHandler/use-admin-hooks";
import { cn } from "~/lib/utils";
const PAYOUT_STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
completed: { label: "پرداخت‌شده", tone: "green" },
rejected: { label: "ردشده", tone: "red" },
};
function Stat({
label,
value,
accent,
}: {
label: string;
value: string;
accent?: "red" | "green";
}) {
return (
<div className="rounded-2xl border border-WHITE3 bg-WHITE p-4">
<p className="text-[12px] text-GRAY">{label}</p>
<p
className={cn(
"font-extrabold tabular-nums mt-1 text-[18px]",
accent === "red" && "text-RED",
accent === "green" && "text-GREEN"
)}
>
{value}
</p>
</div>
);
}
export default function LedgerSellerDetail() {
const { id } = useParams();
const { data, isLoading, isError } = useLedgerSellerDetail(id);
if (isLoading)
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-7 w-7 animate-spin text-GRAY" />
</div>
);
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری اطلاعات فروشنده</p>;
const discrepant = Math.abs(data.discrepancy) > 1;
const payoutColumns: Column<LedgerPayoutRow>[] = [
{ header: "مبلغ", render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span> },
{
header: "وضعیت",
render: (p) => {
const s = PAYOUT_STATUS[p.status] || { label: p.status, tone: "yellow" as const };
return <StatusBadge label={s.label} tone={s.tone} />;
},
},
{ header: "درخواست", render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.requestedAt)}</span> },
{ header: "پرداخت", render: (p) => <span className="text-GRAY whitespace-nowrap">{faDate(p.processedAt)}</span> },
];
return (
<div>
<div className="flex items-center gap-2 mb-4">
<Link
to="/admin/ledger/sellers"
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2"
>
<ArrowRight size={16} />
</Link>
<div>
<h2 className="font-extrabold text-[18px]">{data.seller.storeName}</h2>
<p className="text-GRAY text-[12px]" dir="ltr">
@{data.seller.username} · کمیسیون {faNum(data.seller.platformFee)}٪
</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<Stat label="مانده ثبت‌شده" value={faToman(data.recordedBalance)} />
<Stat label="محاسبه‌شده از سفارش‌ها" value={faToman(data.accruedFromOrders)} />
<Stat label="مانده واقعی (تخمینی)" value={faToman(data.derivedBalance)} accent="green" />
<Stat label="برداشت‌های پرداخت‌شده" value={faToman(data.completedPayouts)} />
<Stat label="در انتظار برداشت" value={faToman(data.pendingPayouts)} />
<Stat
label="اختلاف"
value={faToman(data.discrepancy)}
accent={discrepant ? "red" : "green"}
/>
</div>
{discrepant && (
<p className="mt-3 text-[12px] text-RED">
مانده ثبتشده با مقدار محاسبهشده مغایرت دارد (بهاحتمال زیاد بهدلیل عدم
کسر برداشتهای پرداختشده از مانده).
</p>
)}
{/* Bank accounts */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">حسابهای بانکی</h3>
{data.bankAccounts.length === 0 ? (
<p className="text-GRAY text-[13px]">حساب بانکی ثبت نشده است.</p>
) : (
<div className="flex flex-col gap-2">
{data.bankAccounts.map((b) => (
<div
key={b.id}
className="rounded-xl border border-WHITE3 bg-WHITE p-3 flex items-center justify-between"
>
<div className="text-[13px]">
<p className="font-semibold">{b.accountHolder}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{b.iban} · {b.bankName}
</p>
</div>
{b.isVerified ? (
<span className="flex items-center gap-1 text-[12px] text-VITROWN_BLUE">
<BadgeCheck size={15} /> تأییدشده
</span>
) : (
<StatusBadge label="تأییدنشده" tone="gray" />
)}
</div>
))}
</div>
)}
{/* Payout history */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">تاریخچه برداشت</h3>
<AdminTable
columns={payoutColumns}
rows={data.payouts}
getRowKey={(p) => p.id}
emptyText="برداشتی ثبت نشده است"
/>
</div>
);
}

View File

@ -0,0 +1,95 @@
import { useState } from "react";
import { Link } from "@remix-run/react";
import { ChevronLeft } from "lucide-react";
import {
useLedgerSellers,
type LedgerSellerRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
faToman,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
export default function LedgerSellers() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerSellers({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerSellerRow>[] = [
{
header: "فروشگاه",
render: (s) => (
<div className="min-w-[140px]">
<p className="font-semibold">{s.storeName || "—"}</p>
<p className="text-GRAY text-[11px]" dir="ltr">@{s.username}</p>
</div>
),
},
{
header: "مانده",
render: (s) => (
<span className="tabular-nums font-semibold whitespace-nowrap">
{faToman(s.availableFunds)}
</span>
),
},
{
header: "برداشت‌شده",
render: (s) => <span className="tabular-nums whitespace-nowrap text-GRAY">{faToman(s.completedPayouts)}</span>,
},
{
header: "در انتظار برداشت",
render: (s) => <span className="tabular-nums whitespace-nowrap text-GRAY">{faToman(s.pendingPayouts)}</span>,
},
{
header: "",
className: "text-left w-10",
render: (s) => (
<Link
to={`/admin/ledger/sellers/${s.sellerId}`}
className="inline-grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-BLACK2 hover:bg-WHITE2"
aria-label="جزئیات"
>
<ChevronLeft size={16} />
</Link>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی فروشگاه…"
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(s) => s.sellerId}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,102 @@
import { useState } from "react";
import {
useLedgerTransactions,
type LedgerTransactionRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
StatusBadge,
faToman,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
const PAGE_SIZE = 25;
const TYPE: Record<string, { label: string; tone: "green" | "blue" }> = {
deposit: { label: "واریز", tone: "green" },
purchase: { label: "خرید", tone: "blue" },
};
export default function LedgerTransactions() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [type, setType] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useLedgerTransactions({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
transaction_type: type,
});
const reset = (fn: () => void) => {
fn();
setPage(1);
};
const columns: Column<LedgerTransactionRow>[] = [
{
header: "نوع",
render: (t) => {
const ty = TYPE[t.transactionType] || { label: t.transactionType, tone: "blue" as const };
return <StatusBadge label={ty.label} tone={ty.tone} />;
},
},
{
header: "مبلغ",
render: (t) => <span className="tabular-nums whitespace-nowrap">{faToman(t.amount)}</span>,
},
{
header: "کاربر",
render: (t) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{t.userPhone || "—"}
</span>
),
},
{
header: "سفارش",
render: (t) => (
<span className="text-GRAY tabular-nums">{t.order ? t.order.slice(0, 8) : "—"}</span>
),
},
{
header: "تاریخ",
render: (t) => <span className="text-GRAY whitespace-nowrap">{faDate(t.createdAt)}</span>,
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی تلفن کاربر…"
/>
<AdminSelect
value={type}
onChange={(v) => reset(() => setType(v))}
options={[
{ value: "", label: "همه انواع" },
{ value: "deposit", label: "واریز" },
{ value: "purchase", label: "خرید" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(t) => t.id}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}

View File

@ -0,0 +1,40 @@
import { Link, Outlet, useLocation } from "@remix-run/react";
import { cn } from "~/lib/utils";
import { AdminPageTitle } from "~/components/admin/DataTable";
const TABS = [
{ label: "نمای کلی", to: "/admin/ledger", exact: true },
{ label: "برداشت‌ها", to: "/admin/ledger/payouts" },
{ label: "تراکنش‌ها", to: "/admin/ledger/transactions" },
{ label: "فروشندگان", to: "/admin/ledger/sellers" },
];
export default function LedgerLayout() {
const loc = useLocation();
const active = (t: (typeof TABS)[number]) =>
t.exact ? loc.pathname === t.to : loc.pathname.startsWith(t.to);
return (
<div>
<AdminPageTitle>دفتر مالی</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3 overflow-x-auto hide-scrollbar">
{TABS.map((t) => (
<Link
key={t.to}
to={t.to}
prefetch="intent"
className={cn(
"px-4 py-2 text-[13.5px] font-semibold whitespace-nowrap border-b-2 -mb-px transition-colors",
active(t)
? "border-BLACK text-BLACK"
: "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t.label}
</Link>
))}
</div>
<Outlet />
</div>
);
}

View File

@ -27,7 +27,7 @@ const NAV: NavItem[] = [
{ label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store }, { label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store },
{ label: "کاربران", to: "/admin/users", icon: Users }, { label: "کاربران", to: "/admin/users", icon: Users },
{ label: "محصولات", to: "/admin/products", icon: Package }, { label: "محصولات", to: "/admin/products", icon: Package },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark, soon: true }, { label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
]; ];
export default function AdminLayout() { export default function AdminLayout() {