Files
admin-panel-hokm/app/transactions/page.tsx
T
2026-08-07 09:40:16 +03:30

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