- 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>
204 lines
7.0 KiB
TypeScript
204 lines
7.0 KiB
TypeScript
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,
|
||
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" },
|
||
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 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> },
|
||
{
|
||
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.journalBalance)}
|
||
accent={journalDiscrepant ? "red" : "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="برداشتی ثبت نشده است"
|
||
/>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|