181 lines
5.9 KiB
TypeScript
181 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import {
|
|
Users,
|
|
ShoppingBag,
|
|
Gamepad2,
|
|
Coins,
|
|
Trophy,
|
|
RotateCcw,
|
|
Wallet,
|
|
} from "lucide-react";
|
|
import Shell from "@/components/Shell";
|
|
import StatCard from "@/components/StatCard";
|
|
import Badge from "@/components/Badge";
|
|
import RevenueChart from "@/components/RevenueChart";
|
|
import { fmtNum } from "@/components/NumberInput";
|
|
import { api } from "@/lib/api";
|
|
import { KIND_LABEL, KIND_TONE } from "@/lib/labels";
|
|
|
|
type Recent = {
|
|
id: number;
|
|
mobile: string;
|
|
name: string;
|
|
kind: string;
|
|
product_id: string;
|
|
product_title: string;
|
|
price_toman: number;
|
|
created_local: string;
|
|
};
|
|
|
|
type Dash = {
|
|
users: number;
|
|
purchases: number;
|
|
games: number;
|
|
coins: number;
|
|
tournaments: number;
|
|
season: number;
|
|
revenue: number;
|
|
revenue_today: { count: number; revenue: number };
|
|
revenue_14d: { date: string; revenue: number; count: number }[];
|
|
recent: Recent[];
|
|
};
|
|
|
|
export default function DashboardPage() {
|
|
const [d, setD] = useState<Dash | null>(null);
|
|
const [err, setErr] = useState("");
|
|
|
|
useEffect(() => {
|
|
api
|
|
.get<Dash>("/dashboard")
|
|
.then(setD)
|
|
.catch((e) => setErr(e.message));
|
|
}, []);
|
|
|
|
async function resetSeason() {
|
|
if (!confirm("فصلِ رتبهبندی صفر شود؟ این کار برگشتناپذیر است.")) return;
|
|
await api.post("/season/reset");
|
|
const nd = await api.get<Dash>("/dashboard");
|
|
setD(nd);
|
|
}
|
|
|
|
const cards = [
|
|
{ label: "درآمد کل", value: d ? fmtNum(d.revenue) : undefined, suffix: "تومان", icon: Wallet, c: "var(--gold)" },
|
|
{ label: "کاربران", value: d ? fmtNum(d.users) : undefined, icon: Users, c: "#60a5fa" },
|
|
{ label: "خریدها", value: d ? fmtNum(d.purchases) : undefined, icon: ShoppingBag, c: "#c084fc" },
|
|
{ label: "بازیها", value: d ? fmtNum(d.games) : undefined, icon: Gamepad2, c: "#4ade80" },
|
|
{ label: "کل سکهها", value: d ? fmtNum(d.coins) : undefined, icon: Coins, c: "var(--gold)" },
|
|
{ label: "تورنومنتها", value: d ? fmtNum(d.tournaments) : undefined, icon: Trophy, c: "#fbbf24" },
|
|
];
|
|
|
|
return (
|
|
<Shell title="داشبورد">
|
|
{err && <p className="text-red-400 mb-4">{err}</p>}
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
|
{cards.map((c) => (
|
|
<StatCard
|
|
key={c.label}
|
|
label={c.label}
|
|
value={c.value}
|
|
suffix={c.suffix}
|
|
icon={<c.icon size={18} />}
|
|
color={c.c}
|
|
loading={!d && !err}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className="card p-5 mt-6">
|
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
|
<div>
|
|
<div className="font-bold">درآمدِ ۱۴ روزِ گذشته</div>
|
|
<div className="text-xs text-[#8ab4e8]">بر اساسِ پرداختهای تأییدشده</div>
|
|
</div>
|
|
{d && (
|
|
<div className="text-sm">
|
|
<span dir="ltr" className="font-bold text-[var(--gold)]">
|
|
{fmtNum(d.revenue_today.revenue)}
|
|
</span>
|
|
<span className="text-[#8ab4e8] mr-1">تومان امروز</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{d ? (
|
|
<RevenueChart days={d.revenue_14d} />
|
|
) : (
|
|
<div className="skeleton h-28 w-full" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="card mt-6 overflow-hidden">
|
|
<div className="flex items-center justify-between px-5 pt-4 pb-2">
|
|
<div className="font-bold">آخرین خریدها</div>
|
|
<Link href="/transactions" className="text-xs text-[var(--gold)] hover:underline">
|
|
همهی تراکنشها
|
|
</Link>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>کاربر</th>
|
|
<th>محصول</th>
|
|
<th>نوع</th>
|
|
<th>مبلغ</th>
|
|
<th>زمان (تهران)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{d?.recent.map((r) => (
|
|
<tr key={r.id}>
|
|
<td>
|
|
<div className="font-bold">{r.name || "—"}</div>
|
|
<div className="text-[11px] text-[#6f90bd]" dir="ltr">
|
|
{r.mobile}
|
|
</div>
|
|
</td>
|
|
<td className="font-bold">{r.product_title || r.product_id}</td>
|
|
<td>
|
|
<Badge tone={KIND_TONE[r.kind] ?? "gray"}>
|
|
{KIND_LABEL[r.kind] ?? (r.kind || "نامشخص")}
|
|
</Badge>
|
|
</td>
|
|
<td>
|
|
<span dir="ltr" className="font-bold text-[var(--gold)]">
|
|
{fmtNum(r.price_toman)}
|
|
</span>{" "}
|
|
<span className="text-[11px] text-[#8ab4e8]">تومان</span>
|
|
</td>
|
|
<td className="text-[#a9c2e4]" dir="ltr">
|
|
{r.created_local}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{d && d.recent.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="text-center text-[#8aa] py-8">
|
|
هنوز خریدی ثبت نشده
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="card p-5 mt-6 flex items-center justify-between flex-wrap gap-3">
|
|
<div>
|
|
<div className="font-bold">فصل رتبهبندی: {d?.season ?? "—"}</div>
|
|
<div className="text-sm text-[#8ab4e8]">
|
|
با ریست، امتیازِ رتبهی همه صفر و فصلِ جدید آغاز میشود.
|
|
</div>
|
|
</div>
|
|
<button className="btn btn-danger flex items-center gap-2" onClick={resetSeason}>
|
|
<RotateCcw size={16} /> ریست فصل
|
|
</button>
|
|
</div>
|
|
</Shell>
|
|
);
|
|
}
|