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:
parent
24d4c8a302
commit
40052b7002
@ -222,6 +222,26 @@ export interface LedgerOverview {
|
|||||||
sellerPayableRecorded: number;
|
sellerPayableRecorded: number;
|
||||||
sellerPayableEstimatedTrue: 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 {
|
export interface LedgerTransactionRow {
|
||||||
@ -266,6 +286,8 @@ export interface LedgerSellerDetail {
|
|||||||
pendingPayouts: number;
|
pendingPayouts: number;
|
||||||
derivedBalance: number;
|
derivedBalance: number;
|
||||||
discrepancy: number;
|
discrepancy: number;
|
||||||
|
journalBalance: number;
|
||||||
|
journalDiscrepancy: number;
|
||||||
bankAccounts: Array<{
|
bankAccounts: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
accountHolder: string;
|
accountHolder: string;
|
||||||
@ -275,6 +297,7 @@ export interface LedgerSellerDetail {
|
|||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
}>;
|
}>;
|
||||||
payouts: LedgerPayoutRow[];
|
payouts: LedgerPayoutRow[];
|
||||||
|
journal: LedgerEntryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLedgerOverview() {
|
export function useLedgerOverview() {
|
||||||
@ -292,6 +315,8 @@ export const useLedgerPayouts = (params: AdminListParams) =>
|
|||||||
useAdminList<LedgerPayoutRow>("ledger/payouts", params);
|
useAdminList<LedgerPayoutRow>("ledger/payouts", params);
|
||||||
export const useLedgerSellers = (params: AdminListParams) =>
|
export const useLedgerSellers = (params: AdminListParams) =>
|
||||||
useAdminList<LedgerSellerRow>("ledger/sellers", params);
|
useAdminList<LedgerSellerRow>("ledger/sellers", params);
|
||||||
|
export const useLedgerJournal = (params: AdminListParams) =>
|
||||||
|
useAdminList<LedgerEntryRow>("ledger/journal", params);
|
||||||
|
|
||||||
export function useLedgerSellerDetail(id: string | undefined) {
|
export function useLedgerSellerDetail(id: string | undefined) {
|
||||||
const token = useAuthToken();
|
const token = useAuthToken();
|
||||||
|
|||||||
@ -24,8 +24,10 @@ export default function LedgerOverview() {
|
|||||||
if (isError || !data)
|
if (isError || !data)
|
||||||
return <p className="text-RED text-[14px]">خطا در بارگذاری دفتر مالی</p>;
|
return <p className="text-RED text-[14px]">خطا در بارگذاری دفتر مالی</p>;
|
||||||
|
|
||||||
const { payouts, reconciliation } = data;
|
const { payouts, reconciliation, journal } = data;
|
||||||
const hasDiscrepancy = reconciliation.completedPayoutsNotDebited > 0;
|
const hasDiscrepancy = reconciliation.completedPayoutsNotDebited > 0;
|
||||||
|
const driftClean =
|
||||||
|
Math.abs(journal.sellerPayableDrift) < 1 && Math.abs(journal.walletHeldDrift) < 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -61,6 +63,40 @@ export default function LedgerOverview() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Reconciliation warning */}
|
||||||
{hasDiscrepancy && (
|
{hasDiscrepancy && (
|
||||||
<div className="mt-7 rounded-2xl border border-RED/30 bg-RED/5 p-4">
|
<div className="mt-7 rounded-2xl border border-RED/30 bg-RED/5 p-4">
|
||||||
|
|||||||
156
app/routes/admin.ledger.journal.tsx
Normal file
156
app/routes/admin.ledger.journal.tsx
Normal 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"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
@ -9,9 +9,22 @@ import {
|
|||||||
faDate,
|
faDate,
|
||||||
type Column,
|
type Column,
|
||||||
} from "~/components/admin/DataTable";
|
} 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";
|
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" }> = {
|
const PAYOUT_STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
|
||||||
pending: { label: "در انتظار", tone: "yellow" },
|
pending: { label: "در انتظار", tone: "yellow" },
|
||||||
completed: { label: "پرداختشده", tone: "green" },
|
completed: { label: "پرداختشده", tone: "green" },
|
||||||
@ -57,6 +70,32 @@ export default function LedgerSellerDetail() {
|
|||||||
return <p className="text-RED text-[14px]">خطا در بارگذاری اطلاعات فروشنده</p>;
|
return <p className="text-RED text-[14px]">خطا در بارگذاری اطلاعات فروشنده</p>;
|
||||||
|
|
||||||
const discrepant = Math.abs(data.discrepancy) > 1;
|
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>[] = [
|
const payoutColumns: Column<LedgerPayoutRow>[] = [
|
||||||
{ header: "مبلغ", render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span> },
|
{ 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.recordedBalance)} />
|
||||||
<Stat label="محاسبهشده از سفارشها" value={faToman(data.accruedFromOrders)} />
|
<Stat label="محاسبهشده از سفارشها" value={faToman(data.accruedFromOrders)} />
|
||||||
<Stat label="مانده واقعی (تخمینی)" value={faToman(data.derivedBalance)} accent="green" />
|
<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.completedPayouts)} />
|
||||||
<Stat label="در انتظار برداشت" value={faToman(data.pendingPayouts)} />
|
<Stat label="در انتظار برداشت" value={faToman(data.pendingPayouts)} />
|
||||||
<Stat
|
<Stat
|
||||||
@ -145,6 +189,15 @@ export default function LedgerSellerDetail() {
|
|||||||
getRowKey={(p) => p.id}
|
getRowKey={(p) => p.id}
|
||||||
emptyText="برداشتی ثبت نشده است"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const TABS = [
|
|||||||
{ label: "نمای کلی", to: "/admin/ledger", exact: true },
|
{ label: "نمای کلی", to: "/admin/ledger", exact: true },
|
||||||
{ label: "برداشتها", to: "/admin/ledger/payouts" },
|
{ label: "برداشتها", to: "/admin/ledger/payouts" },
|
||||||
{ label: "تراکنشها", to: "/admin/ledger/transactions" },
|
{ label: "تراکنشها", to: "/admin/ledger/transactions" },
|
||||||
|
{ label: "دفتر روزنامه", to: "/admin/ledger/journal" },
|
||||||
{ label: "فروشندگان", to: "/admin/ledger/sellers" },
|
{ label: "فروشندگان", to: "/admin/ledger/sellers" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user