Vitron-Front/app/routes/admin.ledger.sellers.$id.tsx
Arda Samadi 40052b7002 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>
2026-07-25 13:24:06 +03:30

204 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}