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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user