302 lines
9.3 KiB
TypeScript
302 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
Search,
|
|
Wallet,
|
|
Sun,
|
|
CalendarDays,
|
|
ShoppingBag,
|
|
Users,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
} from "lucide-react";
|
|
import Shell from "@/components/Shell";
|
|
import StatCard from "@/components/StatCard";
|
|
import Badge from "@/components/Badge";
|
|
import { TableSkeleton } from "@/components/Skeleton";
|
|
import Empty from "@/components/Empty";
|
|
import { fmtNum } from "@/components/NumberInput";
|
|
import { api } from "@/lib/api";
|
|
import { KIND_LABEL, KIND_TONE, STATUS_LABEL, STATUS_TONE, STORE_LABEL } from "@/lib/labels";
|
|
|
|
type Purchase = {
|
|
id: number;
|
|
user_id: number;
|
|
mobile: string;
|
|
name: string;
|
|
store: string;
|
|
kind: string;
|
|
product_id: string;
|
|
coins: number;
|
|
tickets: number;
|
|
vip_days: number;
|
|
price_toman: number;
|
|
status: string;
|
|
product_title: string;
|
|
created_local: string;
|
|
};
|
|
|
|
type Stats = {
|
|
total_revenue: number;
|
|
verified_count: number;
|
|
unique_buyers: number;
|
|
avg_revenue: number;
|
|
today: { count: number; revenue: number };
|
|
month: { count: number; revenue: number };
|
|
};
|
|
|
|
type Resp = {
|
|
purchases: Purchase[];
|
|
stats: Stats;
|
|
total: number;
|
|
page: number;
|
|
page_size: number;
|
|
pages: number;
|
|
};
|
|
|
|
function received(p: Purchase): string {
|
|
const parts: string[] = [];
|
|
if (p.coins) parts.push(`${fmtNum(p.coins)} سکه`);
|
|
if (p.tickets) parts.push(`${fmtNum(p.tickets)} بلیط`);
|
|
if (p.vip_days) parts.push(`${fmtNum(p.vip_days)} روز VIP`);
|
|
return parts.join("، ") || "—";
|
|
}
|
|
|
|
export default function TransactionsPage() {
|
|
const [q, setQ] = useState("");
|
|
const [qApplied, setQApplied] = useState("");
|
|
const [store, setStore] = useState("");
|
|
const [kind, setKind] = useState("");
|
|
const [status, setStatus] = useState("verified");
|
|
const [page, setPage] = useState(1);
|
|
const [data, setData] = useState<Resp | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [err, setErr] = useState("");
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
setLoading(true);
|
|
setErr("");
|
|
const params = new URLSearchParams();
|
|
if (qApplied) params.set("q", qApplied);
|
|
if (store) params.set("store", store);
|
|
if (kind) params.set("kind", kind);
|
|
if (status) params.set("status", status);
|
|
params.set("page", String(page));
|
|
api
|
|
.get<Resp>(`/purchases?${params.toString()}`)
|
|
.then((r) => {
|
|
if (alive) setData(r);
|
|
})
|
|
.catch((e) => {
|
|
if (alive) setErr(e.message);
|
|
})
|
|
.finally(() => {
|
|
if (alive) setLoading(false);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [qApplied, store, kind, status, page]);
|
|
|
|
const s = data?.stats;
|
|
|
|
return (
|
|
<Shell title="تراکنشها" subtitle="خریدهای کاربران از بازار و مایکت">
|
|
{err && <p className="text-red-400 mb-4">{err}</p>}
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
|
|
<StatCard
|
|
label="درآمد کل"
|
|
value={s ? fmtNum(s.total_revenue) : undefined}
|
|
suffix="تومان"
|
|
icon={<Wallet size={18} />}
|
|
color="var(--gold)"
|
|
loading={loading}
|
|
/>
|
|
<StatCard
|
|
label="درآمد امروز"
|
|
value={s ? fmtNum(s.today.revenue) : undefined}
|
|
suffix="تومان"
|
|
icon={<Sun size={18} />}
|
|
color="#4ade80"
|
|
sub={s ? `${fmtNum(s.today.count)} خرید` : undefined}
|
|
loading={loading}
|
|
/>
|
|
<StatCard
|
|
label="درآمد این ماه"
|
|
value={s ? fmtNum(s.month.revenue) : undefined}
|
|
suffix="تومان"
|
|
icon={<CalendarDays size={18} />}
|
|
color="#60a5fa"
|
|
loading={loading}
|
|
/>
|
|
<StatCard
|
|
label="خریدها"
|
|
value={s ? fmtNum(s.verified_count) : undefined}
|
|
suffix="خرید"
|
|
icon={<ShoppingBag size={18} />}
|
|
color="#c084fc"
|
|
sub={s ? `میانگین ${fmtNum(s.avg_revenue)} تومان` : undefined}
|
|
loading={loading}
|
|
/>
|
|
<StatCard
|
|
label="خریداران"
|
|
value={s ? fmtNum(s.unique_buyers) : undefined}
|
|
suffix="نفر"
|
|
icon={<Users size={18} />}
|
|
color="#fbbf24"
|
|
loading={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="toolbar mb-4">
|
|
<form
|
|
className="flex gap-2 flex-1 min-w-0"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
setQApplied(q.trim());
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<input
|
|
className="inp"
|
|
placeholder="جستجو با موبایل، نام یا شناسه…"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
/>
|
|
<button className="btn btn-gold flex items-center gap-1">
|
|
<Search size={16} /> جستجو
|
|
</button>
|
|
</form>
|
|
<select
|
|
className="inp"
|
|
value={store}
|
|
onChange={(e) => {
|
|
setStore(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<option value="">همه فروشگاهها</option>
|
|
<option value="bazaar">بازار</option>
|
|
<option value="myket">مایکت</option>
|
|
</select>
|
|
<select
|
|
className="inp"
|
|
value={kind}
|
|
onChange={(e) => {
|
|
setKind(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<option value="">همه انواع</option>
|
|
<option value="coin">سکه</option>
|
|
<option value="ticket">بلیط</option>
|
|
<option value="booster">بوستر</option>
|
|
<option value="vip">VIP</option>
|
|
</select>
|
|
<select
|
|
className="inp"
|
|
value={status}
|
|
onChange={(e) => {
|
|
setStatus(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<option value="">همه وضعیتها</option>
|
|
<option value="verified">پرداختشده</option>
|
|
<option value="pending">در انتظار</option>
|
|
</select>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<TableSkeleton rows={6} cols={8} />
|
|
) : !data || data.purchases.length === 0 ? (
|
|
<Empty
|
|
title="تراکنشی پیدا نشد"
|
|
hint="فیلترها را تغییر دهید یا جستجوی دیگری امتحان کنید."
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="card overflow-x-auto">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>کاربر</th>
|
|
<th>محصول</th>
|
|
<th>نوع</th>
|
|
<th>مبلغ</th>
|
|
<th>دریافت</th>
|
|
<th>فروشگاه</th>
|
|
<th>زمان (تهران)</th>
|
|
<th>وضعیت</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.purchases.map((p) => (
|
|
<tr key={p.id}>
|
|
<td>
|
|
<div className="font-bold">{p.name || "—"}</div>
|
|
<div className="text-[11px] text-[#6f90bd]" dir="ltr">
|
|
{p.mobile} · {p.user_id}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="font-bold">{p.product_title || p.product_id}</div>
|
|
<div className="text-[11px] text-[#6f90bd]" dir="ltr">
|
|
{p.product_id}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<Badge tone={KIND_TONE[p.kind] ?? "gray"}>
|
|
{KIND_LABEL[p.kind] ?? (p.kind || "نامشخص")}
|
|
</Badge>
|
|
</td>
|
|
<td>
|
|
<span dir="ltr" className="font-bold text-[var(--gold)]">
|
|
{fmtNum(p.price_toman)}
|
|
</span>{" "}
|
|
<span className="text-[11px] text-[#8ab4e8]">تومان</span>
|
|
</td>
|
|
<td className="text-[#a9c2e4]">{received(p)}</td>
|
|
<td>{STORE_LABEL[p.store] ?? p.store}</td>
|
|
<td className="text-[#a9c2e4]" dir="ltr">
|
|
{p.created_local}
|
|
</td>
|
|
<td>
|
|
<Badge tone={STATUS_TONE[p.status] ?? "gray"}>
|
|
{STATUS_LABEL[p.status] ?? p.status}
|
|
</Badge>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mt-4">
|
|
<button
|
|
className="btn btn-ghost flex items-center gap-1"
|
|
disabled={page <= 1}
|
|
onClick={() => setPage((p) => p - 1)}
|
|
>
|
|
<ChevronRight size={16} /> قبلی
|
|
</button>
|
|
<span className="text-sm text-[#8ab4e8]">
|
|
صفحه {fmtNum(data.page)} از {fmtNum(data.pages)} — {fmtNum(data.total)} تراکنش
|
|
</span>
|
|
<button
|
|
className="btn btn-ghost flex items-center gap-1"
|
|
disabled={page >= data.pages}
|
|
onClick={() => setPage((p) => p + 1)}
|
|
>
|
|
بعدی <ChevronLeft size={16} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Shell>
|
|
);
|
|
}
|