From 40052b70023f9ef0faf1394eaa3f691cb2feb48d Mon Sep 17 00:00:00 2001 From: Arda Samadi Date: Sat, 25 Jul 2026 13:24:06 +0330 Subject: [PATCH] admin ledger: Slice 2c journal UI (derived balances + journal history) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use-admin-hooks: LedgerOverview.journal, LedgerEntryRow, LedgerSellerDetail journal fields, useLedgerJournal. - new "دفتر روزنامه" sub-tab + admin.ledger.journal route. - ledger overview: journal-derived KPI row + recorded-vs-journal drift badge. - seller detail: journal-balance stat + journal history table. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/requestHandler/use-admin-hooks.ts | 25 ++++ app/routes/admin.ledger._index.tsx | 38 +++++- app/routes/admin.ledger.journal.tsx | 156 ++++++++++++++++++++++++ app/routes/admin.ledger.sellers.$id.tsx | 55 ++++++++- app/routes/admin.ledger.tsx | 1 + 5 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 app/routes/admin.ledger.journal.tsx diff --git a/app/requestHandler/use-admin-hooks.ts b/app/requestHandler/use-admin-hooks.ts index 54def57..408c12b 100644 --- a/app/requestHandler/use-admin-hooks.ts +++ b/app/requestHandler/use-admin-hooks.ts @@ -222,6 +222,26 @@ export interface LedgerOverview { sellerPayableRecorded: number; sellerPayableEstimatedTrue: number; }; + // Slice 2c — balances derived from the append-only journal. + journal: { + sellerPayable: number; + walletHeld: number; + platformRevenue: number; + sellerPayableDrift: number; + walletHeldDrift: number; + }; +} + +export interface LedgerEntryRow { + id: string; + createdAt: string; + entryType: string; + account: string; + delta: number; + sellerName: string | null; + userPhone: string | null; + order: string | null; + note: string | null; } export interface LedgerTransactionRow { @@ -266,6 +286,8 @@ export interface LedgerSellerDetail { pendingPayouts: number; derivedBalance: number; discrepancy: number; + journalBalance: number; + journalDiscrepancy: number; bankAccounts: Array<{ id: string; accountHolder: string; @@ -275,6 +297,7 @@ export interface LedgerSellerDetail { isVerified: boolean; }>; payouts: LedgerPayoutRow[]; + journal: LedgerEntryRow[]; } export function useLedgerOverview() { @@ -292,6 +315,8 @@ export const useLedgerPayouts = (params: AdminListParams) => useAdminList("ledger/payouts", params); export const useLedgerSellers = (params: AdminListParams) => useAdminList("ledger/sellers", params); +export const useLedgerJournal = (params: AdminListParams) => + useAdminList("ledger/journal", params); export function useLedgerSellerDetail(id: string | undefined) { const token = useAuthToken(); diff --git a/app/routes/admin.ledger._index.tsx b/app/routes/admin.ledger._index.tsx index ebfb797..d53094c 100644 --- a/app/routes/admin.ledger._index.tsx +++ b/app/routes/admin.ledger._index.tsx @@ -24,8 +24,10 @@ export default function LedgerOverview() { if (isError || !data) return

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

; - const { payouts, reconciliation } = data; + const { payouts, reconciliation, journal } = data; const hasDiscrepancy = reconciliation.completedPayoutsNotDebited > 0; + const driftClean = + Math.abs(journal.sellerPayableDrift) < 1 && Math.abs(journal.walletHeldDrift) < 1; return (
@@ -61,6 +63,40 @@ export default function LedgerOverview() { />
+ {/* Journal-derived truth (Slice 2c) */} +
+

دفتر روزنامه‌ای (منبع حقیقت)

+ + {driftClean ? "هم‌خوان با ثبت‌شده" : "نیازمند بک‌فیل"} + +
+
+ + + +
+ {/* Reconciliation warning */} {hasDiscrepancy && (
diff --git a/app/routes/admin.ledger.journal.tsx b/app/routes/admin.ledger.journal.tsx new file mode 100644 index 0000000..49de5b4 --- /dev/null +++ b/app/routes/admin.ledger.journal.tsx @@ -0,0 +1,156 @@ +import { useState } from "react"; +import { + useLedgerJournal, + type LedgerEntryRow, +} 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; + +// Journal entry_type → Persian label + tone. +const ENTRY: Record = { + deposit: { label: "واریز", tone: "green" }, + purchase: { label: "خرید", tone: "blue" }, + seller_credit: { label: "اعتبار فروشنده", tone: "green" }, + platform_fee: { label: "کارمزد پلتفرم", tone: "blue" }, + refund: { label: "بازپرداخت", tone: "red" }, + seller_reversal: { label: "برگشت فروشنده", tone: "red" }, + payout: { label: "تسویه", tone: "red" }, +}; + +const ACCOUNT: Record = { + user_wallet: "کیف پول کاربر", + seller_balance: "موجودی فروشنده", + platform: "پلتفرم", + external: "خارجی", +}; + +export default function LedgerJournal() { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [account, setAccount] = useState(""); + const [entryType, setEntryType] = useState(""); + const debouncedSearch = useDebouncedValue(search); + + const { data, isLoading } = useLedgerJournal({ + page, + page_size: PAGE_SIZE, + search: debouncedSearch, + account, + entry_type: entryType, + }); + + const reset = (fn: () => void) => { + fn(); + setPage(1); + }; + + const columns: Column[] = [ + { + header: "نوع", + render: (e) => { + const t = ENTRY[e.entryType] || { label: e.entryType, tone: "blue" as const }; + return ; + }, + }, + { + header: "حساب", + render: (e) => ( + + {ACCOUNT[e.account] || e.account} + + ), + }, + { + header: "مبلغ", + render: (e) => ( + + {e.delta >= 0 ? "+" : "−"} + {faToman(Math.abs(e.delta))} + + ), + }, + { + header: "طرف", + render: (e) => ( + + {e.sellerName || e.userPhone || "—"} + + ), + }, + { + header: "سفارش", + render: (e) => ( + {e.order ? e.order.slice(0, 8) : "—"} + ), + }, + { + header: "تاریخ", + render: (e) => {faDate(e.createdAt)}, + }, + ]; + + return ( +
+ + reset(() => setSearch(v))} + placeholder="جستجوی فروشگاه / تلفن / توضیح…" + /> + reset(() => setAccount(v))} + options={[ + { value: "", label: "همه حساب‌ها" }, + { value: "user_wallet", label: "کیف پول کاربر" }, + { value: "seller_balance", label: "موجودی فروشنده" }, + { value: "platform", label: "پلتفرم" }, + { value: "external", label: "خارجی" }, + ]} + /> + reset(() => setEntryType(v))} + options={[ + { value: "", label: "همه انواع" }, + { value: "deposit", label: "واریز" }, + { value: "purchase", label: "خرید" }, + { value: "seller_credit", label: "اعتبار فروشنده" }, + { value: "platform_fee", label: "کارمزد پلتفرم" }, + { value: "refund", label: "بازپرداخت" }, + { value: "seller_reversal", label: "برگشت فروشنده" }, + { value: "payout", label: "تسویه" }, + ]} + /> + + e.id} + /> + +
+ ); +} + +function cnDelta(delta: number) { + return `tabular-nums whitespace-nowrap font-semibold ${ + delta >= 0 ? "text-GREEN" : "text-RED" + }`; +} diff --git a/app/routes/admin.ledger.sellers.$id.tsx b/app/routes/admin.ledger.sellers.$id.tsx index d7ac17d..4878028 100644 --- a/app/routes/admin.ledger.sellers.$id.tsx +++ b/app/routes/admin.ledger.sellers.$id.tsx @@ -9,9 +9,22 @@ import { faDate, type Column, } from "~/components/admin/DataTable"; -import type { LedgerPayoutRow } from "~/requestHandler/use-admin-hooks"; +import type { + LedgerPayoutRow, + LedgerEntryRow, +} from "~/requestHandler/use-admin-hooks"; import { cn } from "~/lib/utils"; +const ENTRY_LABEL: Record = { + deposit: "واریز", + purchase: "خرید", + seller_credit: "اعتبار فروشنده", + platform_fee: "کارمزد پلتفرم", + refund: "بازپرداخت", + seller_reversal: "برگشت فروشنده", + payout: "تسویه", +}; + const PAYOUT_STATUS: Record = { pending: { label: "در انتظار", tone: "yellow" }, completed: { label: "پرداخت‌شده", tone: "green" }, @@ -57,6 +70,32 @@ export default function LedgerSellerDetail() { return

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

; const discrepant = Math.abs(data.discrepancy) > 1; + const journalDiscrepant = Math.abs(data.journalDiscrepancy) > 1; + + const journalColumns: Column[] = [ + { + header: "نوع", + render: (e) => ( + {ENTRY_LABEL[e.entryType] || e.entryType} + ), + }, + { + header: "مبلغ", + render: (e) => ( + = 0 ? "text-GREEN" : "text-RED" + )} + dir="ltr" + > + {e.delta >= 0 ? "+" : "−"} + {faToman(Math.abs(e.delta))} + + ), + }, + { header: "تاریخ", render: (e) => {faDate(e.createdAt)} }, + ]; const payoutColumns: Column[] = [ { header: "مبلغ", render: (p) => {faToman(p.amount)} }, @@ -92,6 +131,11 @@ export default function LedgerSellerDetail() { + p.id} emptyText="برداشتی ثبت نشده است" /> + + {/* Journal history (Slice 2c) */} +

دفتر روزنامه‌ای

+ e.id} + emptyText="ثبتی در دفتر روزنامه وجود ندارد (پس از اجرای بک‌فیل نمایش داده می‌شود)" + />
); } diff --git a/app/routes/admin.ledger.tsx b/app/routes/admin.ledger.tsx index 8e070b6..d4a6499 100644 --- a/app/routes/admin.ledger.tsx +++ b/app/routes/admin.ledger.tsx @@ -6,6 +6,7 @@ const TABS = [ { label: "نمای کلی", to: "/admin/ledger", exact: true }, { label: "برداشت‌ها", to: "/admin/ledger/payouts" }, { label: "تراکنش‌ها", to: "/admin/ledger/transactions" }, + { label: "دفتر روزنامه", to: "/admin/ledger/journal" }, { label: "فروشندگان", to: "/admin/ledger/sellers" }, ];