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 useAdminSellerUpdate = () => useAdminUpdate("sellers");
|
||||||
export const useAdminUserUpdate = () => useAdminUpdate("users");
|
export const useAdminUserUpdate = () => useAdminUpdate("users");
|
||||||
export const useAdminProductUpdate = () => useAdminUpdate("products");
|
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/sellers", icon: Store },
|
||||||
{ label: "کاربران", to: "/admin/users", icon: Users },
|
{ label: "کاربران", to: "/admin/users", icon: Users },
|
||||||
{ label: "محصولات", to: "/admin/products", icon: Package },
|
{ label: "محصولات", to: "/admin/products", icon: Package },
|
||||||
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark, soon: true },
|
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user