feat(admin): ledger UI — overview/reconciliation, payouts, transactions, per-seller (slice 2a)
Ledger section with sub-tabs: overview (money held/owed, payouts by status, and a reconciliation warning card), payouts + transactions lists, per-seller balances list, and a per-seller detail showing recorded vs derived balance + discrepancy, bank accounts and payout history. Enabled the Ledger nav item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
935c337df0
commit
cedeef4746
@ -204,3 +204,101 @@ function useAdminUpdate(resource: "sellers" | "users" | "products") {
|
||||
export const useAdminSellerUpdate = () => useAdminUpdate("sellers");
|
||||
export const useAdminUserUpdate = () => useAdminUpdate("users");
|
||||
export const useAdminProductUpdate = () => useAdminUpdate("products");
|
||||
|
||||
/* ------------------------------ ledger ------------------------------ */
|
||||
|
||||
export interface LedgerOverview {
|
||||
walletHeld: number;
|
||||
sellerPayable: number;
|
||||
depositsTotal: number;
|
||||
purchasesTotal: number;
|
||||
payouts: {
|
||||
pending: { amount: number; count: number };
|
||||
completed: { amount: number; count: number };
|
||||
rejected: { amount: number; count: number };
|
||||
};
|
||||
reconciliation: {
|
||||
completedPayoutsNotDebited: number;
|
||||
sellerPayableRecorded: number;
|
||||
sellerPayableEstimatedTrue: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LedgerTransactionRow {
|
||||
id: string;
|
||||
transactionType: string;
|
||||
amount: number;
|
||||
userPhone: string | null;
|
||||
order: string | null;
|
||||
authority: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LedgerPayoutRow {
|
||||
id: string;
|
||||
sellerName: string | null;
|
||||
sellerUsername: string | null;
|
||||
amount: number;
|
||||
status: string;
|
||||
requestedAt: string;
|
||||
processedAt: string | null;
|
||||
iban: string | null;
|
||||
bankName: string | null;
|
||||
accountHolder: string | null;
|
||||
shomareMarja: string | null;
|
||||
shomareErja: string | null;
|
||||
}
|
||||
|
||||
export interface LedgerSellerRow {
|
||||
sellerId: string;
|
||||
storeName: string | null;
|
||||
username: string | null;
|
||||
availableFunds: number;
|
||||
completedPayouts: number;
|
||||
pendingPayouts: number;
|
||||
}
|
||||
|
||||
export interface LedgerSellerDetail {
|
||||
seller: { id: string; storeName: string; username: string; platformFee: number };
|
||||
recordedBalance: number;
|
||||
accruedFromOrders: number;
|
||||
completedPayouts: number;
|
||||
pendingPayouts: number;
|
||||
derivedBalance: number;
|
||||
discrepancy: number;
|
||||
bankAccounts: Array<{
|
||||
id: string;
|
||||
accountHolder: string;
|
||||
iban: string;
|
||||
cardNumber: string;
|
||||
bankName: string;
|
||||
isVerified: boolean;
|
||||
}>;
|
||||
payouts: LedgerPayoutRow[];
|
||||
}
|
||||
|
||||
export function useLedgerOverview() {
|
||||
const token = useAuthToken();
|
||||
return useQuery({
|
||||
queryKey: ["admin", "ledger", "overview"],
|
||||
enabled: !!token,
|
||||
queryFn: () => adminGet<LedgerOverview>("ledger/overview/", token as string),
|
||||
});
|
||||
}
|
||||
|
||||
export const useLedgerTransactions = (params: AdminListParams) =>
|
||||
useAdminList<LedgerTransactionRow>("ledger/transactions", params);
|
||||
export const useLedgerPayouts = (params: AdminListParams) =>
|
||||
useAdminList<LedgerPayoutRow>("ledger/payouts", params);
|
||||
export const useLedgerSellers = (params: AdminListParams) =>
|
||||
useAdminList<LedgerSellerRow>("ledger/sellers", params);
|
||||
|
||||
export function useLedgerSellerDetail(id: string | undefined) {
|
||||
const token = useAuthToken();
|
||||
return useQuery({
|
||||
queryKey: ["admin", "ledger", "seller", id],
|
||||
enabled: !!token && !!id,
|
||||
queryFn: () =>
|
||||
adminGet<LedgerSellerDetail>(`ledger/sellers/${id}/`, token as string),
|
||||
});
|
||||
}
|
||||
|
||||
112
app/routes/admin.ledger._index.tsx
Normal file
112
app/routes/admin.ledger._index.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import {
|
||||
Loader2,
|
||||
Wallet,
|
||||
Landmark,
|
||||
ArrowDownCircle,
|
||||
ArrowUpCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { useLedgerOverview } from "~/requestHandler/use-admin-hooks";
|
||||
import { KpiCard } from "~/components/admin/KpiCard";
|
||||
import { faToman, faNum } from "~/components/admin/DataTable";
|
||||
|
||||
export default function LedgerOverview() {
|
||||
const { data, isLoading, isError } = useLedgerOverview();
|
||||
|
||||
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 { payouts, reconciliation } = data;
|
||||
const hasDiscrepancy = reconciliation.completedPayoutsNotDebited > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Money held / owed */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiCard label="موجودی کیف پول کاربران" value={faToman(data.walletHeld)} icon={Wallet} />
|
||||
<KpiCard label="مانده قابل پرداخت فروشندگان" value={faToman(data.sellerPayable)} icon={Landmark} />
|
||||
<KpiCard label="کل واریزها (شارژ کیف پول)" value={faToman(data.depositsTotal)} icon={ArrowDownCircle} accent="green" />
|
||||
<KpiCard label="کل خریدها" value={faToman(data.purchasesTotal)} icon={ArrowUpCircle} />
|
||||
</div>
|
||||
|
||||
{/* Payouts by status */}
|
||||
<h2 className="text-[15px] font-extrabold mt-7 mb-3">برداشتها</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<KpiCard
|
||||
label="در انتظار"
|
||||
value={faToman(payouts.pending.amount)}
|
||||
sub={`${faNum(payouts.pending.count)} درخواست`}
|
||||
icon={Clock}
|
||||
accent={payouts.pending.count > 0 ? "red" : "default"}
|
||||
/>
|
||||
<KpiCard
|
||||
label="پرداختشده"
|
||||
value={faToman(payouts.completed.amount)}
|
||||
sub={`${faNum(payouts.completed.count)} مورد`}
|
||||
icon={CheckCircle2}
|
||||
accent="green"
|
||||
/>
|
||||
<KpiCard
|
||||
label="ردشده"
|
||||
value={faToman(payouts.rejected.amount)}
|
||||
sub={`${faNum(payouts.rejected.count)} مورد`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reconciliation warning */}
|
||||
{hasDiscrepancy && (
|
||||
<div className="mt-7 rounded-2xl border border-RED/30 bg-RED/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-RED shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-extrabold text-[15px] text-RED">
|
||||
مغایرت حسابداری شناسایی شد
|
||||
</h3>
|
||||
<p className="text-[13px] text-BLACK2 mt-1 leading-6">
|
||||
مبلغ{" "}
|
||||
<b className="tabular-nums">
|
||||
{faToman(reconciliation.completedPayoutsNotDebited)}
|
||||
</b>{" "}
|
||||
بهعنوان برداشت «پرداختشده» ثبت شده اما از مانده فروشندگان کسر
|
||||
نشده است. به همین دلیل «مانده قابل پرداخت» فعلی احتمالاً بیش از
|
||||
مقدار واقعی است.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 mt-3">
|
||||
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
|
||||
<p className="text-[12px] text-GRAY">مانده ثبتشده</p>
|
||||
<p className="font-bold tabular-nums mt-1">
|
||||
{faToman(reconciliation.sellerPayableRecorded)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
|
||||
<p className="text-[12px] text-GRAY">برآورد مانده واقعی</p>
|
||||
<p className="font-bold tabular-nums mt-1 text-GREEN">
|
||||
{faToman(reconciliation.sellerPayableEstimatedTrue)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-WHITE border border-WHITE3 p-3">
|
||||
<p className="text-[12px] text-GRAY">اختلاف</p>
|
||||
<p className="font-bold tabular-nums mt-1 text-RED">
|
||||
{faToman(reconciliation.completedPayoutsNotDebited)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-GRAY mt-3">
|
||||
این مورد در فاز بعدی (دفتر روزنامهای و اصلاح جریان پول) رفع خواهد
|
||||
شد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
app/routes/admin.ledger.payouts.tsx
Normal file
112
app/routes/admin.ledger.payouts.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useLedgerPayouts,
|
||||
type LedgerPayoutRow,
|
||||
} 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;
|
||||
const STATUS: Record<string, { label: string; tone: "green" | "red" | "yellow" }> = {
|
||||
pending: { label: "در انتظار", tone: "yellow" },
|
||||
completed: { label: "پرداختشده", tone: "green" },
|
||||
rejected: { label: "ردشده", tone: "red" },
|
||||
};
|
||||
|
||||
export default function LedgerPayouts() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data, isLoading } = useLedgerPayouts({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
status,
|
||||
});
|
||||
|
||||
const reset = (fn: () => void) => {
|
||||
fn();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const columns: Column<LedgerPayoutRow>[] = [
|
||||
{
|
||||
header: "فروشگاه",
|
||||
render: (p) => (
|
||||
<div className="min-w-[130px]">
|
||||
<p className="font-semibold">{p.sellerName || "—"}</p>
|
||||
<p className="text-GRAY text-[11px]" dir="ltr">@{p.sellerUsername}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "مبلغ",
|
||||
render: (p) => <span className="tabular-nums whitespace-nowrap">{faToman(p.amount)}</span>,
|
||||
},
|
||||
{
|
||||
header: "وضعیت",
|
||||
render: (p) => {
|
||||
const s = STATUS[p.status] || { label: p.status, tone: "yellow" as const };
|
||||
return <StatusBadge label={s.label} tone={s.tone} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "بانک",
|
||||
render: (p) => (
|
||||
<div className="min-w-[140px] text-[11px] text-GRAY" dir="ltr">
|
||||
<p>{p.bankName || "—"}</p>
|
||||
<p className="tabular-nums">{p.iban || ""}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
<AdminToolbar>
|
||||
<AdminSearch
|
||||
value={search}
|
||||
onChange={(v) => reset(() => setSearch(v))}
|
||||
placeholder="جستجوی فروشگاه…"
|
||||
/>
|
||||
<AdminSelect
|
||||
value={status}
|
||||
onChange={(v) => reset(() => setStatus(v))}
|
||||
options={[
|
||||
{ value: "", label: "همه وضعیتها" },
|
||||
{ value: "pending", label: "در انتظار" },
|
||||
{ value: "completed", label: "پرداختشده" },
|
||||
{ value: "rejected", label: "ردشده" },
|
||||
]}
|
||||
/>
|
||||
</AdminToolbar>
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={data?.results || []}
|
||||
isLoading={isLoading}
|
||||
getRowKey={(p) => p.id}
|
||||
/>
|
||||
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
app/routes/admin.ledger.sellers.$id.tsx
Normal file
150
app/routes/admin.ledger.sellers.$id.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
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 } from "~/requestHandler/use-admin-hooks";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
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 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.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="برداشتی ثبت نشده است"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
app/routes/admin.ledger.sellers.tsx
Normal file
95
app/routes/admin.ledger.sellers.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import {
|
||||
useLedgerSellers,
|
||||
type LedgerSellerRow,
|
||||
} from "~/requestHandler/use-admin-hooks";
|
||||
import {
|
||||
AdminTable,
|
||||
AdminPagination,
|
||||
AdminToolbar,
|
||||
AdminSearch,
|
||||
faToman,
|
||||
useDebouncedValue,
|
||||
type Column,
|
||||
} from "~/components/admin/DataTable";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function LedgerSellers() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data, isLoading } = useLedgerSellers({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
});
|
||||
|
||||
const reset = (fn: () => void) => {
|
||||
fn();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const columns: Column<LedgerSellerRow>[] = [
|
||||
{
|
||||
header: "فروشگاه",
|
||||
render: (s) => (
|
||||
<div className="min-w-[140px]">
|
||||
<p className="font-semibold">{s.storeName || "—"}</p>
|
||||
<p className="text-GRAY text-[11px]" dir="ltr">@{s.username}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "مانده",
|
||||
render: (s) => (
|
||||
<span className="tabular-nums font-semibold whitespace-nowrap">
|
||||
{faToman(s.availableFunds)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "برداشتشده",
|
||||
render: (s) => <span className="tabular-nums whitespace-nowrap text-GRAY">{faToman(s.completedPayouts)}</span>,
|
||||
},
|
||||
{
|
||||
header: "در انتظار برداشت",
|
||||
render: (s) => <span className="tabular-nums whitespace-nowrap text-GRAY">{faToman(s.pendingPayouts)}</span>,
|
||||
},
|
||||
{
|
||||
header: "",
|
||||
className: "text-left w-10",
|
||||
render: (s) => (
|
||||
<Link
|
||||
to={`/admin/ledger/sellers/${s.sellerId}`}
|
||||
className="inline-grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-BLACK2 hover:bg-WHITE2"
|
||||
aria-label="جزئیات"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminToolbar>
|
||||
<AdminSearch
|
||||
value={search}
|
||||
onChange={(v) => reset(() => setSearch(v))}
|
||||
placeholder="جستجوی فروشگاه…"
|
||||
/>
|
||||
</AdminToolbar>
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={data?.results || []}
|
||||
isLoading={isLoading}
|
||||
getRowKey={(s) => s.sellerId}
|
||||
/>
|
||||
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
app/routes/admin.ledger.transactions.tsx
Normal file
102
app/routes/admin.ledger.transactions.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useLedgerTransactions,
|
||||
type LedgerTransactionRow,
|
||||
} 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;
|
||||
const TYPE: Record<string, { label: string; tone: "green" | "blue" }> = {
|
||||
deposit: { label: "واریز", tone: "green" },
|
||||
purchase: { label: "خرید", tone: "blue" },
|
||||
};
|
||||
|
||||
export default function LedgerTransactions() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data, isLoading } = useLedgerTransactions({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
transaction_type: type,
|
||||
});
|
||||
|
||||
const reset = (fn: () => void) => {
|
||||
fn();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const columns: Column<LedgerTransactionRow>[] = [
|
||||
{
|
||||
header: "نوع",
|
||||
render: (t) => {
|
||||
const ty = TYPE[t.transactionType] || { label: t.transactionType, tone: "blue" as const };
|
||||
return <StatusBadge label={ty.label} tone={ty.tone} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "مبلغ",
|
||||
render: (t) => <span className="tabular-nums whitespace-nowrap">{faToman(t.amount)}</span>,
|
||||
},
|
||||
{
|
||||
header: "کاربر",
|
||||
render: (t) => (
|
||||
<span className="text-GRAY text-[11px] tabular-nums" dir="ltr">
|
||||
{t.userPhone || "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "سفارش",
|
||||
render: (t) => (
|
||||
<span className="text-GRAY tabular-nums">{t.order ? t.order.slice(0, 8) : "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "تاریخ",
|
||||
render: (t) => <span className="text-GRAY whitespace-nowrap">{faDate(t.createdAt)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminToolbar>
|
||||
<AdminSearch
|
||||
value={search}
|
||||
onChange={(v) => reset(() => setSearch(v))}
|
||||
placeholder="جستجوی تلفن کاربر…"
|
||||
/>
|
||||
<AdminSelect
|
||||
value={type}
|
||||
onChange={(v) => reset(() => setType(v))}
|
||||
options={[
|
||||
{ value: "", label: "همه انواع" },
|
||||
{ value: "deposit", label: "واریز" },
|
||||
{ value: "purchase", label: "خرید" },
|
||||
]}
|
||||
/>
|
||||
</AdminToolbar>
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={data?.results || []}
|
||||
isLoading={isLoading}
|
||||
getRowKey={(t) => t.id}
|
||||
/>
|
||||
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
app/routes/admin.ledger.tsx
Normal file
40
app/routes/admin.ledger.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Link, Outlet, useLocation } from "@remix-run/react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { AdminPageTitle } from "~/components/admin/DataTable";
|
||||
|
||||
const TABS = [
|
||||
{ label: "نمای کلی", to: "/admin/ledger", exact: true },
|
||||
{ label: "برداشتها", to: "/admin/ledger/payouts" },
|
||||
{ label: "تراکنشها", to: "/admin/ledger/transactions" },
|
||||
{ label: "فروشندگان", to: "/admin/ledger/sellers" },
|
||||
];
|
||||
|
||||
export default function LedgerLayout() {
|
||||
const loc = useLocation();
|
||||
const active = (t: (typeof TABS)[number]) =>
|
||||
t.exact ? loc.pathname === t.to : loc.pathname.startsWith(t.to);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageTitle>دفتر مالی</AdminPageTitle>
|
||||
<div className="flex gap-1 mb-5 border-b border-WHITE3 overflow-x-auto hide-scrollbar">
|
||||
{TABS.map((t) => (
|
||||
<Link
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
prefetch="intent"
|
||||
className={cn(
|
||||
"px-4 py-2 text-[13.5px] font-semibold whitespace-nowrap border-b-2 -mb-px transition-colors",
|
||||
active(t)
|
||||
? "border-BLACK text-BLACK"
|
||||
: "border-transparent text-GRAY hover:text-BLACK2"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -27,7 +27,7 @@ const NAV: NavItem[] = [
|
||||
{ label: "فروشگاهها", to: "/admin/sellers", icon: Store },
|
||||
{ label: "کاربران", to: "/admin/users", icon: Users },
|
||||
{ label: "محصولات", to: "/admin/products", icon: Package },
|
||||
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark, soon: true },
|
||||
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user