From cedeef474652806739c028f82048ba641779b15b Mon Sep 17 00:00:00 2001 From: Arda Samadi Date: Sat, 25 Jul 2026 12:28:11 +0330 Subject: [PATCH] =?UTF-8?q?feat(admin):=20ledger=20UI=20=E2=80=94=20overvi?= =?UTF-8?q?ew/reconciliation,=20payouts,=20transactions,=20per-seller=20(s?= =?UTF-8?q?lice=202a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/requestHandler/use-admin-hooks.ts | 98 +++++++++++++++ app/routes/admin.ledger._index.tsx | 112 +++++++++++++++++ app/routes/admin.ledger.payouts.tsx | 112 +++++++++++++++++ app/routes/admin.ledger.sellers.$id.tsx | 150 +++++++++++++++++++++++ app/routes/admin.ledger.sellers.tsx | 95 ++++++++++++++ app/routes/admin.ledger.transactions.tsx | 102 +++++++++++++++ app/routes/admin.ledger.tsx | 40 ++++++ app/routes/admin.tsx | 2 +- 8 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 app/routes/admin.ledger._index.tsx create mode 100644 app/routes/admin.ledger.payouts.tsx create mode 100644 app/routes/admin.ledger.sellers.$id.tsx create mode 100644 app/routes/admin.ledger.sellers.tsx create mode 100644 app/routes/admin.ledger.transactions.tsx create mode 100644 app/routes/admin.ledger.tsx diff --git a/app/requestHandler/use-admin-hooks.ts b/app/requestHandler/use-admin-hooks.ts index fa5b3b9..57f09f3 100644 --- a/app/requestHandler/use-admin-hooks.ts +++ b/app/requestHandler/use-admin-hooks.ts @@ -204,3 +204,101 @@ function useAdminUpdate(resource: "sellers" | "users" | "products") { export const useAdminSellerUpdate = () => useAdminUpdate("sellers"); export const useAdminUserUpdate = () => useAdminUpdate("users"); 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("ledger/overview/", token as string), + }); +} + +export const useLedgerTransactions = (params: AdminListParams) => + useAdminList("ledger/transactions", params); +export const useLedgerPayouts = (params: AdminListParams) => + useAdminList("ledger/payouts", params); +export const useLedgerSellers = (params: AdminListParams) => + useAdminList("ledger/sellers", params); + +export function useLedgerSellerDetail(id: string | undefined) { + const token = useAuthToken(); + return useQuery({ + queryKey: ["admin", "ledger", "seller", id], + enabled: !!token && !!id, + queryFn: () => + adminGet(`ledger/sellers/${id}/`, token as string), + }); +} diff --git a/app/routes/admin.ledger._index.tsx b/app/routes/admin.ledger._index.tsx new file mode 100644 index 0000000..ebfb797 --- /dev/null +++ b/app/routes/admin.ledger._index.tsx @@ -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 ( +
+ +
+ ); + if (isError || !data) + return

خطا در بارگذاری دفتر مالی

; + + const { payouts, reconciliation } = data; + const hasDiscrepancy = reconciliation.completedPayoutsNotDebited > 0; + + return ( +
+ {/* Money held / owed */} +
+ + + + +
+ + {/* Payouts by status */} +

برداشت‌ها

+
+ 0 ? "red" : "default"} + /> + + +
+ + {/* Reconciliation warning */} + {hasDiscrepancy && ( +
+
+ +
+

+ مغایرت حسابداری شناسایی شد +

+

+ مبلغ{" "} + + {faToman(reconciliation.completedPayoutsNotDebited)} + {" "} + به‌عنوان برداشت «پرداخت‌شده» ثبت شده اما از مانده فروشندگان کسر + نشده است. به همین دلیل «مانده قابل پرداخت» فعلی احتمالاً بیش از + مقدار واقعی است. +

+
+
+

مانده ثبت‌شده

+

+ {faToman(reconciliation.sellerPayableRecorded)} +

+
+
+

برآورد مانده واقعی

+

+ {faToman(reconciliation.sellerPayableEstimatedTrue)} +

+
+
+

اختلاف

+

+ {faToman(reconciliation.completedPayoutsNotDebited)} +

+
+
+

+ این مورد در فاز بعدی (دفتر روزنامه‌ای و اصلاح جریان پول) رفع خواهد + شد. +

+
+
+
+ )} +
+ ); +} diff --git a/app/routes/admin.ledger.payouts.tsx b/app/routes/admin.ledger.payouts.tsx new file mode 100644 index 0000000..16d481a --- /dev/null +++ b/app/routes/admin.ledger.payouts.tsx @@ -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 = { + 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[] = [ + { + header: "فروشگاه", + render: (p) => ( +
+

{p.sellerName || "—"}

+

@{p.sellerUsername}

+
+ ), + }, + { + header: "مبلغ", + render: (p) => {faToman(p.amount)}, + }, + { + header: "وضعیت", + render: (p) => { + const s = STATUS[p.status] || { label: p.status, tone: "yellow" as const }; + return ; + }, + }, + { + header: "بانک", + render: (p) => ( +
+

{p.bankName || "—"}

+

{p.iban || ""}

+
+ ), + }, + { + header: "درخواست", + render: (p) => {faDate(p.requestedAt)}, + }, + { + header: "پرداخت", + render: (p) => {faDate(p.processedAt)}, + }, + ]; + + return ( +
+ + reset(() => setSearch(v))} + placeholder="جستجوی فروشگاه…" + /> + reset(() => setStatus(v))} + options={[ + { value: "", label: "همه وضعیت‌ها" }, + { value: "pending", label: "در انتظار" }, + { value: "completed", label: "پرداخت‌شده" }, + { value: "rejected", label: "ردشده" }, + ]} + /> + + p.id} + /> + +
+ ); +} diff --git a/app/routes/admin.ledger.sellers.$id.tsx b/app/routes/admin.ledger.sellers.$id.tsx new file mode 100644 index 0000000..d7ac17d --- /dev/null +++ b/app/routes/admin.ledger.sellers.$id.tsx @@ -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 = { + 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 ( +
+

{label}

+

+ {value} +

+
+ ); +} + +export default function LedgerSellerDetail() { + const { id } = useParams(); + const { data, isLoading, isError } = useLedgerSellerDetail(id); + + if (isLoading) + return ( +
+ +
+ ); + if (isError || !data) + return

خطا در بارگذاری اطلاعات فروشنده

; + + const discrepant = Math.abs(data.discrepancy) > 1; + + const payoutColumns: Column[] = [ + { header: "مبلغ", render: (p) => {faToman(p.amount)} }, + { + header: "وضعیت", + render: (p) => { + const s = PAYOUT_STATUS[p.status] || { label: p.status, tone: "yellow" as const }; + return ; + }, + }, + { header: "درخواست", render: (p) => {faDate(p.requestedAt)} }, + { header: "پرداخت", render: (p) => {faDate(p.processedAt)} }, + ]; + + return ( +
+
+ + + +
+

{data.seller.storeName}

+

+ @{data.seller.username} · کمیسیون {faNum(data.seller.platformFee)}٪ +

+
+
+ +
+ + + + + + +
+ + {discrepant && ( +

+ مانده ثبت‌شده با مقدار محاسبه‌شده مغایرت دارد (به‌احتمال زیاد به‌دلیل عدم + کسر برداشت‌های پرداخت‌شده از مانده). +

+ )} + + {/* Bank accounts */} +

حساب‌های بانکی

+ {data.bankAccounts.length === 0 ? ( +

حساب بانکی ثبت نشده است.

+ ) : ( +
+ {data.bankAccounts.map((b) => ( +
+
+

{b.accountHolder}

+

+ {b.iban} · {b.bankName} +

+
+ {b.isVerified ? ( + + تأییدشده + + ) : ( + + )} +
+ ))} +
+ )} + + {/* Payout history */} +

تاریخچه برداشت

+ p.id} + emptyText="برداشتی ثبت نشده است" + /> +
+ ); +} diff --git a/app/routes/admin.ledger.sellers.tsx b/app/routes/admin.ledger.sellers.tsx new file mode 100644 index 0000000..09ac608 --- /dev/null +++ b/app/routes/admin.ledger.sellers.tsx @@ -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[] = [ + { + header: "فروشگاه", + render: (s) => ( +
+

{s.storeName || "—"}

+

@{s.username}

+
+ ), + }, + { + header: "مانده", + render: (s) => ( + + {faToman(s.availableFunds)} + + ), + }, + { + header: "برداشت‌شده", + render: (s) => {faToman(s.completedPayouts)}, + }, + { + header: "در انتظار برداشت", + render: (s) => {faToman(s.pendingPayouts)}, + }, + { + header: "", + className: "text-left w-10", + render: (s) => ( + + + + ), + }, + ]; + + return ( +
+ + reset(() => setSearch(v))} + placeholder="جستجوی فروشگاه…" + /> + + s.sellerId} + /> + +
+ ); +} diff --git a/app/routes/admin.ledger.transactions.tsx b/app/routes/admin.ledger.transactions.tsx new file mode 100644 index 0000000..b719be9 --- /dev/null +++ b/app/routes/admin.ledger.transactions.tsx @@ -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 = { + 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[] = [ + { + header: "نوع", + render: (t) => { + const ty = TYPE[t.transactionType] || { label: t.transactionType, tone: "blue" as const }; + return ; + }, + }, + { + header: "مبلغ", + render: (t) => {faToman(t.amount)}, + }, + { + header: "کاربر", + render: (t) => ( + + {t.userPhone || "—"} + + ), + }, + { + header: "سفارش", + render: (t) => ( + {t.order ? t.order.slice(0, 8) : "—"} + ), + }, + { + header: "تاریخ", + render: (t) => {faDate(t.createdAt)}, + }, + ]; + + return ( +
+ + reset(() => setSearch(v))} + placeholder="جستجوی تلفن کاربر…" + /> + reset(() => setType(v))} + options={[ + { value: "", label: "همه انواع" }, + { value: "deposit", label: "واریز" }, + { value: "purchase", label: "خرید" }, + ]} + /> + + t.id} + /> + +
+ ); +} diff --git a/app/routes/admin.ledger.tsx b/app/routes/admin.ledger.tsx new file mode 100644 index 0000000..8e070b6 --- /dev/null +++ b/app/routes/admin.ledger.tsx @@ -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 ( +
+ دفتر مالی +
+ {TABS.map((t) => ( + + {t.label} + + ))} +
+ +
+ ); +} diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index bfc99d1..1012234 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -27,7 +27,7 @@ const NAV: NavItem[] = [ { label: "فروشگاه‌ها", to: "/admin/sellers", icon: Store }, { label: "کاربران", to: "/admin/users", icon: Users }, { label: "محصولات", to: "/admin/products", icon: Package }, - { label: "دفتر مالی", to: "/admin/ledger", icon: Landmark, soon: true }, + { label: "دفتر مالی", to: "/admin/ledger", icon: Landmark }, ]; export default function AdminLayout() {