admin ledger: Slice 2c journal UI (derived balances + journal history)

- 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) <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-07-25 13:24:06 +03:30
parent 24d4c8a302
commit 40052b7002
5 changed files with 273 additions and 2 deletions

View File

@ -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<LedgerPayoutRow>("ledger/payouts", params);
export const useLedgerSellers = (params: AdminListParams) =>
useAdminList<LedgerSellerRow>("ledger/sellers", params);
export const useLedgerJournal = (params: AdminListParams) =>
useAdminList<LedgerEntryRow>("ledger/journal", params);
export function useLedgerSellerDetail(id: string | undefined) {
const token = useAuthToken();

View File

@ -24,8 +24,10 @@ export default function LedgerOverview() {
if (isError || !data)
return <p className="text-RED text-[14px]">خطا در بارگذاری دفتر مالی</p>;
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 (
<div>
@ -61,6 +63,40 @@ export default function LedgerOverview() {
/>
</div>
{/* Journal-derived truth (Slice 2c) */}
<div className="mt-7 flex items-center gap-2 mb-3">
<h2 className="text-[15px] font-extrabold">دفتر روزنامهای (منبع حقیقت)</h2>
<span
className={`text-[11px] font-semibold px-2 py-0.5 rounded-full ${
driftClean ? "bg-GREEN/10 text-GREEN" : "bg-yellow-500/10 text-yellow-600"
}`}
>
{driftClean ? "هم‌خوان با ثبت‌شده" : "نیازمند بک‌فیل"}
</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<KpiCard
label="مانده فروشندگان (روزنامه)"
value={faToman(journal.sellerPayable)}
sub={`اختلاف با ثبت‌شده: ${faToman(journal.sellerPayableDrift)}`}
icon={Landmark}
accent={Math.abs(journal.sellerPayableDrift) < 1 ? "green" : "red"}
/>
<KpiCard
label="کیف پول کاربران (روزنامه)"
value={faToman(journal.walletHeld)}
sub={`اختلاف با ثبت‌شده: ${faToman(journal.walletHeldDrift)}`}
icon={Wallet}
accent={Math.abs(journal.walletHeldDrift) < 1 ? "green" : "red"}
/>
<KpiCard
label="درآمد پلتفرم (کارمزد)"
value={faToman(journal.platformRevenue)}
icon={ArrowUpCircle}
accent="green"
/>
</div>
{/* Reconciliation warning */}
{hasDiscrepancy && (
<div className="mt-7 rounded-2xl border border-RED/30 bg-RED/5 p-4">

View File

@ -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<string, { label: string; tone: "green" | "blue" | "red" }> = {
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<string, string> = {
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<LedgerEntryRow>[] = [
{
header: "نوع",
render: (e) => {
const t = ENTRY[e.entryType] || { label: e.entryType, tone: "blue" as const };
return <StatusBadge label={t.label} tone={t.tone} />;
},
},
{
header: "حساب",
render: (e) => (
<span className="text-GRAY text-[12px] whitespace-nowrap">
{ACCOUNT[e.account] || e.account}
</span>
),
},
{
header: "مبلغ",
render: (e) => (
<span
className={cnDelta(e.delta)}
dir="ltr"
>
{e.delta >= 0 ? "+" : ""}
{faToman(Math.abs(e.delta))}
</span>
),
},
{
header: "طرف",
render: (e) => (
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
{e.sellerName || e.userPhone || "—"}
</span>
),
},
{
header: "سفارش",
render: (e) => (
<span className="text-GRAY tabular-nums">{e.order ? e.order.slice(0, 8) : "—"}</span>
),
},
{
header: "تاریخ",
render: (e) => <span className="text-GRAY whitespace-nowrap">{faDate(e.createdAt)}</span>,
},
];
return (
<div>
<AdminToolbar>
<AdminSearch
value={search}
onChange={(v) => reset(() => setSearch(v))}
placeholder="جستجوی فروشگاه / تلفن / توضیح…"
/>
<AdminSelect
value={account}
onChange={(v) => reset(() => setAccount(v))}
options={[
{ value: "", label: "همه حساب‌ها" },
{ value: "user_wallet", label: "کیف پول کاربر" },
{ value: "seller_balance", label: "موجودی فروشنده" },
{ value: "platform", label: "پلتفرم" },
{ value: "external", label: "خارجی" },
]}
/>
<AdminSelect
value={entryType}
onChange={(v) => 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: "تسویه" },
]}
/>
</AdminToolbar>
<AdminTable
columns={columns}
rows={data?.results || []}
isLoading={isLoading}
getRowKey={(e) => e.id}
/>
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
function cnDelta(delta: number) {
return `tabular-nums whitespace-nowrap font-semibold ${
delta >= 0 ? "text-GREEN" : "text-RED"
}`;
}

View File

@ -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<string, string> = {
deposit: "واریز",
purchase: "خرید",
seller_credit: "اعتبار فروشنده",
platform_fee: "کارمزد پلتفرم",
refund: "بازپرداخت",
seller_reversal: "برگشت فروشنده",
payout: "تسویه",
};
const PAYOUT_STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
pending: { label: "در انتظار", tone: "yellow" },
completed: { label: "پرداخت‌شده", tone: "green" },
@ -57,6 +70,32 @@ export default function LedgerSellerDetail() {
return <p className="text-RED text-[14px]">خطا در بارگذاری اطلاعات فروشنده</p>;
const discrepant = Math.abs(data.discrepancy) > 1;
const journalDiscrepant = Math.abs(data.journalDiscrepancy) > 1;
const journalColumns: Column<LedgerEntryRow>[] = [
{
header: "نوع",
render: (e) => (
<span className="whitespace-nowrap">{ENTRY_LABEL[e.entryType] || e.entryType}</span>
),
},
{
header: "مبلغ",
render: (e) => (
<span
className={cn(
"tabular-nums whitespace-nowrap font-semibold",
e.delta >= 0 ? "text-GREEN" : "text-RED"
)}
dir="ltr"
>
{e.delta >= 0 ? "+" : ""}
{faToman(Math.abs(e.delta))}
</span>
),
},
{ header: "تاریخ", render: (e) => <span className="text-GRAY whitespace-nowrap">{faDate(e.createdAt)}</span> },
];
const payoutColumns: Column<LedgerPayoutRow>[] = [
{ header: "مبلغ", render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span> },
@ -92,6 +131,11 @@ export default function LedgerSellerDetail() {
<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.journalBalance)}
accent={journalDiscrepant ? "red" : "green"}
/>
<Stat label="برداشت‌های پرداخت‌شده" value={faToman(data.completedPayouts)} />
<Stat label="در انتظار برداشت" value={faToman(data.pendingPayouts)} />
<Stat
@ -145,6 +189,15 @@ export default function LedgerSellerDetail() {
getRowKey={(p) => p.id}
emptyText="برداشتی ثبت نشده است"
/>
{/* Journal history (Slice 2c) */}
<h3 className="text-[15px] font-extrabold mt-7 mb-3">دفتر روزنامهای</h3>
<AdminTable
columns={journalColumns}
rows={data.journal}
getRowKey={(e) => e.id}
emptyText="ثبتی در دفتر روزنامه وجود ندارد (پس از اجرای بک‌فیل نمایش داده می‌شود)"
/>
</div>
);
}

View File

@ -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" },
];