feat(admin, players): enhance player management and admin interfaces
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Updated the player management components to improve error handling and display for various operations, including ticket loading and transaction retrieval. Introduced new status badges for better visual representation of player statuses. Enhanced localization support for error messages and improved the overall user experience in the admin console. Refactored components for better maintainability and clarity.
This commit is contained in:
@@ -172,7 +172,9 @@ export type SettlementCreditLedgerRow = {
|
||||
|
||||
export async function getCreditLedger(params: {
|
||||
admin_site_id: number;
|
||||
settlement_period_id: number;
|
||||
/** 0 或不传表示不限账期(玩家详情等场景) */
|
||||
settlement_period_id?: number;
|
||||
player_id?: number;
|
||||
entry_kind?: "credit";
|
||||
bet_flow_only?: boolean;
|
||||
bet_flow_display?: "simple";
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { AgentsDirectoryConsole } from "@/modules/agents/agents-directory-console";
|
||||
import {
|
||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("agents", "listTitle");
|
||||
|
||||
export default function AgentsListPage() {
|
||||
return <AgentsDirectoryConsole />;
|
||||
return (
|
||||
<ModuleScaffold embedded>
|
||||
<AdminPermissionGate
|
||||
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
|
||||
>
|
||||
<AgentsDirectoryConsole />
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
}
|
||||
|
||||
.admin-list-title {
|
||||
@apply text-[1.05rem] font-semibold tracking-tight text-[#13315f];
|
||||
@apply text-[1.05rem] font-semibold tracking-tight text-foreground;
|
||||
}
|
||||
|
||||
.admin-list-content {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { resolveAdminPageTitle } from "@/lib/admin-page-title";
|
||||
import { hydrateAdminUiLocale } from "@/lib/admin-locale";
|
||||
import { normalizeAdminLanguage } from "@/i18n/languages";
|
||||
|
||||
/** 随界面语言更新 `document.title`(弥补 Next 静态 metadata 无法按用户语言切换)。 */
|
||||
export function AdminDocumentTitle() {
|
||||
@@ -22,7 +24,19 @@ export function AdminDocumentTitle() {
|
||||
|
||||
const pageTitle = t(spec.key, { ns: spec.ns, ...(spec.params ?? {}) });
|
||||
document.title = `${pageTitle} · ${appTitle}`;
|
||||
}, [pathname, i18n.language, t]);
|
||||
}, [pathname, i18n.language, i18n.resolvedLanguage, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const locale = hydrateAdminUiLocale();
|
||||
if (!locale) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAdminLanguage(locale);
|
||||
const current = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
if (normalized !== current) {
|
||||
void i18n.changeLanguage(normalized);
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -71,6 +72,7 @@ export function AdminNumericStepper({
|
||||
className,
|
||||
suffix,
|
||||
}: AdminNumericStepperProps): React.ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const current = parseNumeric(value, integer);
|
||||
const atMin = min !== undefined && current <= min;
|
||||
const atMax = max !== undefined && current >= max;
|
||||
@@ -122,7 +124,7 @@ export function AdminNumericStepper({
|
||||
size="icon-sm"
|
||||
className="h-auto shrink-0 rounded-none border-r border-input/80 px-3 hover:bg-muted/60"
|
||||
disabled={disabled || atMin}
|
||||
aria-label="减少"
|
||||
aria-label={t("decrease", { defaultValue: "减少" })}
|
||||
onClick={() => applyDelta(-step)}
|
||||
>
|
||||
<Minus className="size-4" aria-hidden />
|
||||
@@ -170,7 +172,7 @@ export function AdminNumericStepper({
|
||||
size="icon-sm"
|
||||
className="h-auto shrink-0 rounded-none border-l border-input/80 px-3 hover:bg-muted/60"
|
||||
disabled={disabled || atMax}
|
||||
aria-label="增加"
|
||||
aria-label={t("increase", { defaultValue: "增加" })}
|
||||
onClick={() => applyDelta(step)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
|
||||
@@ -28,7 +28,8 @@ import { ADMIN_BASE, type AdminNavGroup, type AdminNavItem } from "@/modules/_co
|
||||
|
||||
const NAV_BTN =
|
||||
"h-8 gap-2 px-2.5 py-0 text-[13px] leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground [&_svg]:size-4";
|
||||
const NAV_ACTIVE = "data-active:bg-red-600 data-active:text-white data-active:font-medium data-active:shadow-sm";
|
||||
const NAV_ACTIVE =
|
||||
"data-active:bg-sidebar-primary/90 data-active:text-sidebar-primary-foreground data-active:font-medium data-active:shadow-sm";
|
||||
const SUB_NAV =
|
||||
"h-8 min-h-8 gap-2 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm [&_svg]:size-4";
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { playerFundingModeLabel } from "@/lib/admin-player-display";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { isCreditFundingPlayer, playerFundingModeLabel } from "@/lib/admin-player-display";
|
||||
import { resolveLedgerSourceTone } from "@/lib/admin-status-tone";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FundingRow = {
|
||||
funding_mode?: string | null;
|
||||
uses_credit?: boolean;
|
||||
auth_source?: string | null;
|
||||
available_credit?: number | null;
|
||||
credit_limit?: number | null;
|
||||
};
|
||||
|
||||
export function PlayerFundingModeBadge({
|
||||
@@ -19,7 +22,13 @@ export function PlayerFundingModeBadge({
|
||||
className?: string;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation("players");
|
||||
const isCredit = row.funding_mode === "credit" || row.uses_credit === true;
|
||||
const normalized = {
|
||||
funding_mode: row.funding_mode ?? undefined,
|
||||
uses_credit: row.uses_credit,
|
||||
available_credit: row.available_credit ?? undefined,
|
||||
credit_limit: row.credit_limit ?? undefined,
|
||||
};
|
||||
const isCredit = isCreditFundingPlayer(normalized);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -31,7 +40,7 @@ export function PlayerFundingModeBadge({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{playerFundingModeLabel(row, t)}
|
||||
{playerFundingModeLabel(normalized, t)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -54,8 +63,6 @@ export function PlayerLedgerSourceBadge({
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceClass = "border-border bg-muted/30 text-muted-foreground";
|
||||
|
||||
const label =
|
||||
ledgerSource === "wallet_txn"
|
||||
? t("ledgerWallet", { defaultValue: "钱包流水" })
|
||||
@@ -68,14 +75,8 @@ export function PlayerLedgerSourceBadge({
|
||||
: t("ledgerCredit", { defaultValue: "信用流水" });
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
sourceClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AdminStatusBadge status={ledgerSource} tone={resolveLedgerSourceTone(ledgerSource)} className={className}>
|
||||
{label}
|
||||
</span>
|
||||
</AdminStatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-11 px-3 text-center align-middle font-semibold tracking-wide whitespace-nowrap text-[#17305f] [&:has([role=checkbox])]:pr-0",
|
||||
"h-11 px-3 text-center align-middle font-semibold tracking-wide whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"px-3 py-2.5 text-center align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
"px-3 py-2.5 text-center align-middle [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -64,10 +64,10 @@ export function playerBalanceCells(
|
||||
}
|
||||
|
||||
export function playerFundingModeLabel(
|
||||
row: { funding_mode?: string | null },
|
||||
row: Pick<AdminPlayerRow, "funding_mode" | "uses_credit" | "available_credit" | "credit_limit">,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
if (row.funding_mode === "credit") {
|
||||
if (isCreditFundingPlayer(row)) {
|
||||
return t("players:fundingCredit", { defaultValue: "信用" });
|
||||
}
|
||||
if (row.funding_mode === "wallet") {
|
||||
|
||||
@@ -58,10 +58,29 @@ const STATUS_TONE_MAP: Record<string, AdminStatusTone> = {
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
|
||||
// 代理账期账单
|
||||
// 代理账期 / 账单
|
||||
confirmed: "warning",
|
||||
partial_paid: "warning",
|
||||
overdue: "danger",
|
||||
reversed: "neutral",
|
||||
|
||||
// 报表导出任务
|
||||
queued: "info",
|
||||
|
||||
// 开奖结果批次
|
||||
published: "success",
|
||||
|
||||
// 对账明细
|
||||
resolved: "success",
|
||||
unresolved: "warning",
|
||||
stale_processing: "warning",
|
||||
missing_wallet_txn: "danger",
|
||||
unexpected_wallet_txn: "danger",
|
||||
missing_refund: "warning",
|
||||
missing_reversal: "warning",
|
||||
main_site_record_missing: "warning",
|
||||
main_site_failed: "danger",
|
||||
main_site_unavailable: "neutral",
|
||||
|
||||
// 注单
|
||||
pending_confirm: "info",
|
||||
@@ -70,12 +89,15 @@ const STATUS_TONE_MAP: Record<string, AdminStatusTone> = {
|
||||
pending_payout: "warning",
|
||||
settled_win: "success",
|
||||
settled_lose: "neutral",
|
||||
placed: "neutral",
|
||||
pending_draw: "info",
|
||||
partial_failed: "warning",
|
||||
refunded: "neutral",
|
||||
|
||||
// 钱包 / 划转
|
||||
processing: "info",
|
||||
posted: "success",
|
||||
pending_reconcile: "warning",
|
||||
reversed: "neutral",
|
||||
manually_processed: "indigo",
|
||||
|
||||
// 对账
|
||||
@@ -117,6 +139,49 @@ export function resolveRoleStatusTone(status: number): AdminStatusTone {
|
||||
return status === 1 ? "success" : "neutral";
|
||||
}
|
||||
|
||||
/** 风控号码池:售罄 / 高占用 / 正常 */
|
||||
export function resolveRiskPoolStatusTone(
|
||||
row: { is_sold_out?: boolean; usage_ratio?: number | null },
|
||||
highRiskThreshold = 0.8,
|
||||
): AdminStatusTone {
|
||||
if (row.is_sold_out) {
|
||||
return "danger";
|
||||
}
|
||||
if ((row.usage_ratio ?? 0) >= highRiskThreshold) {
|
||||
return "warning";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
/** 账务流水行:优先账单状态,否则入账状态 */
|
||||
export function resolveCreditLedgerRowStatusTone(
|
||||
row: { bill_status?: string | null; status?: string | null },
|
||||
): AdminStatusTone {
|
||||
if (row.bill_status) {
|
||||
return resolveAdminStatusTone(row.bill_status);
|
||||
}
|
||||
return resolveAdminStatusTone(row.status ?? "posted");
|
||||
}
|
||||
|
||||
/** 流水来源渠道色标 */
|
||||
export function resolveLedgerSourceTone(
|
||||
ledgerSource: string | null | undefined,
|
||||
): AdminStatusTone {
|
||||
switch (ledgerSource) {
|
||||
case "wallet_txn":
|
||||
case "payment_record":
|
||||
return "success";
|
||||
case "credit_ledger":
|
||||
return "info";
|
||||
case "settlement_adjustment":
|
||||
return "warning";
|
||||
case "share_ledger":
|
||||
return "indigo";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAdminStatusTone(
|
||||
status: string | number | boolean | null | undefined,
|
||||
): AdminStatusTone {
|
||||
|
||||
28
src/lib/resolve-admin-site-id.ts
Normal file
28
src/lib/resolve-admin-site-id.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||
|
||||
/** 按玩家 site_code 从登录态解析 admin_sites.id(结算/信用流水 API 必填)。 */
|
||||
export function resolveAdminSiteId(
|
||||
siteCode: string,
|
||||
profile: AdminProfile | null | undefined,
|
||||
): number | null {
|
||||
const code = siteCode.trim().toUpperCase();
|
||||
if (code === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (profile?.agent?.site_code?.trim().toUpperCase() === code) {
|
||||
return profile.agent.admin_site_id;
|
||||
}
|
||||
if (profile?.site?.code?.trim().toUpperCase() === code) {
|
||||
return profile.site.id;
|
||||
}
|
||||
|
||||
const fromAccessible = profile?.accessible_sites?.find(
|
||||
(site) => site.code.trim().toUpperCase() === code,
|
||||
);
|
||||
if (fromAccessible) {
|
||||
return fromAccessible.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function AccountSettingsConsole() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4 md:p-6 lg:p-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-[#13315f]">
|
||||
<h1 className="admin-list-title">
|
||||
{t("accountSettings")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -275,7 +275,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
defaultValue: "可新增自定义角色并配置权限;内置角色(超级管理员、站点管理员、代理)不可删除。",
|
||||
})}
|
||||
</p>
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
<div className="rounded-md border">
|
||||
<Table id="admin-roles-table">
|
||||
<TableHeader>
|
||||
|
||||
@@ -451,7 +451,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table id="admin-users-table">
|
||||
<TableHeader>
|
||||
|
||||
@@ -127,6 +127,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
||||
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
||||
const [selectedProfileErr, setSelectedProfileErr] = useState<string | null>(null);
|
||||
const [playerCreateRequestKey, setPlayerCreateRequestKey] = useState(0);
|
||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||
@@ -450,12 +451,14 @@ export function AgentsConsole(): React.ReactElement {
|
||||
if (selectedNode === null) {
|
||||
setSelectedProfile(null);
|
||||
setSelectedProfileLoading(false);
|
||||
setSelectedProfileErr(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeId = selectedNode.id;
|
||||
let cancelled = false;
|
||||
setSelectedProfileLoading(true);
|
||||
setSelectedProfileErr(null);
|
||||
|
||||
void getAgentNodeProfile(nodeId)
|
||||
.then((row) => {
|
||||
@@ -470,6 +473,9 @@ export function AgentsConsole(): React.ReactElement {
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSelectedProfile(null);
|
||||
setSelectedProfileErr(
|
||||
t("profile.loadFailed", { defaultValue: "代理档案加载失败,请稍后重试。" }),
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -1024,6 +1030,10 @@ export function AgentsConsole(): React.ReactElement {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedProfileErr ? (
|
||||
<p className="border-b border-border/70 px-4 py-2 text-sm text-destructive">{selectedProfileErr}</p>
|
||||
) : null}
|
||||
|
||||
<AgentLineDetailPanel
|
||||
node={selectedNode}
|
||||
profile={selectedProfile}
|
||||
|
||||
@@ -69,7 +69,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_SETTLEMENT_AGENT_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
|
||||
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||
@@ -201,6 +201,7 @@ export function AgentsPlayersPanel({
|
||||
const [total, setTotal] = useState(0);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [listErr, setListErr] = useState<string | null>(null);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -252,6 +253,7 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setListErr(null);
|
||||
try {
|
||||
const data = await getAdminPlayers({
|
||||
page,
|
||||
@@ -262,10 +264,11 @@ export function AgentsPlayersPanel({
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
setLastPage(Math.max(1, data.meta.last_page));
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLastPage(1);
|
||||
setListErr(e instanceof LotteryApiBizError ? e.message : t("common:loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -819,6 +822,9 @@ export function AgentsPlayersPanel({
|
||||
<AdminLoadingState minHeight="6rem" />
|
||||
) : (
|
||||
<>
|
||||
{listErr ? (
|
||||
<p className="mb-3 text-sm text-destructive">{listErr}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
@@ -910,7 +916,7 @@ export function AgentsPlayersPanel({
|
||||
</TableCell>
|
||||
{!embedded ? (
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(row.status)}>
|
||||
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
|
||||
{playerStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
|
||||
@@ -202,7 +202,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
|
||||
@@ -262,12 +262,18 @@ export function DashboardConsole(): ReactElement {
|
||||
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
|
||||
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
|
||||
const rawPlatformUsagePct = platformRisk?.usage_percent;
|
||||
const platformUsagePct =
|
||||
typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct)
|
||||
? Math.min(100, Math.max(0, rawPlatformUsagePct))
|
||||
: platformCap > 0
|
||||
? (platformLocked / platformCap) * 100
|
||||
: 0;
|
||||
const useCurrentDrawRisk = drawId != null;
|
||||
const capUsageLocked = useCurrentDrawRisk ? riskLocked : platformLocked;
|
||||
const capUsageCap = useCurrentDrawRisk ? riskCap : platformCap;
|
||||
const capUsagePct =
|
||||
capUsageCap > 0
|
||||
? (capUsageLocked / capUsageCap) * 100
|
||||
: typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct)
|
||||
? Math.min(100, Math.max(0, rawPlatformUsagePct))
|
||||
: platformCap > 0
|
||||
? (platformLocked / platformCap) * 100
|
||||
: 0;
|
||||
const showCapUsageCard = useCurrentDrawRisk || platformRisk != null;
|
||||
|
||||
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||
|
||||
@@ -403,25 +409,25 @@ export function DashboardConsole(): ReactElement {
|
||||
</DashboardPanelCard>
|
||||
|
||||
<DashboardPanelCard
|
||||
href="/admin/risk"
|
||||
href={drawScopedHref(drawId, "/risk/pools")}
|
||||
title={t("riskCapUsage")}
|
||||
value={`${platformUsagePct.toFixed(1)}%`}
|
||||
value={`${capUsagePct.toFixed(1)}%`}
|
||||
actionLabel={t("occupancyDetails")}
|
||||
icon={<Shield className="size-5" aria-hidden />}
|
||||
accent={
|
||||
platformUsagePct >= 90
|
||||
capUsagePct >= 90
|
||||
? "destructive"
|
||||
: platformUsagePct >= 70
|
||||
: capUsagePct >= 70
|
||||
? "primary"
|
||||
: "muted"
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
{platformRisk != null ? (
|
||||
{showCapUsageCard ? (
|
||||
<CapUsageBar
|
||||
locked={platformLocked}
|
||||
cap={platformCap}
|
||||
usagePct={platformUsagePct}
|
||||
locked={capUsageLocked}
|
||||
cap={capUsageCap}
|
||||
usagePct={capUsagePct}
|
||||
formatMoney={formatMoneyMinor}
|
||||
currency={currency}
|
||||
compact
|
||||
|
||||
@@ -18,6 +18,7 @@ import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -273,9 +274,11 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("status")}</p>
|
||||
<p className="mt-1 text-base font-semibold">
|
||||
{statusOn ? t("enabled") : t("disabled")}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<AdminStatusBadge status={statusOn ? "enabled" : "disabled"}>
|
||||
{statusOn ? t("enabled") : t("disabled")}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("payoutRate")}</p>
|
||||
|
||||
317
src/modules/players/player-credit-ledger-tab.tsx
Normal file
317
src/modules/players/player-credit-ledger-tab.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import {
|
||||
getCreditLedger,
|
||||
type SettlementCreditLedgerRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
import { AdminTableMoney } from "@/components/admin/admin-table-money";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { resolveAdminSiteId } from "@/lib/resolve-admin-site-id";
|
||||
import { resolveCreditLedgerRowStatusTone } from "@/lib/admin-status-tone";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
creditLedgerReasonLabel,
|
||||
settlementBillStatusLabel,
|
||||
} from "@/modules/settlement/settlement-status-label";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function signedLedgerAmount(row: SettlementCreditLedgerRow): number {
|
||||
if (typeof row.signed_amount === "number") {
|
||||
return row.signed_amount;
|
||||
}
|
||||
|
||||
return row.direction === 1 ? row.amount : -row.amount;
|
||||
}
|
||||
|
||||
function signedLedgerAmountClass(signed: number): string {
|
||||
if (signed < 0) {
|
||||
return "font-medium text-destructive";
|
||||
}
|
||||
if (signed > 0) {
|
||||
return "font-medium text-emerald-700 dark:text-emerald-400";
|
||||
}
|
||||
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function formatSignedLedgerAmount(signed: number, currencyCode: string): string {
|
||||
if (signed === 0) {
|
||||
return formatAdminMinorUnits(0, currencyCode);
|
||||
}
|
||||
|
||||
const prefix = signed < 0 ? "−" : "+";
|
||||
return `${prefix}${formatAdminMinorUnits(Math.abs(signed), currencyCode)}`;
|
||||
}
|
||||
|
||||
function CreditLedgerReasonBadge({ reason }: { reason: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter"]);
|
||||
const label = creditLedgerReasonLabel(reason, t);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted/30 px-2 py-0.5 text-xs font-medium text-foreground/80">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function refLabel(row: SettlementCreditLedgerRow): string {
|
||||
const parts: string[] = [];
|
||||
if (row.biz_no) {
|
||||
parts.push(row.biz_no);
|
||||
}
|
||||
if (row.draw_no) {
|
||||
parts.push(row.draw_no);
|
||||
}
|
||||
if (row.play_code) {
|
||||
parts.push(row.play_code);
|
||||
}
|
||||
if (row.ticket_item_id) {
|
||||
parts.push(`#${row.ticket_item_id}`);
|
||||
}
|
||||
if (row.settlement_bill_id) {
|
||||
parts.push(`#${row.settlement_bill_id}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(" · ") : "—";
|
||||
}
|
||||
|
||||
type PlayerCreditLedgerTabProps = {
|
||||
playerId: number;
|
||||
siteCode: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
export function PlayerCreditLedgerTab({
|
||||
playerId,
|
||||
siteCode,
|
||||
currencyCode,
|
||||
}: PlayerCreditLedgerTabProps): React.ReactElement {
|
||||
const { t } = useTranslation(["players", "settlementCenter", "common"]);
|
||||
const tRef = useTranslationRef(["players", "common"]);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(() =>
|
||||
resolveAdminSiteId(siteCode, profile),
|
||||
);
|
||||
const [siteResolveErr, setSiteResolveErr] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<SettlementCreditLedgerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
let cancelled = false;
|
||||
const fromProfile = resolveAdminSiteId(siteCode, profile);
|
||||
if (fromProfile !== null) {
|
||||
setAdminSiteId(fromProfile);
|
||||
setSiteResolveErr(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSiteResolveErr(null);
|
||||
void getAdminIntegrationSites()
|
||||
.then((data) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const code = siteCode.trim().toUpperCase();
|
||||
const match = data.items.find((site) => site.code.trim().toUpperCase() === code);
|
||||
if (match) {
|
||||
setAdminSiteId(match.id);
|
||||
} else {
|
||||
setAdminSiteId(null);
|
||||
setSiteResolveErr(tRef.current("creditLedgerSiteNotFound", { defaultValue: "无法解析玩家所属站点" }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAdminSiteId(null);
|
||||
setSiteResolveErr(tRef.current("loadFailed"));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [profile, siteCode, tRef]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (adminSiteId === null) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoadErr(null);
|
||||
try {
|
||||
const res = await getCreditLedger({
|
||||
admin_site_id: adminSiteId,
|
||||
player_id: playerId,
|
||||
page,
|
||||
per_page: perPage,
|
||||
});
|
||||
setRows(res.items ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
} catch (e) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setLoadErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, page, perPage, playerId, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId === null) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
}, [adminSiteId, load]);
|
||||
|
||||
const lastPage = Math.max(1, Math.ceil(total / Math.max(1, perPage)));
|
||||
const colSpan = 7;
|
||||
|
||||
if (siteResolveErr) {
|
||||
return <p className="text-sm text-destructive">{siteResolveErr}</p>;
|
||||
}
|
||||
|
||||
if (adminSiteId === null && !loading) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
{t("creditLedgerSiteNotFound", { defaultValue: "无法解析玩家所属站点" })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{loadErr ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm text-destructive">{loadErr}</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void load()}>
|
||||
{t("retry", { ns: "common", defaultValue: "重试" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("creditLedger.columns.txn", { ns: "settlementCenter", defaultValue: "流水号" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.channel", { ns: "settlementCenter", defaultValue: "渠道" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.reason", { ns: "settlementCenter", defaultValue: "业务类型" })}</TableHead>
|
||||
<TableHead className="text-center">{t("creditLedger.columns.amount", { ns: "settlementCenter", defaultValue: "金额" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.ref", { ns: "settlementCenter", defaultValue: "关联" })}</TableHead>
|
||||
<TableHead>{t("columns.status", { ns: "settlementCenter", defaultValue: "状态" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.time", { ns: "settlementCenter", defaultValue: "时间" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && rows.length === 0 ? <AdminTableLoadingRow colSpan={colSpan} /> : null}
|
||||
{rows.map((row) => {
|
||||
const signed = signedLedgerAmount(row);
|
||||
const statusText = row.bill_status
|
||||
? settlementBillStatusLabel(row.bill_status, t)
|
||||
: row.status === "posted"
|
||||
? t("creditLedger.status.posted", { ns: "settlementCenter", defaultValue: "已入账" })
|
||||
: row.status || "—";
|
||||
|
||||
return (
|
||||
<TableRow key={row.row_key}>
|
||||
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
||||
<TableCell>
|
||||
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CreditLedgerReasonBadge reason={row.biz_type} />
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-center text-xs", signedLedgerAmountClass(signed))}>
|
||||
<AdminTableMoney>
|
||||
{row.biz_type === "bet_hold"
|
||||
? t("creditLedger.reason.freezeAmount", {
|
||||
ns: "settlementCenter",
|
||||
defaultValue: "冻结 {{amount}}",
|
||||
amount: formatAdminMinorUnits(row.amount, currencyCode),
|
||||
})
|
||||
: formatSignedLedgerAmount(signed, currencyCode)}
|
||||
</AdminTableMoney>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{row.ticket_item_id ? (
|
||||
<Link
|
||||
href={`/admin/tickets?ticket_item_id=${row.ticket_item_id}`}
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{refLabel(row)}
|
||||
</Link>
|
||||
) : (
|
||||
refLabel(row)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge
|
||||
status={row.bill_status ?? row.status ?? "posted"}
|
||||
tone={resolveCreditLedgerRowStatusTone(row)}
|
||||
>
|
||||
{statusText}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-xs text-muted-foreground">
|
||||
{row.created_at ? formatTs(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!loading && rows.length === 0 && !loadErr ? (
|
||||
<AdminTableNoResourceRow colSpan={colSpan} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-credit-ledger"
|
||||
total={total}
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,9 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
||||
import { AdminTableMoney } from "@/components/admin/admin-table-money";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { PlayerCreditLedgerTab } from "@/modules/players/player-credit-ledger-tab";
|
||||
import {
|
||||
isCreditFundingPlayer,
|
||||
playerAuthSourceLabel,
|
||||
@@ -115,6 +117,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
const [ticketTotal, setTicketTotal] = useState(0);
|
||||
const [ticketLastPage, setTicketLastPage] = useState(1);
|
||||
const [ticketsLoading, setTicketsLoading] = useState(false);
|
||||
const [ticketsErr, setTicketsErr] = useState<string | null>(null);
|
||||
|
||||
const [txnPage, setTxnPage] = useState(1);
|
||||
const [txnPerPage, setTxnPerPage] = useState(10);
|
||||
@@ -122,6 +125,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
const [txnTotal, setTxnTotal] = useState(0);
|
||||
const [txnLastPage, setTxnLastPage] = useState(1);
|
||||
const [txnsLoading, setTxnsLoading] = useState(false);
|
||||
const [txnsErr, setTxnsErr] = useState<string | null>(null);
|
||||
|
||||
const [transferPage, setTransferPage] = useState(1);
|
||||
const [transferPerPage, setTransferPerPage] = useState(10);
|
||||
@@ -129,6 +133,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
const [transferTotal, setTransferTotal] = useState(0);
|
||||
const [transferLastPage, setTransferLastPage] = useState(1);
|
||||
const [transfersLoading, setTransfersLoading] = useState(false);
|
||||
const [transfersErr, setTransfersErr] = useState<string | null>(null);
|
||||
|
||||
const loadPlayer = useCallback(async () => {
|
||||
setPlayerLoading(true);
|
||||
@@ -145,6 +150,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
|
||||
const loadTickets = useCallback(async () => {
|
||||
setTicketsLoading(true);
|
||||
setTicketsErr(null);
|
||||
try {
|
||||
const d = await getAdminPlayerTicketItems(playerId, {
|
||||
page: ticketPage,
|
||||
@@ -153,17 +159,19 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
setTickets(d.items);
|
||||
setTicketTotal(d.total);
|
||||
setTicketLastPage(Math.max(1, d.last_page));
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setTickets([]);
|
||||
setTicketTotal(0);
|
||||
setTicketLastPage(1);
|
||||
setTicketsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setTicketsLoading(false);
|
||||
}
|
||||
}, [playerId, ticketPage, ticketPerPage]);
|
||||
}, [playerId, ticketPage, ticketPerPage, tRef]);
|
||||
|
||||
const loadTxns = useCallback(async () => {
|
||||
setTxnsLoading(true);
|
||||
setTxnsErr(null);
|
||||
try {
|
||||
const d = await getAdminWalletTransactions({
|
||||
player_id: playerId,
|
||||
@@ -173,17 +181,19 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
setTxns(d.items);
|
||||
setTxnTotal(d.total);
|
||||
setTxnLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setTxns([]);
|
||||
setTxnTotal(0);
|
||||
setTxnLastPage(1);
|
||||
setTxnsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setTxnsLoading(false);
|
||||
}
|
||||
}, [playerId, txnPage, txnPerPage]);
|
||||
}, [playerId, txnPage, txnPerPage, tRef]);
|
||||
|
||||
const loadTransfers = useCallback(async () => {
|
||||
setTransfersLoading(true);
|
||||
setTransfersErr(null);
|
||||
try {
|
||||
const d = await getAdminTransferOrders({
|
||||
player_id: playerId,
|
||||
@@ -193,14 +203,15 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
setTransfers(d.items);
|
||||
setTransferTotal(d.total);
|
||||
setTransferLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setTransfers([]);
|
||||
setTransferTotal(0);
|
||||
setTransferLastPage(1);
|
||||
setTransfersErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setTransfersLoading(false);
|
||||
}
|
||||
}, [playerId, transferPage, transferPerPage]);
|
||||
}, [playerId, transferPage, transferPerPage, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void loadPlayer();
|
||||
@@ -212,7 +223,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
}, [player, loadTickets]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!player) return;
|
||||
if (!player || isCreditFundingPlayer(player)) return;
|
||||
void loadTxns();
|
||||
}, [player, loadTxns]);
|
||||
|
||||
@@ -344,27 +355,27 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<dl className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("creditLimit")}</p>
|
||||
<p className="mt-1 text-sm font-semibold tabular-nums">
|
||||
<AdminTableMoney className="mt-1 text-sm font-semibold">
|
||||
{player.credit_limit != null
|
||||
? formatPlayerCreditAmount(player.credit_limit, player.default_currency)
|
||||
: "—"}
|
||||
</p>
|
||||
</AdminTableMoney>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("availableCredit")}</p>
|
||||
<p className="mt-1 text-sm font-semibold tabular-nums">
|
||||
<AdminTableMoney className="mt-1 text-sm font-semibold">
|
||||
{player.available_credit != null
|
||||
? formatPlayerCreditAmount(player.available_credit, player.default_currency)
|
||||
: "—"}
|
||||
</p>
|
||||
</AdminTableMoney>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("usedCredit")}</p>
|
||||
<p className="mt-1 text-sm tabular-nums text-muted-foreground">
|
||||
<AdminTableMoney className="mt-1 text-sm text-muted-foreground">
|
||||
{player.used_credit != null
|
||||
? formatPlayerCreditAmount(player.used_credit, player.default_currency)
|
||||
: "—"}
|
||||
</p>
|
||||
</AdminTableMoney>
|
||||
</div>
|
||||
</dl>
|
||||
) : player.wallets.length === 0 ? (
|
||||
@@ -417,6 +428,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{ticketsErr ? (
|
||||
<p className="mb-3 text-sm text-destructive">{ticketsErr}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -507,59 +521,76 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("txnNo", { ns: "wallet" })}</TableHead>
|
||||
<TableHead>{t("bizType", { ns: "wallet" })}</TableHead>
|
||||
<TableHead className="text-center">{t("txnAmount")}</TableHead>
|
||||
<TableHead className="text-center">{t("balanceAfterTxn")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("createdAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{txnsLoading && txns.length === 0 ? <AdminTableLoadingRow colSpan={6} /> : null}
|
||||
{txns.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
||||
<TableCell className="text-xs">{row.biz_type}</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount, player.default_currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.balance_after, player.default_currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{walletStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!txnsLoading && txns.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={6} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-txns"
|
||||
total={txnTotal}
|
||||
page={txnPage}
|
||||
lastPage={txnLastPage}
|
||||
perPage={txnPerPage}
|
||||
loading={txnsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setTxnPerPage(n);
|
||||
setTxnPage(1);
|
||||
}}
|
||||
onPageChange={setTxnPage}
|
||||
/>
|
||||
{isCreditPlayer ? (
|
||||
<PlayerCreditLedgerTab
|
||||
playerId={player.id}
|
||||
siteCode={player.site_code}
|
||||
currencyCode={player.default_currency}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{txnsErr ? (
|
||||
<p className="mb-3 text-sm text-destructive">{txnsErr}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("txnNo", { ns: "wallet" })}</TableHead>
|
||||
<TableHead>{t("bizType", { ns: "wallet" })}</TableHead>
|
||||
<TableHead className="text-center">{t("txnAmount")}</TableHead>
|
||||
<TableHead className="text-center">{t("balanceAfterTxn")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("createdAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{txnsLoading && txns.length === 0 ? <AdminTableLoadingRow colSpan={6} /> : null}
|
||||
{txns.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
||||
<TableCell className="text-xs">{row.biz_type}</TableCell>
|
||||
<TableCell className="text-center text-xs">
|
||||
<AdminTableMoney>
|
||||
{row.amount_formatted ?? formatAdminMinorUnits(row.amount, player.default_currency)}
|
||||
</AdminTableMoney>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs">
|
||||
<AdminTableMoney>
|
||||
{formatAdminMinorUnits(row.balance_after, player.default_currency)}
|
||||
</AdminTableMoney>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{walletStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!txnsLoading && txns.length === 0 && !txnsErr ? (
|
||||
<AdminTableNoResourceRow colSpan={6} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-txns"
|
||||
total={txnTotal}
|
||||
page={txnPage}
|
||||
lastPage={txnLastPage}
|
||||
perPage={txnPerPage}
|
||||
loading={txnsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setTxnPerPage(n);
|
||||
setTxnPage(1);
|
||||
}}
|
||||
onPageChange={setTxnPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -571,6 +602,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<CardTitle className="admin-list-title">{t("tabTransferOrders")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{transfersErr ? (
|
||||
<p className="mb-3 text-sm text-destructive">{transfersErr}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -603,7 +637,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!transfersLoading && transfers.length === 0 ? (
|
||||
{!transfersLoading && transfers.length === 0 && !transfersErr ? (
|
||||
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-st
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminTableMoney } from "@/components/admin/admin-table-money";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
||||
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
||||
@@ -531,23 +532,23 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table id="players-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("site")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
|
||||
<AdminAgentHead />
|
||||
<TableHead>{t("sitePlayerId")}</TableHead>
|
||||
<TableHead>{t("username")}</TableHead>
|
||||
<TableHead>{t("nickname")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("riskTags", { defaultValue: "风控标签" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
@@ -567,37 +568,14 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{row.site_code}</span>
|
||||
</TableCell>
|
||||
<AdminAgentCell row={row} />
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{row.site_player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell>{row.username ?? "—"}</TableCell>
|
||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-[16rem]">
|
||||
{riskTags.length > 0 ? (
|
||||
<div className="flex flex-nowrap items-center gap-1 overflow-x-auto whitespace-nowrap" title={riskTags.join(", ")}>
|
||||
{riskTags.map((tag) => (
|
||||
<span
|
||||
key={`${row.id}-${tag}`}
|
||||
className="inline-flex shrink-0 items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium leading-4 text-amber-900"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.default_currency}</TableCell>
|
||||
<TableCell>
|
||||
<PlayerFundingModeBadge row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
|
||||
{balances.balance}
|
||||
<TableCell className="text-center text-xs">
|
||||
<AdminTableMoney>{balances.balance}</AdminTableMoney>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
|
||||
{balances.available}
|
||||
<TableCell className="text-center text-xs">
|
||||
<AdminTableMoney>{balances.available}</AdminTableMoney>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.status === 2 ? (
|
||||
@@ -632,11 +610,34 @@ export function PlayersConsole(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
|
||||
{playerStatusLabelT(row.status, t)}
|
||||
</span>
|
||||
</AdminStatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<AdminAgentCell row={row} />
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{row.site_player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell>{row.username ?? "—"}</TableCell>
|
||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-[16rem]">
|
||||
{riskTags.length > 0 ? (
|
||||
<div className="flex flex-nowrap items-center gap-1 overflow-x-auto whitespace-nowrap" title={riskTags.join(", ")}>
|
||||
{riskTags.map((tag) => (
|
||||
<span
|
||||
key={`${row.id}-${tag}`}
|
||||
className="inline-flex shrink-0 items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium leading-4 text-amber-900"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.default_currency}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||
</TableCell>
|
||||
|
||||
@@ -478,7 +478,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
||||
{jobsErr ? <p className="text-sm text-destructive">{jobsErr}</p> : null}
|
||||
{jobs ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
|
||||
@@ -24,6 +24,8 @@ import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
import { drawStatusLabel } from "@/modules/draws/draw-display";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs";
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -1214,7 +1217,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{item.draw_no}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.status}</span>
|
||||
<DrawStatusBadge status={item.status} label={drawStatusLabel(item.status, t)} />
|
||||
</button>
|
||||
))
|
||||
) : null}
|
||||
@@ -1363,7 +1366,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{summary.draw_no}</TableCell>
|
||||
<TableCell>{summary.draw_status}</TableCell>
|
||||
<TableCell>
|
||||
<DrawStatusBadge status={summary.draw_status} label={drawStatusLabel(summary.draw_status, t)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{summary.order_count}</TableCell>
|
||||
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
|
||||
@@ -1376,7 +1381,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
{summary.settlement_batches.map((batch) => (
|
||||
<TableRow key={batch.id} className="bg-muted/15">
|
||||
<TableCell>#{batch.id}</TableCell>
|
||||
<TableCell>{batch.status}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={batch.status}>{batch.status}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{batch.total_ticket_count}</TableCell>
|
||||
<TableCell className="text-center">{batch.total_win_count}</TableCell>
|
||||
<TableCell className="text-center">-</TableCell>
|
||||
@@ -1395,7 +1402,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
<TableCell className="font-mono text-xs">{item.transfer_no}</TableCell>
|
||||
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
|
||||
<TableCell>{item.direction}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={item.status}>{item.status}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.currency_code} {item.amount}</TableCell>
|
||||
<TableCell>{item.external_ref_no || "-"}</TableCell>
|
||||
<TableCell>{item.fail_reason || "-"}</TableCell>
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Shield } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
|
||||
|
||||
const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "pending", label: "statusOptions.pending" },
|
||||
{ value: "open", label: "statusOptions.open" },
|
||||
{ value: "closing", label: "statusOptions.closing" },
|
||||
{ value: "closed", label: "statusOptions.closed" },
|
||||
{ value: "drawing", label: "statusOptions.drawing" },
|
||||
{ value: "review", label: "statusOptions.review" },
|
||||
{ value: "cooldown", label: "statusOptions.cooldown" },
|
||||
{ value: "settling", label: "statusOptions.settling" },
|
||||
{ value: "settled", label: "statusOptions.settled" },
|
||||
{ value: "cancelled", label: "statusOptions.cancelled" },
|
||||
];
|
||||
|
||||
export function RiskIndexConsole() {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskIndex");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [drawNoInput, setDrawNoInput] = useState("");
|
||||
const [drawNoQuery, setDrawNoQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const riskStatusTriggerLabel = useMemo(() => {
|
||||
if (statusFilter === "") {
|
||||
return t("all");
|
||||
}
|
||||
const key = DRAW_STATUS_OPTIONS.find((o) => o.value === statusFilter)?.label;
|
||||
return key ? t(key) : statusFilter;
|
||||
}, [statusFilter, t]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getAdminDraws({
|
||||
page,
|
||||
per_page: perPage,
|
||||
...(drawNoQuery.trim() !== "" ? { draw_no: drawNoQuery.trim() } : {}),
|
||||
...(statusFilter !== "" ? { status: statusFilter } : {}),
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDrawListFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||
|
||||
function applySearch(): void {
|
||||
setDrawNoQuery(drawNoInput.trim());
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const total = data?.meta.total ?? 0;
|
||||
const lastPage = Math.max(1, data?.meta.last_page ?? 1);
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<CardTitle className="admin-list-title">{t("center")}</CardTitle>
|
||||
<div className="admin-list-toolbar lg:w-auto">
|
||||
<div className="admin-list-field lg:min-w-0">
|
||||
<Label
|
||||
htmlFor="risk-index-draw-no"
|
||||
className="text-xs text-muted-foreground sm:w-10 sm:shrink-0"
|
||||
>
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-index-draw-no"
|
||||
placeholder={t("fuzzyDrawNo")}
|
||||
className="w-full sm:w-[18rem] lg:w-[24rem]"
|
||||
value={drawNoInput}
|
||||
onChange={(e) => setDrawNoInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
applySearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label
|
||||
htmlFor="risk-index-status"
|
||||
className="text-xs text-muted-foreground sm:w-10 sm:shrink-0"
|
||||
>
|
||||
{t("status")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={statusFilter === "" ? "all" : statusFilter}
|
||||
onValueChange={(v) => {
|
||||
const next = v == null || v === "all" ? "" : v;
|
||||
setStatusFilter(next);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-index-status" size="sm" className="w-full sm:w-40">
|
||||
<SelectValue>{riskStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="all">{t("all")}</SelectItem>
|
||||
{DRAW_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="risk-index-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => applySearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table id="risk-index-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("closeTime")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={4} />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={4} className="text-muted-foreground" />
|
||||
) : (
|
||||
(data?.items ?? []).map((row: AdminDrawListItem) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono font-medium">{row.draw_no}</TableCell>
|
||||
<TableCell>
|
||||
<DrawStatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{row.close_time ? formatDt(row.close_time) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "risk",
|
||||
label: t("enterRisk"),
|
||||
icon: Shield,
|
||||
href: `/admin/draws/${row.id}/risk/pools`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="risk-index-draws-per-page"
|
||||
total={total}
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -42,6 +43,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { resolveRiskPoolStatusTone } from "@/lib/admin-status-tone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { RiskPoolsPageTitleKey } from "@/modules/risk/risk-display";
|
||||
@@ -315,18 +317,12 @@ export function RiskPoolsConsole({
|
||||
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center rounded px-2 text-xs font-medium",
|
||||
row.is_sold_out
|
||||
? "bg-red-600 text-white"
|
||||
: highRisk
|
||||
? "bg-orange-500 text-white"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
<AdminStatusBadge
|
||||
status={row.is_sold_out ? "sold_out" : highRisk ? "warning" : "normal"}
|
||||
tone={resolveRiskPoolStatusTone(row)}
|
||||
>
|
||||
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
|
||||
</span>
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 text-center">
|
||||
<AdminRowActionsMenu
|
||||
|
||||
@@ -54,6 +54,7 @@ export function AgentBillDetail({
|
||||
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
|
||||
const [downlineShares, setDownlineShares] = useState<DownlineShareBreakdown | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||
const [payAmount, setPayAmount] = useState("");
|
||||
const [payMethod, setPayMethod] = useState("");
|
||||
const [payProof, setPayProof] = useState("");
|
||||
@@ -65,6 +66,7 @@ export function AgentBillDetail({
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadErr(null);
|
||||
try {
|
||||
const data = await getSettlementBill(billId);
|
||||
setBill(data.bill);
|
||||
@@ -72,19 +74,36 @@ export function AgentBillDetail({
|
||||
setRebateAllocations(data.rebate_allocations ?? []);
|
||||
setDownlineShares(data.downline_shares ?? null);
|
||||
setPayAmount(formatAdminMinorDecimal(data.bill.unpaid_amount ?? 0, currencyCode));
|
||||
} catch (e) {
|
||||
setBill(null);
|
||||
setPayments([]);
|
||||
setRebateAllocations([]);
|
||||
setDownlineShares(null);
|
||||
setLoadErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [billId, currencyCode]);
|
||||
}, [billId, currencyCode, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (loading || !bill) {
|
||||
if (loading && !bill) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
if (loadErr || !bill) {
|
||||
return (
|
||||
<div className="space-y-3 py-4">
|
||||
<p className="text-sm text-destructive">{loadErr ?? t("loadFailed", { ns: "common" })}</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void load()}>
|
||||
{t("retry", { ns: "common", defaultValue: "重试" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const direction = describeBillPaymentDirection(bill, t);
|
||||
const canOperateBill = canManage && settlementBillOperableByBoundAgent(bill, boundAgent);
|
||||
const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
|
||||
|
||||
@@ -59,6 +59,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
|
||||
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
||||
const [sitesReady, setSitesReady] = useState(() => boundAgent?.admin_site_id != null);
|
||||
const [sitesLoadErr, setSitesLoadErr] = useState<string | null>(null);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const [sitePickerOpen, setSitePickerOpen] = useState(false);
|
||||
const [siteKeyword, setSiteKeyword] = useState("");
|
||||
@@ -68,50 +69,74 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [periodLookupDone, setPeriodLookupDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadIntegrationSites = useCallback(async (): Promise<void> => {
|
||||
if (boundAgent?.admin_site_id) {
|
||||
const label = formatAdminSiteLabel(
|
||||
boundAgent.admin_site_name,
|
||||
boundAgent.site_code ?? boundAgent.code,
|
||||
);
|
||||
setSiteOptions([{
|
||||
id: boundAgent.admin_site_id,
|
||||
label,
|
||||
code: boundAgent.site_code ?? boundAgent.code ?? "",
|
||||
currency_code: "NPR",
|
||||
}]);
|
||||
setAdminSiteId(boundAgent.admin_site_id);
|
||||
setSitesReady(true);
|
||||
const siteCode = boundAgent.site_code ?? boundAgent.code ?? "";
|
||||
const siteId = boundAgent.admin_site_id;
|
||||
|
||||
setSitesReady(false);
|
||||
setSitesLoadErr(null);
|
||||
try {
|
||||
const sites = await getAdminIntegrationSites();
|
||||
const match = sites.items.find((site) => site.id === siteId);
|
||||
setSiteOptions([{
|
||||
id: siteId,
|
||||
label,
|
||||
code: siteCode,
|
||||
currency_code: match?.currency_code ?? "NPR",
|
||||
}]);
|
||||
setAdminSiteId(siteId);
|
||||
} catch {
|
||||
setSiteOptions([{
|
||||
id: siteId,
|
||||
label,
|
||||
code: siteCode,
|
||||
currency_code: "NPR",
|
||||
}]);
|
||||
setAdminSiteId(siteId);
|
||||
} finally {
|
||||
setSitesReady(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSitesReady(false);
|
||||
void getAdminIntegrationSites()
|
||||
.then((sites) => {
|
||||
const options = (sites.items ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: formatAdminSiteLabel(site.name, site.code),
|
||||
code: site.code,
|
||||
currency_code: site.currency_code ?? "NPR",
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
setAdminSiteId((current) => {
|
||||
if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) {
|
||||
return siteFromUrl;
|
||||
}
|
||||
if (current !== null && options.some((site) => site.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return options[0]?.id ?? null;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setSiteOptions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setSitesReady(true);
|
||||
setSitesLoadErr(null);
|
||||
try {
|
||||
const sites = await getAdminIntegrationSites();
|
||||
const options = (sites.items ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: formatAdminSiteLabel(site.name, site.code),
|
||||
code: site.code,
|
||||
currency_code: site.currency_code ?? "NPR",
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
setAdminSiteId((current) => {
|
||||
if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) {
|
||||
return siteFromUrl;
|
||||
}
|
||||
if (current !== null && options.some((site) => site.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return options[0]?.id ?? null;
|
||||
});
|
||||
}, [boundAgent, siteFromUrl]);
|
||||
} catch {
|
||||
setSiteOptions([]);
|
||||
setSitesLoadErr(
|
||||
t("sites.loadFailed", { defaultValue: "站点列表加载失败,请稍后重试。" }),
|
||||
);
|
||||
} finally {
|
||||
setSitesReady(true);
|
||||
}
|
||||
}, [boundAgent, siteFromUrl, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadIntegrationSites();
|
||||
}, [loadIntegrationSites]);
|
||||
|
||||
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||
const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null;
|
||||
@@ -300,6 +325,13 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
{shellBootstrapping ? (
|
||||
<AdminLoadingState />
|
||||
) : sitesLoadErr ? (
|
||||
<div className="flex flex-col items-start gap-3 rounded-xl border border-border/70 bg-card p-4">
|
||||
<p className="text-sm text-destructive">{sitesLoadErr}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void loadIntegrationSites()}>
|
||||
{t("common:actions.retry", { defaultValue: "重试" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : siteId === null && siteOptions.length === 0 && boundAgent === null ? (
|
||||
<AdminNoIntegrationSiteState canCreate={profile?.is_super_admin === true} />
|
||||
) : siteId === null ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { resolveCreditLedgerRowStatusTone } from "@/lib/admin-status-tone";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { creditLedgerReasonLabel, settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
@@ -367,8 +369,13 @@ export function SettlementCreditLedgerPanel({
|
||||
<TableCell className="tabular-nums text-xs">
|
||||
{row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{statusLabel(row)}
|
||||
<TableCell>
|
||||
<AdminStatusBadge
|
||||
status={row.bill_status ?? row.status ?? "posted"}
|
||||
tone={resolveCreditLedgerRowStatusTone(row)}
|
||||
>
|
||||
{statusLabel(row)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-right text-xs">
|
||||
<span className={cn(signedLedgerAmountClass(signed))}>
|
||||
|
||||
@@ -542,7 +542,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell overflow-x-auto rounded-md border">
|
||||
@@ -860,7 +860,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell overflow-x-auto rounded-md border">
|
||||
@@ -1012,7 +1012,7 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{result ? (
|
||||
<div className="space-y-3 rounded-lg border p-4 text-sm">
|
||||
<p>
|
||||
|
||||
@@ -16,18 +16,6 @@ import { readProfile, writeProfile } from "@/stores/admin-profile";
|
||||
import { readToken, writeToken } from "@/stores/admin-token";
|
||||
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||
|
||||
/** 避免 rehydrate 先用 localStorage 里的旧 navigation 渲染侧栏 */
|
||||
function profileForRehydrate(profile: AdminProfile | null): AdminProfile | null {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
navigation: [],
|
||||
};
|
||||
}
|
||||
|
||||
export type AdminSessionState = {
|
||||
bearerToken: string | null;
|
||||
adminProfile: AdminProfile | null;
|
||||
@@ -86,7 +74,7 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
||||
const profile = readProfile();
|
||||
if (token) {
|
||||
setAdminBearerToken(token);
|
||||
set({ bearerToken: token, adminProfile: profileForRehydrate(profile) });
|
||||
set({ bearerToken: token, adminProfile: profile });
|
||||
void get().refreshAdminProfile();
|
||||
} else {
|
||||
setAdminBearerToken(null);
|
||||
@@ -112,9 +100,7 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
||||
|
||||
const cached = get().adminProfile ?? readProfile();
|
||||
if (cached) {
|
||||
const withoutNav = profileForRehydrate(cached);
|
||||
writeProfile(withoutNav);
|
||||
set({ adminProfile: withoutNav });
|
||||
set({ adminProfile: cached });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user