first commit
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user