first commit
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Search, Coins, Gift, Loader2 } from "lucide-react";
|
||||
import Shell from "@/components/Shell";
|
||||
import NumberInput, { fmtNum } from "@/components/NumberInput";
|
||||
import Badge from "@/components/Badge";
|
||||
import Empty from "@/components/Empty";
|
||||
import { TableSkeleton } from "@/components/Skeleton";
|
||||
import { api } from "@/lib/api";
|
||||
import { toastError } from "@/lib/toast";
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
mobile: string;
|
||||
first_name: string | null;
|
||||
coins: number;
|
||||
rank_points: number;
|
||||
is_admin: number;
|
||||
};
|
||||
|
||||
export default function UsersPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [sel, setSel] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load(query = "") {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.get<{ users: User[] }>(
|
||||
`/users${query ? `?q=${encodeURIComponent(query)}` : ""}`,
|
||||
);
|
||||
setUsers(r.users || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Shell title="کاربران">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
load(q);
|
||||
}}
|
||||
className="flex gap-2 mb-4"
|
||||
>
|
||||
<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>
|
||||
|
||||
{loading ? (
|
||||
<TableSkeleton rows={6} cols={7} />
|
||||
) : users.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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="text-[#8aa]">{u.id}</td>
|
||||
<td dir="ltr">{u.mobile}</td>
|
||||
<td>{u.first_name || "—"}</td>
|
||||
<td className="text-[var(--gold)]" dir="ltr">
|
||||
{fmtNum(u.coins)}
|
||||
</td>
|
||||
<td dir="ltr">{fmtNum(u.rank_points)}</td>
|
||||
<td>{u.is_admin ? <Badge tone="gold">مدیر</Badge> : ""}</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost" onClick={() => setSel(u)}>
|
||||
مدیریت
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sel && <ManageUser user={sel} onClose={() => setSel(null)} onDone={() => load(q)} />}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageUser({
|
||||
user,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: User;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [coins, setCoins] = useState(0);
|
||||
const [tickets, setTickets] = useState(0);
|
||||
const [vipDays, setVipDays] = useState(30);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function run(fn: () => Promise<unknown>) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
onDone();
|
||||
} catch (e) {
|
||||
toastError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 grid place-items-center z-20 p-4" onClick={onClose}>
|
||||
<div className="card p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="font-bold text-lg mb-1">{user.first_name || user.mobile}</h2>
|
||||
<p className="text-sm text-[#8aa] mb-4" dir="ltr">
|
||||
{user.mobile} · سکه: {fmtNum(user.coins)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm flex items-center gap-1 mb-1">
|
||||
<Coins size={14} className="text-[var(--gold)]" /> تغییر سکه (+/−)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<NumberInput value={coins} onChange={setCoins} allowNegative />
|
||||
<button
|
||||
className="btn btn-gold"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(() => api.post("/users/coins", { user_id: user.id, amount: coins }))
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : "اعمال"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm flex items-center gap-1 mb-1">
|
||||
<Gift size={14} className="text-[var(--gold)]" /> اعطای بلیط
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<NumberInput value={tickets} onChange={setTickets} />
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(() =>
|
||||
api.post("/users/grant", { user_id: user.id, kind: "ticket", amount: tickets }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : "اعطا"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm flex items-center gap-1 mb-1">
|
||||
<Gift size={14} className="text-[var(--gold)]" /> اعطای VIP (روز)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<NumberInput value={vipDays} onChange={setVipDays} />
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(() =>
|
||||
api.post("/users/grant", { user_id: user.id, kind: "vip", vip_days: vipDays }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : "اعطا"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-ghost w-full mt-6" onClick={onClose}>
|
||||
بستن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user