feat(admin, i18n): enhance reports, draws, config, and player workflows
This commit is contained in:
@@ -59,6 +59,7 @@ export type AgentLineDetailPanelProps = {
|
||||
canViewPlayersTab: boolean;
|
||||
canManageNode: boolean;
|
||||
canCreateChild: boolean;
|
||||
canCreateChildAgent: boolean;
|
||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||
onEditChild: (node: AgentNodeRow) => void;
|
||||
onDeleteChild: (node: AgentNodeRow) => void;
|
||||
@@ -88,6 +89,7 @@ export function AgentLineDetailPanel({
|
||||
canViewPlayersTab,
|
||||
canManageNode,
|
||||
canCreateChild,
|
||||
canCreateChildAgent,
|
||||
canDeleteChild,
|
||||
onEditChild,
|
||||
onDeleteChild,
|
||||
@@ -155,6 +157,17 @@ export function AgentLineDetailPanel({
|
||||
siteLabel && siteCode.trim() !== ""
|
||||
? `${siteLabel} (${siteCode})`
|
||||
: siteLabel ?? siteCode;
|
||||
const codeText = typeof node.code === "string" ? node.code.trim() : "";
|
||||
const usernameText = typeof node.username === "string" ? node.username.trim() : "";
|
||||
const childActionHint = canCreateChild
|
||||
? null
|
||||
: canCreateChildAgent
|
||||
? t("lineUi.addChildUnavailableHint", {
|
||||
defaultValue: "当前代理未开启“允许创建下级代理”,如需新增请先调整该代理配置。",
|
||||
})
|
||||
: t("lineUi.addChildNoPermissionHint", {
|
||||
defaultValue: "当前账号没有为该节点创建下级代理的权限。",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background">
|
||||
@@ -171,21 +184,25 @@ export function AgentLineDetailPanel({
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
<span className="font-mono text-xs text-foreground/80">{node.code}</span>
|
||||
{node.username ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-border">·</span>
|
||||
{node.username}
|
||||
</>
|
||||
) : null}
|
||||
{parentName ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-border">·</span>
|
||||
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
{(codeText !== "" || usernameText !== "" || parentName) ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{codeText !== "" ? (
|
||||
<span className="rounded-md bg-muted/50 px-2 py-1 font-mono text-xs text-foreground/80">
|
||||
{t("lineUi.agentCode", { defaultValue: "编码" })} {codeText}
|
||||
</span>
|
||||
) : null}
|
||||
{usernameText !== "" ? (
|
||||
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
|
||||
{t("lineUi.agentUsername", { defaultValue: "账号" })} {usernameText}
|
||||
</span>
|
||||
) : null}
|
||||
{parentName ? (
|
||||
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
|
||||
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||
@@ -202,16 +219,23 @@ export function AgentLineDetailPanel({
|
||||
</div>
|
||||
) : null}
|
||||
{canManageNode ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
{t("lineUi.editAccount", { defaultValue: "账号与状态" })}
|
||||
</Button>
|
||||
{canCreateChild ? (
|
||||
<Button type="button" size="sm" onClick={onAddChild}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
||||
<div className="flex max-w-[28rem] flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
{t("lineUi.editAgent", { defaultValue: "编辑代理" })}
|
||||
</Button>
|
||||
{canCreateChild ? (
|
||||
<Button type="button" size="sm" onClick={onAddChild}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{childActionHint ? (
|
||||
<p className="text-right text-xs leading-5 text-muted-foreground">
|
||||
{childActionHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -267,7 +291,7 @@ export function AgentLineDetailPanel({
|
||||
})
|
||||
: t("lineUi.profileTabHint", {
|
||||
defaultValue:
|
||||
"占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
|
||||
"占成、授信、回水与风控标签在此维护;登录名、密码与启停状态请用「编辑代理」。",
|
||||
})}
|
||||
</p>
|
||||
</CardHeader>
|
||||
@@ -426,7 +450,7 @@ function OverviewTab({
|
||||
defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。",
|
||||
count: childCount,
|
||||
})}
|
||||
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
|
||||
actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
|
||||
onAction={onGoToDownline}
|
||||
/>
|
||||
) : null}
|
||||
@@ -437,7 +461,7 @@ function OverviewTab({
|
||||
description={t("lineUi.overviewPlayersHint", {
|
||||
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
|
||||
})}
|
||||
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
|
||||
actionLabel={t("lineUi.viewPlayers", { defaultValue: "查看直属玩家" })}
|
||||
onAction={onGoToPlayers}
|
||||
/>
|
||||
) : null}
|
||||
@@ -509,6 +533,9 @@ function DownlineTable({
|
||||
onAddChild: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const createChildLabel = t("lineUi.createDownline", { defaultValue: "创建下级代理" });
|
||||
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" });
|
||||
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" });
|
||||
|
||||
if (childAgents.length === 0) {
|
||||
return (
|
||||
@@ -517,7 +544,7 @@ function DownlineTable({
|
||||
{canManageNode && canCreateChild ? (
|
||||
<Button type="button" className="mt-2" onClick={onAddChild}>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
||||
{createChildLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</AdminNoResourceState>
|
||||
@@ -604,13 +631,13 @@ function DownlineTable({
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: t("editNode", { defaultValue: "编辑代理" }),
|
||||
label: editChildLabel,
|
||||
icon: Pencil,
|
||||
onClick: () => onEditChild(child),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("deleteNode", { defaultValue: "删除代理" }),
|
||||
label: deleteChildLabel,
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: !canDeleteChild(child),
|
||||
|
||||
@@ -295,24 +295,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
return counts;
|
||||
}, [flatNodes]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
return businessRows.filter((node) => {
|
||||
if (normalized === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentName =
|
||||
node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : "";
|
||||
|
||||
return [node.name, node.code, node.username ?? "", parentName]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalized);
|
||||
});
|
||||
}, [businessRows, keyword, parentNameMap]);
|
||||
|
||||
const loadTree = useCallback(async (siteId?: number | null) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
@@ -421,15 +403,15 @@ export function AgentsConsole(): React.ReactElement {
|
||||
]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (filteredRows.length === 0) {
|
||||
if (businessRows.length === 0) {
|
||||
setSelectedNodeId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeId === null || !filteredRows.some((row) => row.id === selectedNodeId)) {
|
||||
setSelectedNodeId(filteredRows[0]?.id ?? null);
|
||||
if (selectedNodeId === null || !businessRows.some((row) => row.id === selectedNodeId)) {
|
||||
setSelectedNodeId(businessRows[0]?.id ?? null);
|
||||
}
|
||||
}, [filteredRows, selectedNodeId]);
|
||||
}, [businessRows, selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailTab("overview");
|
||||
@@ -798,10 +780,10 @@ export function AgentsConsole(): React.ReactElement {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AgentLineDetailPanel
|
||||
node={selectedNode}
|
||||
profile={selectedProfile}
|
||||
profileLoading={selectedProfileLoading}
|
||||
<AgentLineDetailPanel
|
||||
node={selectedNode}
|
||||
profile={selectedProfile}
|
||||
profileLoading={selectedProfileLoading}
|
||||
childAgents={selectedChildAgents}
|
||||
childCountById={childCountById}
|
||||
siteCode={activeSiteCode}
|
||||
@@ -820,6 +802,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
canViewPlayersTab={canShowPlayersTab}
|
||||
canManageNode={canManageNode}
|
||||
canCreateChild={canCreateChildOnSelected}
|
||||
canCreateChildAgent={canCreateChildAgent}
|
||||
canDeleteChild={canDeleteNode}
|
||||
onEditChild={(node) => openEditForNode(node)}
|
||||
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
||||
@@ -843,7 +826,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
<DialogTitle>
|
||||
{nodeDialogMode === "create"
|
||||
? t("createChild", { defaultValue: "添加下级代理" })
|
||||
: t("editNode", { defaultValue: "编辑代理" })}
|
||||
: t("editNode", { defaultValue: "编辑代理账号与配置" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAgentNodeProfile } from "@/api/admin-agents";
|
||||
import {
|
||||
getSettlementBills,
|
||||
postSettlementBillBadDebtWriteOff,
|
||||
postSettlementBillConfirm,
|
||||
postSettlementBillPayment,
|
||||
type SettlementBillRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import {
|
||||
deleteAdminPlayer,
|
||||
getAdminPlayer,
|
||||
@@ -15,7 +22,7 @@ import {
|
||||
} from "@/api/admin-player";
|
||||
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
@@ -48,7 +55,7 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
||||
import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
@@ -79,6 +86,15 @@ function playerStatusLabel(
|
||||
return String(status);
|
||||
}
|
||||
|
||||
function creditAdjustModeLabel(
|
||||
mode: "increase" | "decrease",
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
return mode === "increase"
|
||||
? t("playersPanel.creditIncrease", { defaultValue: "增加授信" })
|
||||
: t("playersPanel.creditDecrease", { defaultValue: "减少授信" });
|
||||
}
|
||||
|
||||
function resolvePlayerRebateRate(row: AdminPlayerRow): number | null {
|
||||
if (row.rebate_rate != null) {
|
||||
return row.rebate_rate;
|
||||
@@ -108,7 +124,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
|
||||
nickname: string;
|
||||
currency: string;
|
||||
status: number;
|
||||
creditLimit: string;
|
||||
creditLimit: number;
|
||||
rebateRate: string;
|
||||
riskTags: string;
|
||||
} {
|
||||
@@ -119,7 +135,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
|
||||
nickname: row.nickname ?? "",
|
||||
currency: row.default_currency ?? "",
|
||||
status: row.status,
|
||||
creditLimit: row.credit_limit != null ? String(row.credit_limit) : "",
|
||||
creditLimit: row.credit_limit ?? 0,
|
||||
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
|
||||
riskTags: (row.risk_tags ?? []).join(", "),
|
||||
};
|
||||
@@ -143,6 +159,15 @@ export function AgentsPlayersPanel({
|
||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "players", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const createPlayerLabel = embedded
|
||||
? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" })
|
||||
: t("playersPanel.create", { defaultValue: "创建玩家" });
|
||||
const viewPlayerLabel = t("players:viewDetail", { defaultValue: "查看玩家详情" });
|
||||
const editPlayerLabel = t("players:editPlayer", { defaultValue: "编辑玩家" });
|
||||
const deletePlayerLabel = t("players:deletePlayer", { defaultValue: "删除玩家" });
|
||||
const settlementCenterLabel = t("playersPanel.gotoSettlementCenter", {
|
||||
defaultValue: "去结算中心",
|
||||
});
|
||||
const profile = useAdminProfile();
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
@@ -175,7 +200,6 @@ export function AgentsPlayersPanel({
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sitePlayerId, setSitePlayerId] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
@@ -189,10 +213,22 @@ export function AgentsPlayersPanel({
|
||||
const [editNickname, setEditNickname] = useState("");
|
||||
const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
|
||||
const [editStatus, setEditStatus] = useState(0);
|
||||
const [editCreditLimit, setEditCreditLimit] = useState("");
|
||||
const [editCreditBase, setEditCreditBase] = useState(0);
|
||||
const [editCreditAdjustMode, setEditCreditAdjustMode] = useState<"increase" | "decrease">("increase");
|
||||
const [editCreditDelta, setEditCreditDelta] = useState("");
|
||||
const [editRebateRate, setEditRebateRate] = useState("");
|
||||
const [editRiskTags, setEditRiskTags] = useState("");
|
||||
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
||||
const [billingDialogOpen, setBillingDialogOpen] = useState(false);
|
||||
const [billingPlayer, setBillingPlayer] = useState<AdminPlayerRow | null>(null);
|
||||
const [billingBills, setBillingBills] = useState<SettlementBillRow[]>([]);
|
||||
const [billingLoading, setBillingLoading] = useState(false);
|
||||
const [billingBusy, setBillingBusy] = useState(false);
|
||||
const [selectedBillId, setSelectedBillId] = useState<number | null>(null);
|
||||
const [payAmount, setPayAmount] = useState("");
|
||||
const [payMethod, setPayMethod] = useState("");
|
||||
const [payProof, setPayProof] = useState("");
|
||||
const [badDebtReason, setBadDebtReason] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteCode.trim() === "") {
|
||||
@@ -241,7 +277,6 @@ export function AgentsPlayersPanel({
|
||||
try {
|
||||
await postAdminPlayer({
|
||||
site_code: siteCode.trim(),
|
||||
...(sitePlayerId.trim() !== "" ? { site_player_id: sitePlayerId.trim() } : {}),
|
||||
username: username.trim(),
|
||||
password: password,
|
||||
nickname: nickname.trim() || null,
|
||||
@@ -259,7 +294,6 @@ export function AgentsPlayersPanel({
|
||||
}),
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setSitePlayerId("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setNickname("");
|
||||
@@ -290,7 +324,9 @@ export function AgentsPlayersPanel({
|
||||
setEditNickname(form.nickname);
|
||||
setEditDefaultCurrency(form.currency);
|
||||
setEditStatus(form.status);
|
||||
setEditCreditLimit(form.creditLimit);
|
||||
setEditCreditBase(form.creditLimit);
|
||||
setEditCreditAdjustMode("increase");
|
||||
setEditCreditDelta("");
|
||||
setEditRebateRate(form.rebateRate);
|
||||
setEditRiskTags(form.riskTags);
|
||||
};
|
||||
@@ -339,10 +375,13 @@ export function AgentsPlayersPanel({
|
||||
if (editStatus !== editingPlayer.status) {
|
||||
body.status = editStatus;
|
||||
}
|
||||
const nextCredit =
|
||||
editCreditLimit.trim() === "" ? 0 : Number.parseInt(editCreditLimit, 10);
|
||||
if (!Number.isNaN(nextCredit) && nextCredit !== (editingPlayer.credit_limit ?? 0)) {
|
||||
body.credit_limit = Math.max(0, nextCredit);
|
||||
const creditDelta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
|
||||
if (!Number.isNaN(creditDelta) && creditDelta > 0) {
|
||||
const signedDelta = editCreditAdjustMode === "increase" ? creditDelta : -creditDelta;
|
||||
const nextCredit = Math.max(0, (editingPlayer.credit_limit ?? 0) + signedDelta);
|
||||
if (nextCredit !== (editingPlayer.credit_limit ?? 0)) {
|
||||
body.credit_limit = nextCredit;
|
||||
}
|
||||
}
|
||||
const prevRebate = resolvePlayerRebateRate(editingPlayer);
|
||||
const nextPercent = parsePercentUi(editRebateRate);
|
||||
@@ -390,7 +429,141 @@ export function AgentsPlayersPanel({
|
||||
setTotal((current) => Math.max(0, current - 1));
|
||||
toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id }));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("deleteFailed"));
|
||||
if (e instanceof LotteryApiBizError) {
|
||||
const needsSettlement = e.message.includes("已占用信用额度");
|
||||
toast.error(
|
||||
needsSettlement
|
||||
? t("playersPanel.deleteBlockedByCreditHint", {
|
||||
defaultValue: "该玩家仍有已占用信用额度,请先到结算中心结清或核销相关账单后再删除。",
|
||||
})
|
||||
: e.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.error(t("deleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const selectedBill = useMemo(
|
||||
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null,
|
||||
[billingBills, selectedBillId],
|
||||
);
|
||||
const projectedCreditLimit = useMemo(() => {
|
||||
const delta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
|
||||
if (Number.isNaN(delta) || delta <= 0) {
|
||||
return editCreditBase;
|
||||
}
|
||||
return Math.max(0, editCreditBase + (editCreditAdjustMode === "increase" ? delta : -delta));
|
||||
}, [editCreditAdjustMode, editCreditBase, editCreditDelta]);
|
||||
|
||||
function resetBillingForm(): void {
|
||||
setPayAmount("");
|
||||
setPayMethod("");
|
||||
setPayProof("");
|
||||
setBadDebtReason("");
|
||||
}
|
||||
|
||||
async function openBillingDialog(row: AdminPlayerRow): Promise<void> {
|
||||
setBillingDialogOpen(true);
|
||||
setBillingPlayer(row);
|
||||
setBillingBills([]);
|
||||
setSelectedBillId(null);
|
||||
resetBillingForm();
|
||||
setBillingLoading(true);
|
||||
try {
|
||||
const data = await getSettlementBills({
|
||||
bill_type: "player",
|
||||
keyword: row.site_player_id,
|
||||
per_page: 20,
|
||||
});
|
||||
const items = (data.items ?? []).filter(
|
||||
(bill) =>
|
||||
bill.bill_type === "player" &&
|
||||
bill.owner_id === row.id &&
|
||||
(bill.status === "pending_confirm" || Number(bill.unpaid_amount ?? 0) > 0),
|
||||
);
|
||||
setBillingBills(items);
|
||||
const first = items[0] ?? null;
|
||||
setSelectedBillId(first?.id ?? null);
|
||||
setPayAmount(first ? String(first.unpaid_amount ?? 0) : "");
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError
|
||||
? e.message
|
||||
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmBill(): Promise<void> {
|
||||
if (selectedBill === null) return;
|
||||
setBillingBusy(true);
|
||||
try {
|
||||
await postSettlementBillConfirm(selectedBill.id);
|
||||
toast.success(
|
||||
t("playersPanel.billConfirmed", { defaultValue: "账单已确认,请继续登记收付或核销" }),
|
||||
);
|
||||
if (billingPlayer) {
|
||||
await openBillingDialog(billingPlayer);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError
|
||||
? e.message
|
||||
: t("playersPanel.billConfirmFailed", { defaultValue: "确认账单失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePayBill(): Promise<void> {
|
||||
if (selectedBill === null) return;
|
||||
setBillingBusy(true);
|
||||
try {
|
||||
await postSettlementBillPayment(selectedBill.id, {
|
||||
amount: Number(payAmount || selectedBill.unpaid_amount || 0),
|
||||
method: payMethod.trim() || undefined,
|
||||
proof: payProof.trim() || undefined,
|
||||
});
|
||||
toast.success(t("playersPanel.billPaid", { defaultValue: "已登记收付" }));
|
||||
await load();
|
||||
if (billingPlayer) {
|
||||
await openBillingDialog(billingPlayer);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError
|
||||
? e.message
|
||||
: t("playersPanel.billPayFailed", { defaultValue: "登记收付失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWriteOffBill(): Promise<void> {
|
||||
if (selectedBill === null) return;
|
||||
setBillingBusy(true);
|
||||
try {
|
||||
await postSettlementBillBadDebtWriteOff(selectedBill.id, {
|
||||
reason: badDebtReason.trim() || undefined,
|
||||
});
|
||||
toast.success(t("playersPanel.billWrittenOff", { defaultValue: "已核销坏账" }));
|
||||
await load();
|
||||
if (billingPlayer) {
|
||||
await openBillingDialog(billingPlayer);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError
|
||||
? e.message
|
||||
: t("playersPanel.billWriteOffFailed", { defaultValue: "核销坏账失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +588,7 @@ export function AgentsPlayersPanel({
|
||||
{canCreatePlayer ? (
|
||||
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("playersPanel.create", { defaultValue: "创建玩家" })}
|
||||
{createPlayerLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -434,6 +607,9 @@ export function AgentsPlayersPanel({
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("players:riskTags", { defaultValue: "风控标签" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("players:fundingMode", { defaultValue: "资金模式" })}
|
||||
</TableHead>
|
||||
@@ -455,11 +631,12 @@ export function AgentsPlayersPanel({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={embedded ? 8 : 9} cellClassName="py-12 text-center" />
|
||||
<AdminTableNoResourceRow colSpan={embedded ? 9 : 10} cellClassName="py-12 text-center" />
|
||||
) : (
|
||||
items.map((row) => {
|
||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||
const rebate = resolvePlayerRebateRate(row);
|
||||
const riskTags = row.risk_tags ?? [];
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="tabular-nums text-xs font-medium">#{row.id}</TableCell>
|
||||
@@ -471,6 +648,22 @@ export function AgentsPlayersPanel({
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[14rem]">
|
||||
{riskTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
|
||||
{riskTags.map((tag) => (
|
||||
<span
|
||||
key={`${row.id}-${tag}`}
|
||||
className="inline-flex 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>
|
||||
<PlayerFundingModeBadge row={row} />
|
||||
</TableCell>
|
||||
@@ -509,21 +702,33 @@ export function AgentsPlayersPanel({
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("players:viewDetail", { defaultValue: "查看详情" }),
|
||||
label: viewPlayerLabel,
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.id),
|
||||
},
|
||||
...(row.funding_mode === "credit" || row.uses_credit === true
|
||||
? [
|
||||
{
|
||||
key: "settlement",
|
||||
label: t("playersPanel.manageSettlement", {
|
||||
defaultValue: "处理账单",
|
||||
}),
|
||||
icon: ReceiptText,
|
||||
onClick: () => void openBillingDialog(row),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canManagePlayerRows
|
||||
? [
|
||||
{
|
||||
key: "edit",
|
||||
label: t("players:edit", { defaultValue: "编辑" }),
|
||||
label: editPlayerLabel,
|
||||
icon: Pencil,
|
||||
onClick: () => openEditPlayer(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("players:delete", { defaultValue: "删除" }),
|
||||
label: deletePlayerLabel,
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () =>
|
||||
@@ -572,24 +777,12 @@ export function AgentsPlayersPanel({
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle>
|
||||
<DialogTitle>{createPlayerLabel}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
|
||||
<Input value={siteCode} readOnly disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-site-id">
|
||||
{t("playersPanel.externalIdOptional", { defaultValue: "外部 ID(可选)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-site-id"
|
||||
value={sitePlayerId}
|
||||
onChange={(e) => setSitePlayerId(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("playersPanel.externalIdHint", { defaultValue: "留空则系统自动生成" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-username">
|
||||
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
|
||||
@@ -669,6 +862,138 @@ export function AgentsPlayersPanel({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={billingDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBillingDialogOpen(open);
|
||||
if (!open) {
|
||||
setBillingPlayer(null);
|
||||
setBillingBills([]);
|
||||
setSelectedBillId(null);
|
||||
resetBillingForm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("playersPanel.manageSettlement", { defaultValue: "处理账单" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{billingLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("playersPanel.billingLoading", { defaultValue: "正在加载账单…" })}
|
||||
</p>
|
||||
) : billingBills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("playersPanel.noPendingBills", { defaultValue: "当前没有可处理的未结账单。" })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("playersPanel.selectBill", { defaultValue: "选择账单" })}</Label>
|
||||
<Select
|
||||
value={selectedBillId ? String(selectedBillId) : ""}
|
||||
onValueChange={(value) => {
|
||||
const next = billingBills.find((bill) => bill.id === Number(value)) ?? null;
|
||||
setSelectedBillId(next?.id ?? null);
|
||||
setPayAmount(next ? String(next.unpaid_amount ?? 0) : "");
|
||||
setPayMethod("");
|
||||
setPayProof("");
|
||||
setBadDebtReason("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("playersPanel.selectBill", { defaultValue: "选择账单" })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{billingBills.map((bill) => (
|
||||
<SelectItem key={bill.id} value={String(bill.id)}>
|
||||
{`#${bill.id} · ${bill.status} · ${bill.player_site_player_id ?? bill.owner_id} · ${bill.unpaid_amount ?? 0}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedBill ? (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 p-4">
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("playersPanel.billStatus", { defaultValue: "状态" })}:
|
||||
</span>{" "}
|
||||
{selectedBill.status}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("playersPanel.billUnpaid", { defaultValue: "未结" })}:
|
||||
</span>{" "}
|
||||
{selectedBill.unpaid_amount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBill.status === "pending_confirm" ? (
|
||||
<Button type="button" className="w-full" disabled={billingBusy} onClick={() => void handleConfirmBill()}>
|
||||
{t("agents:settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{selectedBill.status !== "pending_confirm" && Number(selectedBill.unpaid_amount ?? 0) > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
||||
<Input
|
||||
value={payMethod}
|
||||
onChange={(e) => setPayMethod(e.target.value)}
|
||||
placeholder={t("agents:settlementBills.paymentMethodPlaceholder", {
|
||||
defaultValue: "例如:现金 / 银行转账",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("agents:settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||
<Input
|
||||
value={payProof}
|
||||
onChange={(e) => setPayProof(e.target.value)}
|
||||
placeholder={t("agents:settlementBills.paymentProofPlaceholder", {
|
||||
defaultValue: "可填写流水号、截图说明或备注",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" className="w-full" disabled={billingBusy} onClick={() => void handlePayBill()}>
|
||||
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
|
||||
</Button>
|
||||
|
||||
<div className="space-y-1 pt-2">
|
||||
<Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||
<Input
|
||||
value={badDebtReason}
|
||||
onChange={(e) => setBadDebtReason(e.target.value)}
|
||||
placeholder={t("agents:settlementBills.badDebtReasonPlaceholder", {
|
||||
defaultValue: "例如:客户失联、确认坏账",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy} onClick={() => void handleWriteOffBill()}>
|
||||
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -713,13 +1038,60 @@ export function AgentsPlayersPanel({
|
||||
<Label htmlFor="agent-player-edit-credit">
|
||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-edit-credit"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editCreditLimit}
|
||||
onChange={(e) => setEditCreditLimit(e.target.value)}
|
||||
/>
|
||||
<div className="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("playersPanel.currentCredit", { defaultValue: "当前授信" })}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{formatPlayerCreditAmount(editCreditBase, editDefaultCurrency || "NPR")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="agent-player-edit-credit-mode">
|
||||
{t("playersPanel.creditAdjustType", { defaultValue: "调整方式" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={editCreditAdjustMode}
|
||||
onValueChange={(value) => setEditCreditAdjustMode(value as "increase" | "decrease")}
|
||||
>
|
||||
<SelectTrigger id="agent-player-edit-credit-mode">
|
||||
<SelectValue>
|
||||
{creditAdjustModeLabel(editCreditAdjustMode, t)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="increase">
|
||||
{creditAdjustModeLabel("increase", t)}
|
||||
</SelectItem>
|
||||
<SelectItem value="decrease">
|
||||
{creditAdjustModeLabel("decrease", t)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="agent-player-edit-credit-delta">
|
||||
{t("playersPanel.creditAdjustAmount", { defaultValue: "调整额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-edit-credit-delta"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editCreditDelta}
|
||||
onChange={(e) => setEditCreditDelta(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{t("playersPanel.creditProjected", {
|
||||
defaultValue: "调整后授信:{{amount}}",
|
||||
amount: formatPlayerCreditAmount(projectedCreditLimit, editDefaultCurrency || "NPR"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-edit-rebate">
|
||||
|
||||
@@ -227,11 +227,10 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.id}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{t(`operatorTypes.${row.operator_type}`, {
|
||||
ns: "audit",
|
||||
defaultValue: row.operator_type,
|
||||
})}
|
||||
:{row.operator_id}
|
||||
<div className="font-medium text-foreground">{row.operator_label}</div>
|
||||
{row.operator_subtitle ? (
|
||||
<div className="text-muted-foreground">{row.operator_subtitle}</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{row.module_label}</TableCell>
|
||||
<TableCell className="text-sm">{row.action_label}</TableCell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -32,6 +32,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -157,6 +164,9 @@ export function PlayConfigDocScreen() {
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const detailRequestSeq = useRef(0);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
@@ -268,6 +278,61 @@ export function PlayConfigDocScreen() {
|
||||
[draftRows],
|
||||
);
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return orderedRows
|
||||
.map((row) => row.category?.trim() || "")
|
||||
.filter((value) => {
|
||||
if (!value || seen.has(value)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(value);
|
||||
return true;
|
||||
});
|
||||
}, [orderedRows]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
|
||||
return orderedRows.filter((row) => {
|
||||
const normalizedCategory = row.category?.trim() || "uncategorized";
|
||||
const matchesKeyword =
|
||||
normalizedKeyword === "" ||
|
||||
row.play_code.toLowerCase().includes(normalizedKeyword) ||
|
||||
(row.display_name ?? "").toLowerCase().includes(normalizedKeyword) ||
|
||||
(row.category ?? "").toLowerCase().includes(normalizedKeyword);
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "enabled" && row.is_enabled) ||
|
||||
(statusFilter === "disabled" && !row.is_enabled);
|
||||
const matchesCategory = categoryFilter === "all" || normalizedCategory === categoryFilter;
|
||||
|
||||
return matchesKeyword && matchesStatus && matchesCategory;
|
||||
});
|
||||
}, [categoryFilter, keyword, orderedRows, statusFilter]);
|
||||
|
||||
const groupedRows = useMemo(() => {
|
||||
const groups = new Map<string, PlayConfigItemRow[]>();
|
||||
for (const row of filteredRows) {
|
||||
const groupKey = row.category?.trim() || "uncategorized";
|
||||
const current = groups.get(groupKey);
|
||||
if (current) {
|
||||
current.push(row);
|
||||
} else {
|
||||
groups.set(groupKey, [row]);
|
||||
}
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
}, [filteredRows]);
|
||||
|
||||
function categoryLabel(categoryKey: string): string {
|
||||
if (categoryKey === "uncategorized") {
|
||||
return t("play.filters.uncategorized", { ns: "config" });
|
||||
}
|
||||
const mapped = t(`play.categories.${categoryKey}`, { ns: "config" });
|
||||
return mapped === `play.categories.${categoryKey}` ? categoryKey : mapped;
|
||||
}
|
||||
|
||||
function updateConfigRow(playCode: string, patch: Partial<PlayConfigItemRow>) {
|
||||
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
||||
}
|
||||
@@ -474,69 +539,153 @@ export function PlayConfigDocScreen() {
|
||||
>
|
||||
{detail ? (
|
||||
<ConfigSection
|
||||
title={t("play.batchSwitchesTitle", { ns: "config" })}
|
||||
description={!isDraft ? t("play.readOnlyDraftHint", { ns: "config" }) : undefined}
|
||||
title={t("play.filters.sectionTitle", { ns: "config" })}
|
||||
description={isDraft ? t("play.filters.sectionDescription", { ns: "config" }) : undefined}
|
||||
>
|
||||
<ConfigChipGroup>
|
||||
{batchSwitchStates.map((group) => {
|
||||
const groupOn = group.allEnabled;
|
||||
const isPartial =
|
||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||
return (
|
||||
<div
|
||||
key={group.key}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-border/60 bg-card px-4 py-3"
|
||||
{!isDraft ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
|
||||
{t("play.readOnlyDraftHint", { ns: "config" })}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="flex flex-1 flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
|
||||
<div className="flex min-w-0 flex-col gap-1.5 md:w-[320px]">
|
||||
<span className="text-sm font-medium">{t("play.filters.keyword", { ns: "config" })}</span>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 md:w-[140px]">
|
||||
<span className="text-sm font-medium">{t("play.filters.category", { ns: "config" })}</span>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue>
|
||||
{categoryFilter === "all"
|
||||
? t("play.filters.allCategories", { ns: "config" })
|
||||
: categoryLabel(categoryFilter)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
|
||||
{categoryOptions.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{categoryLabel(category)}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="uncategorized">
|
||||
{t("play.filters.uncategorized", { ns: "config" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 md:w-[140px]">
|
||||
<span className="text-sm font-medium">{t("play.filters.status", { ns: "config" })}</span>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{group.total > 0
|
||||
? isPartial
|
||||
? t("play.batchPartialEnabled", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.noPlayTypes", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={groupOn}
|
||||
indeterminate={isPartial}
|
||||
disabled={!isDraft || saving || group.total === 0 || confirmBusy}
|
||||
aria-label={t("play.aria.batchGroupSwitch", {
|
||||
ns: "config",
|
||||
group: group.label,
|
||||
})}
|
||||
onCheckedChange={(checked) => {
|
||||
const enable = checked === true;
|
||||
const action = enable
|
||||
? t("play.batchSwitchEnable", { ns: "config" })
|
||||
: t("play.batchSwitchDisable", { ns: "config" });
|
||||
requestConfirm({
|
||||
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||
description: t("play.batchSwitchConfirmDescription", {
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue>
|
||||
{statusFilter === "all"
|
||||
? t("play.filters.allStatuses", { ns: "config" })
|
||||
: statusFilter === "enabled"
|
||||
? t("play.states.enabled", { ns: "config" })
|
||||
: t("play.states.disabled", { ns: "config" })}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("play.filters.allStatuses", { ns: "config" })}</SelectItem>
|
||||
<SelectItem value="enabled">{t("play.states.enabled", { ns: "config" })}</SelectItem>
|
||||
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-start lg:flex-none">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setKeyword("");
|
||||
setCategoryFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("play.filters.reset", { ns: "config" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isDraft ? (
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t("play.batchSwitchesTitle", { ns: "config" })}
|
||||
</div>
|
||||
<ConfigChipGroup>
|
||||
{batchSwitchStates.map((group) => {
|
||||
const groupOn = group.allEnabled;
|
||||
const isPartial =
|
||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||
return (
|
||||
<div
|
||||
key={group.key}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-card px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{group.total > 0
|
||||
? isPartial
|
||||
? t("play.batchPartialEnabled", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.noPlayTypes", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={groupOn}
|
||||
indeterminate={isPartial}
|
||||
disabled={saving || group.total === 0 || confirmBusy}
|
||||
aria-label={t("play.aria.batchGroupSwitch", {
|
||||
ns: "config",
|
||||
action,
|
||||
group: group.label,
|
||||
count: group.total,
|
||||
}),
|
||||
confirmVariant: enable ? "default" : "destructive",
|
||||
onConfirm: () => applyBatchSwitch(group, enable),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ConfigChipGroup>
|
||||
})}
|
||||
onCheckedChange={(checked) => {
|
||||
const enable = checked === true;
|
||||
const action = enable
|
||||
? t("play.batchSwitchEnable", { ns: "config" })
|
||||
: t("play.batchSwitchDisable", { ns: "config" });
|
||||
requestConfirm({
|
||||
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||
description: t("play.batchSwitchConfirmDescription", {
|
||||
ns: "config",
|
||||
action,
|
||||
group: group.label,
|
||||
count: group.total,
|
||||
}),
|
||||
confirmVariant: enable ? "default" : "destructive",
|
||||
onConfirm: () => applyBatchSwitch(group, enable),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ConfigChipGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</ConfigSection>
|
||||
) : null}
|
||||
|
||||
@@ -546,22 +695,41 @@ export function PlayConfigDocScreen() {
|
||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("play.filters.empty", { ns: "config" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{groupedRows.map(([groupKey, rows]) => (
|
||||
<Fragment key={groupKey}>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableCell colSpan={7} className="py-2 text-sm font-medium text-foreground">
|
||||
{categoryLabel(groupKey)}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{t("play.filters.groupCount", { ns: "config", count: rows.length })}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orderedRows.map((row) => (
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.play_code}>
|
||||
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground text-sm">
|
||||
{row.category ? categoryLabel(row.category) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isDraft ? (
|
||||
<div className="flex justify-center">
|
||||
@@ -691,7 +859,9 @@ export function PlayConfigDocScreen() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
publishRiskCapVersion,
|
||||
putRiskCapItems,
|
||||
} from "@/api/admin-config";
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
||||
import {
|
||||
@@ -43,6 +44,13 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
@@ -51,10 +59,13 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
|
||||
import { PRD_RISK_CAP_MANAGE } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
||||
import type {
|
||||
AdminDrawListItem,
|
||||
} from "@/types/api/admin-draws";
|
||||
import type {
|
||||
ConfigVersionSummary,
|
||||
RiskCapItemRow,
|
||||
@@ -102,6 +113,7 @@ export function RiskCapDocScreen() {
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawOptions, setDrawOptions] = useState<AdminDrawListItem[]>([]);
|
||||
|
||||
const [defaultCapStr, setDefaultCapStr] = useState("");
|
||||
const [syncOpen, setSyncOpen] = useState(false);
|
||||
@@ -124,10 +136,23 @@ export function RiskCapDocScreen() {
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, []);
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void refreshList();
|
||||
}, [tRef]);
|
||||
|
||||
const loadDrawOptions = useCallback(async () => {
|
||||
try {
|
||||
const data = await getAdminDraws({ page: 1, per_page: 100 });
|
||||
setDrawOptions(data.items);
|
||||
} catch {
|
||||
setDrawOptions([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void loadDrawOptions();
|
||||
}, []);
|
||||
|
||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
||||
@@ -232,12 +257,20 @@ export function RiskCapDocScreen() {
|
||||
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
if (r.draw_id !== null) {
|
||||
toast.error(t("riskCap.validation.defaultCannotBindDraw", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
|
||||
toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number }));
|
||||
return;
|
||||
}
|
||||
if (r.cap_amount <= 0) {
|
||||
toast.error(t("riskCap.validation.specialGreaterThanZero", { ns: "config", number: r.normalized_number }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -340,6 +373,15 @@ export function RiskCapDocScreen() {
|
||||
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
|
||||
[draftRows],
|
||||
);
|
||||
const globalRows = useMemo(
|
||||
() => specialRows.filter(({ row }) => row.draw_id == null),
|
||||
[specialRows],
|
||||
);
|
||||
const drawRows = useMemo(
|
||||
() => specialRows.filter(({ row }) => row.draw_id != null),
|
||||
[specialRows],
|
||||
);
|
||||
const defaultCapDisplay = defaultCapStr || formatAdminMinorDecimal(0, amountCurrencyCode);
|
||||
|
||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||
try {
|
||||
@@ -459,6 +501,35 @@ export function RiskCapDocScreen() {
|
||||
>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
key: "default",
|
||||
label: t("riskCap.summary.defaultCap", { ns: "config" }),
|
||||
value: defaultCapDisplay,
|
||||
hint: t("riskCap.summary.defaultHint", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
key: "global",
|
||||
label: t("riskCap.summary.globalCaps", { ns: "config" }),
|
||||
value: t("riskCap.groups.count", { ns: "config", count: globalRows.length }),
|
||||
hint: t("riskCap.summary.globalHint", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
key: "draw",
|
||||
label: t("riskCap.summary.drawCaps", { ns: "config" }),
|
||||
value: t("riskCap.groups.count", { ns: "config", count: drawRows.length }),
|
||||
hint: t("riskCap.summary.drawHint", { ns: "config" }),
|
||||
},
|
||||
].map((card) => (
|
||||
<div key={card.key} className="rounded-xl border border-border/60 bg-background p-4 shadow-sm">
|
||||
<p className="text-xs text-muted-foreground">{card.label}</p>
|
||||
<p className="mt-1 font-mono text-lg font-semibold tabular-nums">{card.value}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{card.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConfigSection title={t("riskCap.defaultCap.title", { ns: "config" })}>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid gap-1">
|
||||
@@ -466,8 +537,8 @@ export function RiskCapDocScreen() {
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
id="default-cap"
|
||||
type="number"
|
||||
min={0}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="w-[220px] font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={defaultCapStr}
|
||||
@@ -490,6 +561,7 @@ export function RiskCapDocScreen() {
|
||||
|
||||
<ConfigSection
|
||||
title={t("riskCap.specialCaps.title", { ns: "config" })}
|
||||
description={t("riskCap.specialCaps.description", { ns: "config" })}
|
||||
actions={
|
||||
canEditDraft ? (
|
||||
<Button
|
||||
@@ -508,79 +580,150 @@ export function RiskCapDocScreen() {
|
||||
) : specialRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</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("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{specialRows.map(({ row: r, index: idx }) => (
|
||||
<TableRow key={r.clientKey}>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
className="h-8 font-mono tabular-nums"
|
||||
maxLength={4}
|
||||
disabled={saving}
|
||||
value={r.normalized_number}
|
||||
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
cap_amount:
|
||||
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue mono>
|
||||
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canEditDraft ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={saving}
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: t("actions.delete", { ns: "adminUsers" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => removeRow(idx),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
key: "global",
|
||||
title: t("riskCap.groups.globalTitle", { ns: "config" }),
|
||||
description: t("riskCap.groups.globalDescription", { ns: "config" }),
|
||||
rows: globalRows,
|
||||
emptyText: t("riskCap.groups.globalEmpty", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
key: "draw",
|
||||
title: t("riskCap.groups.drawTitle", { ns: "config" }),
|
||||
description: t("riskCap.groups.drawDescription", { ns: "config" }),
|
||||
rows: drawRows,
|
||||
emptyText: t("riskCap.groups.drawEmpty", { ns: "config" }),
|
||||
},
|
||||
].map((group) => (
|
||||
<div key={group.key} className="rounded-xl border border-border/60 bg-muted/10 p-3">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{group.description}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-background px-2 py-1 text-xs text-muted-foreground">
|
||||
{t("riskCap.groups.count", { ns: "config", count: group.rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{group.rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{group.emptyText}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">{t("riskCap.table.scope", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("riskCap.table.actions", { ns: "config" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.rows.map(({ row: r, index: idx }) => (
|
||||
<TableRow key={r.clientKey}>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Select
|
||||
value={r.draw_id == null ? "__global__" : String(r.draw_id)}
|
||||
onValueChange={(value) =>
|
||||
updateRow(idx, { draw_id: value === "__global__" ? null : Number(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__global__">
|
||||
{t("riskCap.scope.global", { ns: "config" })}
|
||||
</SelectItem>
|
||||
{drawOptions.map((draw) => (
|
||||
<SelectItem key={draw.id} value={String(draw.id)}>
|
||||
{draw.draw_no}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<ConfigReadonlyValue>
|
||||
{r.draw_id == null
|
||||
? t("riskCap.scope.global", { ns: "config" })
|
||||
: drawOptions.find((draw) => draw.id === r.draw_id)?.draw_no ??
|
||||
t("riskCap.scope.drawId", { ns: "config", id: r.draw_id })}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
className="h-8 font-mono tabular-nums"
|
||||
maxLength={4}
|
||||
disabled={saving}
|
||||
value={r.normalized_number}
|
||||
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
cap_amount:
|
||||
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue mono>
|
||||
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canEditDraft ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={saving}
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: t("actions.delete", { ns: "adminUsers" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => removeRow(idx),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("riskCap.readOnly", { ns: "config" })}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ConfigSection>
|
||||
|
||||
|
||||
@@ -421,6 +421,9 @@ export function DashboardAgentRankingCard({
|
||||
const v = metricValue(row);
|
||||
const pct = (Math.abs(v) / maxAbs) * 100;
|
||||
const color = barColor(row);
|
||||
const agentName = row.agent_name?.trim() || "-";
|
||||
const agentCode = row.agent_code?.trim() || "";
|
||||
const showCode = agentCode !== "" && agentCode !== agentName;
|
||||
return (
|
||||
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -429,8 +432,10 @@ export function DashboardAgentRankingCard({
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p>
|
||||
<p className="truncate text-xs font-medium">{agentName}</p>
|
||||
{showCode ? (
|
||||
<p className="truncate text-[11px] text-muted-foreground">{agentCode}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
|
||||
|
||||
@@ -13,6 +13,38 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
|
||||
function formatDashboardDrawStatus(
|
||||
status: string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const lower = status.trim().toLowerCase();
|
||||
|
||||
switch (lower) {
|
||||
case "pending":
|
||||
return t("statusOptions.pending", { ns: "draws" });
|
||||
case "open":
|
||||
return t("statusOptions.open", { ns: "draws" });
|
||||
case "closing":
|
||||
return t("statusOptions.closing", { ns: "draws" });
|
||||
case "closed":
|
||||
return t("statusOptions.closed", { ns: "draws" });
|
||||
case "drawing":
|
||||
return t("statusOptions.drawing", { ns: "draws" });
|
||||
case "review":
|
||||
return t("statusOptions.review", { ns: "draws" });
|
||||
case "cooldown":
|
||||
return t("statusOptions.cooldown", { ns: "draws" });
|
||||
case "settling":
|
||||
return t("statusOptions.settling", { ns: "draws" });
|
||||
case "settled":
|
||||
return t("statusOptions.settled", { ns: "draws" });
|
||||
case "cancelled":
|
||||
return t("statusOptions.cancelled", { ns: "draws" });
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenLikeStatus(status: string): boolean {
|
||||
const lower = status.toLowerCase();
|
||||
return lower.includes("open") || lower.includes("sale");
|
||||
@@ -29,7 +61,7 @@ export function DashboardCurrentDrawCard({
|
||||
drawId,
|
||||
loading = false,
|
||||
}: DashboardCurrentDrawCardProps): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { t } = useTranslation(["dashboard", "draws"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
|
||||
if (loading) {
|
||||
@@ -54,6 +86,7 @@ export function DashboardCurrentDrawCard({
|
||||
}
|
||||
|
||||
const openLike = isOpenLikeStatus(hall.status);
|
||||
const statusLabel = formatDashboardDrawStatus(hall.status, t);
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card overflow-hidden border-primary/15 py-0 shadow-sm">
|
||||
@@ -95,7 +128,7 @@ export function DashboardCurrentDrawCard({
|
||||
openLike ? "bg-emerald-500" : "bg-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
{hall.status}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -817,6 +817,11 @@ export function HotUsageBars({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
|
||||
const shortenPoolLabel = (value: string): string => {
|
||||
const normalized = value.trim();
|
||||
const trimmedMeta = normalized.replace(/\s*\(.+\)$/, "");
|
||||
return trimmedMeta.length > 10 ? `${trimmedMeta.slice(0, 10)}…` : trimmedMeta;
|
||||
};
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
@@ -824,6 +829,7 @@ export function HotUsageBars({
|
||||
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
|
||||
return {
|
||||
number: row.normalized_number.trim(),
|
||||
displayNumber: shortenPoolLabel(row.normalized_number),
|
||||
usage: pct,
|
||||
fill: usageBarFill(pct),
|
||||
};
|
||||
@@ -855,15 +861,23 @@ export function HotUsageBars({
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="number"
|
||||
width={72}
|
||||
width={compact ? 92 : 112}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
const row = chartData.find((item) => item.number === value);
|
||||
return row?.displayNumber ?? value;
|
||||
}}
|
||||
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) => `${Number(value).toFixed(1)}%`}
|
||||
formatter={(value, _name, item) => {
|
||||
const payload = item?.payload as { number?: string } | undefined;
|
||||
const number = payload?.number ? `${payload.number} · ` : "";
|
||||
return `${number}${Number(value).toFixed(1)}%`;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminDraw,
|
||||
getAdminDrawFinanceSummary,
|
||||
postAdminCancelDraw,
|
||||
postAdminManualCloseDraw,
|
||||
postAdminReopenDraw,
|
||||
@@ -22,11 +23,13 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||
import { canManageDrawResults } from "@/lib/draw-access";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
|
||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
@@ -80,6 +83,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -91,9 +95,20 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await getAdminDraw(idNum));
|
||||
const draw = await getAdminDraw(idNum);
|
||||
setData(draw);
|
||||
if (draw.capabilities?.can_view_draw_finance !== false) {
|
||||
try {
|
||||
setFinanceSummary(await getAdminDrawFinanceSummary(idNum));
|
||||
} catch {
|
||||
setFinanceSummary(null);
|
||||
}
|
||||
} else {
|
||||
setFinanceSummary(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setFinanceSummary(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -225,6 +240,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
const batch = data.result_batch_counts;
|
||||
const pendingReview = batch.pending_review ?? 0;
|
||||
const totalBatches = batch.total ?? batch.published;
|
||||
const financeCurrency = financeSummary?.currency_code ?? "NPR";
|
||||
const hasResultActivity =
|
||||
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
|
||||
const showActions =
|
||||
@@ -236,6 +252,14 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3">
|
||||
<Link
|
||||
href="/admin/draws"
|
||||
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
<CardTitle className="font-mono text-xl tracking-tight">{data.draw_no}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("detailSubtitle", {
|
||||
@@ -263,6 +287,39 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 border-t pt-6">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("overviewTitle")}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("overviewBetTotal")}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("overviewPayoutTotal")}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
|
||||
<ScheduleTimeline steps={scheduleSteps} />
|
||||
@@ -307,6 +364,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noResultBatchesYet")}
|
||||
<span className="ml-1">{t("reviewQueueHint")}</span>
|
||||
{canManageDraw ? (
|
||||
<>
|
||||
{" "}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
|
||||
export function JackpotConfigScreen() {
|
||||
@@ -26,6 +28,15 @@ export function JackpotConfigScreen() {
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<AdminPageCard title={t("poolsSectionTitle")}>
|
||||
<Alert className="mb-4 border-primary/20 bg-primary/5 text-foreground">
|
||||
<Info className="size-4" aria-hidden />
|
||||
<AlertTitle>{t("rulesTitle")}</AlertTitle>
|
||||
<AlertDescription className="space-y-1 text-xs leading-5">
|
||||
<p>{t("rulesJoin")}</p>
|
||||
<p>{t("rulesBurst")}</p>
|
||||
<p>{t("rulesManual")}</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<JackpotPoolsConsole embedded />
|
||||
</AdminPageCard>
|
||||
|
||||
|
||||
@@ -214,15 +214,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
const manualBurst = async (p: AdminJackpotPoolRow) => {
|
||||
const d = drafts[p.id];
|
||||
if (!d) return;
|
||||
const drawId = Number.parseInt(d.manual_burst_draw_id, 10);
|
||||
if (!Number.isFinite(drawId) || drawId <= 0) {
|
||||
const drawRef = d.manual_burst_draw_id.trim();
|
||||
if (drawRef.length === 0) {
|
||||
toast.error(t("invalidDrawId"));
|
||||
return;
|
||||
}
|
||||
|
||||
setBurstingId(p.id);
|
||||
try {
|
||||
const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawId });
|
||||
const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawRef });
|
||||
toast.success(
|
||||
`${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
||||
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
|
||||
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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -42,6 +43,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
|
||||
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
|
||||
import { Eye } from "lucide-react";
|
||||
|
||||
function playerStatusLabel(status: number, t: (key: string) => string): string {
|
||||
if (status === 0) return t("statusNormal");
|
||||
@@ -309,6 +311,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<ProfileField label={t("authSource")}>
|
||||
{playerAuthSourceLabel(player, t)}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("riskTags", { defaultValue: "风控标签" })}>
|
||||
{player.risk_tags && player.risk_tags.length > 0 ? player.risk_tags.join(", ") : "—"}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("status")}>
|
||||
<PlayerStatusBadge status={player.status} t={t} />
|
||||
</ProfileField>
|
||||
@@ -408,7 +413,10 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<TabsContent value="tickets" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t("ticketTableHint")}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
@@ -416,21 +424,29 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("orderNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("status", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("failReason", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="text-center">{t("winAmount", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("updatedAt", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-12 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("table.actions", { ns: "common" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ticketsLoading && tickets.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={7} />
|
||||
<AdminTableLoadingRow colSpan={11} />
|
||||
) : null}
|
||||
{tickets.map((row) => (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||||
@@ -442,13 +458,36 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
{ticketStatusText(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||||
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.jackpot_win_amount_minor > 0
|
||||
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
|
||||
: row.win_amount_formatted}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.placed_at ? formatDt(row.placed_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.updated_at ? formatDt(row.updated_at) : "—"}
|
||||
</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: "view-ticket-in-list",
|
||||
label: t("viewTicketInList", { ns: "tickets" }),
|
||||
icon: Eye,
|
||||
href: `/admin/tickets?player_id=${player.id}&number=${encodeURIComponent(row.ticket_no)}${row.draw_no ? `&draw_no=${encodeURIComponent(row.draw_no)}` : ""}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!ticketsLoading && tickets.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={7} className="text-muted-foreground" />
|
||||
<AdminTableNoResourceRow colSpan={11} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -82,6 +82,17 @@ const PLAYER_STATUS_OPTIONS = [
|
||||
{ value: 2, label: "statusBanned" },
|
||||
];
|
||||
|
||||
function parseRiskTagsInput(text: string): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
text
|
||||
.split(/[,,\s]+/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["players", "common"]);
|
||||
const tRef = useTranslationRef(["players", "common"]);
|
||||
@@ -121,6 +132,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [formNickname, setFormNickname] = useState("");
|
||||
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
|
||||
const [formStatus, setFormStatus] = useState(0);
|
||||
const [formRiskTags, setFormRiskTags] = useState("");
|
||||
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
|
||||
const [createAgentLoading, setCreateAgentLoading] = useState(false);
|
||||
@@ -211,6 +223,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setFormNickname("");
|
||||
setFormDefaultCurrency("NPR");
|
||||
setFormStatus(0);
|
||||
setFormRiskTags("");
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
@@ -269,6 +282,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setFormNickname(row.nickname ?? "");
|
||||
setFormDefaultCurrency(row.default_currency);
|
||||
setFormStatus(row.status);
|
||||
setFormRiskTags((row.risk_tags ?? []).join(", "));
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
@@ -337,6 +351,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
if (formStatus !== editingPlayer?.status) {
|
||||
body.status = formStatus;
|
||||
}
|
||||
const nextRiskTags = parseRiskTagsInput(formRiskTags);
|
||||
const prevRiskTags = editingPlayer?.risk_tags ?? [];
|
||||
if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) {
|
||||
body.risk_tags = nextRiskTags;
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
toast.success(t("noChanges"));
|
||||
@@ -517,6 +536,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<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>
|
||||
@@ -528,12 +548,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={13} />
|
||||
<AdminTableLoadingRow colSpan={14} />
|
||||
) : items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={13} className="text-muted-foreground" />
|
||||
<AdminTableNoResourceRow colSpan={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
items.map((row) => {
|
||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||
const riskTags = row.risk_tags ?? [];
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="tabular-nums">#{row.id}</TableCell>
|
||||
@@ -546,6 +567,22 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</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} />
|
||||
@@ -791,24 +828,61 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</>
|
||||
)}
|
||||
{accountMode === "edit" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-edit-status">{t("status")}</Label>
|
||||
<Select
|
||||
value={String(formStatus)}
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="player-edit-status">
|
||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<>
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("fundingMode", { defaultValue: "资金模式" })}
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{editingPlayer ? <PlayerFundingModeBadge row={editingPlayer} /> : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("authSource", { defaultValue: "登录来源" })}
|
||||
</p>
|
||||
<p className="mt-1 font-medium">
|
||||
{editingPlayer?.auth_source === "main_site_sso"
|
||||
? t("authMainSite", { defaultValue: "主站 SSO" })
|
||||
: editingPlayer?.auth_source === "lottery_native"
|
||||
? t("authNative", { defaultValue: "彩票端" })
|
||||
: editingPlayer?.auth_source ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-edit-risk-tags">
|
||||
{t("riskTags", { defaultValue: "风控标签" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="player-edit-risk-tags"
|
||||
value={formRiskTags}
|
||||
placeholder={t("riskTagsPlaceholder", { defaultValue: "如:高频、大额、需复核;多个标签用逗号分隔" })}
|
||||
onChange={(e) => setFormRiskTags(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-edit-status">{t("status")}</Label>
|
||||
<Select
|
||||
value={String(formStatus)}
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="player-edit-status">
|
||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -18,7 +19,7 @@ import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admi
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -71,6 +72,18 @@ function itemStatusLabel(status: string, t: (key: string) => string): string {
|
||||
return t("itemMatched");
|
||||
case "pending_check":
|
||||
return t("itemPendingCheck");
|
||||
case "stale_processing":
|
||||
return t("itemStaleProcessing");
|
||||
case "pending_reconcile":
|
||||
return t("itemPendingReconcile");
|
||||
case "missing_wallet_txn":
|
||||
return t("itemMissingWalletTxn");
|
||||
case "unexpected_wallet_txn":
|
||||
return t("itemUnexpectedWalletTxn");
|
||||
case "missing_refund":
|
||||
return t("itemMissingRefund");
|
||||
case "missing_reversal":
|
||||
return t("itemMissingReversal");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
@@ -85,6 +98,82 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function itemResolutionLabel(
|
||||
row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return row.is_resolved === true || row.resolved_at ? t("itemResolved") : t("itemUnresolved");
|
||||
}
|
||||
|
||||
function itemResolutionTone(row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">): "success" | "warning" {
|
||||
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
|
||||
}
|
||||
|
||||
function itemDiagnosisLabel(status: string, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case "stale_processing":
|
||||
return t("diagnosisStaleProcessing");
|
||||
case "pending_reconcile":
|
||||
return t("diagnosisPendingReconcile");
|
||||
case "missing_wallet_txn":
|
||||
return t("diagnosisMissingWalletTxn");
|
||||
case "unexpected_wallet_txn":
|
||||
return t("diagnosisUnexpectedWalletTxn");
|
||||
case "missing_refund":
|
||||
return t("diagnosisMissingRefund");
|
||||
case "missing_reversal":
|
||||
return t("diagnosisMissingReversal");
|
||||
case "matched":
|
||||
return t("diagnosisMatched");
|
||||
default:
|
||||
return t("diagnosisPendingCheck");
|
||||
}
|
||||
}
|
||||
|
||||
function itemSuggestedAction(
|
||||
row: Pick<AdminReconcileItemsData["items"][number], "status" | "resolved_at" | "is_resolved" | "current_transfer_status">,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (row.is_resolved === true || row.resolved_at) {
|
||||
return t("actionResolved", {
|
||||
status: row.current_transfer_status ? itemTransferStatusLabel(row.current_transfer_status, t) : t("statusCompleted"),
|
||||
});
|
||||
}
|
||||
|
||||
const status = row.status;
|
||||
switch (status) {
|
||||
case "stale_processing":
|
||||
return t("actionStaleProcessing");
|
||||
case "pending_reconcile":
|
||||
return t("actionPendingReconcile");
|
||||
case "missing_wallet_txn":
|
||||
return t("actionMissingWalletTxn");
|
||||
case "unexpected_wallet_txn":
|
||||
return t("actionUnexpectedWalletTxn");
|
||||
case "missing_refund":
|
||||
return t("actionMissingRefund");
|
||||
case "missing_reversal":
|
||||
return t("actionMissingReversal");
|
||||
case "matched":
|
||||
return t("actionMatched");
|
||||
default:
|
||||
return t("actionPendingCheck");
|
||||
}
|
||||
}
|
||||
|
||||
function itemTransferStatusLabel(status: string, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return t("transferStatusSuccess");
|
||||
case "reversed":
|
||||
return t("transferStatusReversed");
|
||||
case "manually_processed":
|
||||
return t("transferStatusManual");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
||||
const raw = summary?.[key];
|
||||
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
||||
@@ -507,8 +596,8 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view",
|
||||
label: t("view"),
|
||||
key: "view-details",
|
||||
label: t("viewDetails"),
|
||||
icon: Eye,
|
||||
onClick: () => {
|
||||
setSelectedId(row.id);
|
||||
@@ -609,16 +698,20 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("sideARef")}</TableHead>
|
||||
<TableHead>{t("sideBRef")}</TableHead>
|
||||
<TableHead className="text-right">{t("differenceAmount")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("detectedAt")}</TableHead>
|
||||
<TableHead className="min-w-[10rem]">{t("sideARef")}</TableHead>
|
||||
<TableHead className="min-w-[10rem]">{t("sideBRef")}</TableHead>
|
||||
<TableHead className="w-28 text-right">{t("differenceAmount")}</TableHead>
|
||||
<TableHead className="w-32">{t("itemResult")}</TableHead>
|
||||
<TableHead className="min-w-[16rem] whitespace-normal leading-snug">{t("diagnosis")}</TableHead>
|
||||
<TableHead className="min-w-[16rem] whitespace-normal leading-snug">{t("suggestedAction")}</TableHead>
|
||||
<TableHead className="w-28">{t("processingStatus")}</TableHead>
|
||||
<TableHead className="w-32">{t("quickAccess")}</TableHead>
|
||||
<TableHead className="w-36">{t("detectedAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={6} />
|
||||
<AdminTableNoResourceRow colSpan={10} />
|
||||
) : (
|
||||
items.items.map((r) => (
|
||||
<TableRow
|
||||
@@ -628,10 +721,10 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
r.status === "matched" && "bg-emerald-500/5",
|
||||
)}
|
||||
>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<TableCell className="align-top">{r.id}</TableCell>
|
||||
<TableCell className="align-top font-mono text-xs break-all">{r.side_a_ref ?? "—"}</TableCell>
|
||||
<TableCell className="align-top font-mono text-xs break-all">{r.side_b_ref ?? "—"}</TableCell>
|
||||
<TableCell className="align-top text-right tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
||||
@@ -640,12 +733,46 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{r.difference_amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AdminStatusBadge status={r.status}>
|
||||
{itemStatusLabel(r.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
<TableCell className="align-top min-w-[16rem] max-w-[18rem] whitespace-normal break-words text-xs leading-6 text-muted-foreground">
|
||||
{itemDiagnosisLabel(r.status, t)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top min-w-[16rem] max-w-[18rem] whitespace-normal break-words text-xs leading-6">
|
||||
{itemSuggestedAction(r, t)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AdminStatusBadge status={r.resolved_at ? "resolved" : "unresolved"} tone={itemResolutionTone(r)}>
|
||||
{itemResolutionLabel(r, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top min-w-[10rem]">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{r.side_a_ref ? (
|
||||
<Link
|
||||
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(r.side_a_ref)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||
>
|
||||
{t("openTransferOrder")}
|
||||
</Link>
|
||||
) : null}
|
||||
{r.side_b_ref ? (
|
||||
<Link
|
||||
href={`/admin/wallet/transactions?txn_no=${encodeURIComponent(r.side_b_ref)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||
>
|
||||
{t("openWalletTxn")}
|
||||
</Link>
|
||||
) : null}
|
||||
{!r.side_a_ref && !r.side_b_ref ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(r.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -40,9 +40,10 @@ function downloadBlob(blob: Blob, filename: string): void {
|
||||
type ReportJobsPanelProps = {
|
||||
canExport: boolean;
|
||||
refreshToken?: number;
|
||||
reportType?: string;
|
||||
};
|
||||
|
||||
export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) {
|
||||
export function ReportJobsPanel({ canExport, refreshToken = 0, reportType }: ReportJobsPanelProps) {
|
||||
const { t } = useTranslation(["reports", "common"]);
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
@@ -53,7 +54,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
const loadJobs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminReportJobs({ page: 1, per_page: 10 });
|
||||
const data = await getAdminReportJobs({ page: 1, per_page: 10, report_type: reportType || undefined });
|
||||
setJobs(data.items);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed"));
|
||||
@@ -61,7 +62,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [reportType, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void loadJobs();
|
||||
@@ -100,7 +101,9 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<p className="mb-3 text-xs text-muted-foreground">{t("exportHint")}</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{reportType ? t("tasks.currentReportHint") : t("exportHint")}
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -93,7 +93,7 @@ import type {
|
||||
AdminReportRebateCommissionRow,
|
||||
} from "@/types/api/admin-reports";
|
||||
|
||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit" | "legacy";
|
||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
||||
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
|
||||
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
||||
type ExportFormat = "csv" | "excel";
|
||||
@@ -192,7 +192,7 @@ const REPORTS: ReportDefinition[] = [
|
||||
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
|
||||
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
|
||||
{ key: "rebate_commission", category: "legacy", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||
{ key: "rebate_commission", category: "profit", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
|
||||
];
|
||||
|
||||
@@ -226,8 +226,6 @@ function categoryTone(category: ReportCategory): string {
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
case "audit":
|
||||
return "border-slate-200 bg-slate-50 text-slate-700";
|
||||
case "legacy":
|
||||
return "border-amber-200 bg-amber-50 text-amber-800";
|
||||
default:
|
||||
return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
}
|
||||
@@ -405,6 +403,90 @@ function resultRowCount(result: ReportResult | null): number {
|
||||
return result?.rows.length ?? 0;
|
||||
}
|
||||
|
||||
function defaultSummaryCards(
|
||||
reportKey: ReportKey,
|
||||
filters: ReportFilters,
|
||||
t: (key: string) => string,
|
||||
): StatCard[] {
|
||||
const periodLabel =
|
||||
filters.dateFrom && filters.dateTo
|
||||
? `${filters.dateFrom} ~ ${filters.dateTo}`
|
||||
: filters.dateFrom || filters.dateTo || t("preview.stats.notQueried");
|
||||
|
||||
switch (reportKey) {
|
||||
case "draw_profit":
|
||||
return [
|
||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||
];
|
||||
case "daily_profit":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.period"), value: periodLabel },
|
||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "player_win_loss":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.players"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "player_transfer":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.transferIn"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.transferOut"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "hot_number_risk":
|
||||
return [
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||
{ label: t("fields.number"), value: filters.number || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.logs"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "play_dimension":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.play"), value: filters.play || t("filterAll") },
|
||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "sold_out_number":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.currency"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "rebate_commission":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.play"), value: filters.play || t("filterAll") },
|
||||
{ label: t("preview.stats.rebate"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.orders"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "admin_audit":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.modules"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.operators"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.exportRows"), value: "0" },
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
|
||||
const { t, i18n } = useTranslation(["reports", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
@@ -1426,189 +1508,179 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<Card className="admin-list-card self-start">
|
||||
<CardHeader className="admin-list-header pb-4">
|
||||
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5 pt-3">
|
||||
{filteredReports.map((report) => {
|
||||
const Icon = report.icon;
|
||||
const active = report.key === selectedReport.key;
|
||||
return (
|
||||
<button
|
||||
key={report.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedKey(report.key)}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-3 rounded-md border px-3 py-2.5 text-left transition",
|
||||
active
|
||||
? "border-primary bg-primary/[0.05] shadow-sm ring-1 ring-primary/15"
|
||||
: "border-border/80 bg-card hover:border-primary/35 hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{t(`items.${report.key}.title`)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="min-w-0 space-y-5">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="admin-list-title">{t("filterPanel")}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="outline" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header pb-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredReports.map((report) => {
|
||||
const Icon = report.icon;
|
||||
const active = report.key === selectedReport.key;
|
||||
return (
|
||||
<button
|
||||
key={report.key}
|
||||
type="button"
|
||||
disabled={!canViewReports || !selectedReport.connected || loading}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
void queryReport();
|
||||
}}
|
||||
onClick={() => setSelectedKey(report.key)}
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition",
|
||||
active
|
||||
? "border-primary bg-primary/[0.06] text-primary shadow-sm"
|
||||
: "border-border/80 bg-card text-muted-foreground hover:border-primary/35 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Database data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{(result?.summary ?? [
|
||||
{ label: t("preview.stats.records"), value: "-" },
|
||||
{ label: t("preview.stats.currentPage"), value: "-" },
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || "-" },
|
||||
{ label: t("preview.stats.exportRows"), value: String(resultRowCount(result)) },
|
||||
]).map((item) => (
|
||||
<div key={item.label} className={cn("rounded-md border px-4 py-3", statTone(item.tone))}>
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedReport.key === "rebate_commission" ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
{t("items.rebate_commission.disclaimer", {
|
||||
defaultValue:
|
||||
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
|
||||
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
<span className="truncate">{t(`items.${report.key}.title`)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-muted-foreground">{t("filterPanel")}</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canViewReports || !selectedReport.connected || loading}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
void queryReport();
|
||||
}}
|
||||
>
|
||||
<Database data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("exportServerHint")}</p>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("csv")}
|
||||
>
|
||||
<FileDown data-icon="inline-start" />
|
||||
{t("formats.csvServer")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("excel")}
|
||||
>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
{t("formats.excelServer")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && result.rows.length > 0 ? (
|
||||
<>
|
||||
<div className="grid gap-2 md:grid-cols-4">
|
||||
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
|
||||
<div key={item.label} className={cn("rounded-md border px-3 py-2.5", statTone(item.tone))}>
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
<div className="mt-0.5 truncate text-base font-semibold tabular-nums">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedReport.key === "rebate_commission" ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
{t("items.rebate_commission.disclaimer", {
|
||||
defaultValue:
|
||||
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("csv")}
|
||||
>
|
||||
<FileDown data-icon="inline-start" />
|
||||
{t("formats.csvServer")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("excel")}
|
||||
>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
{t("formats.excelServer")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && result.rows.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("csv")}
|
||||
>
|
||||
{t("formats.csvPreview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("excel")}
|
||||
>
|
||||
{t("formats.excelPreview")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("csv")}
|
||||
>
|
||||
{t("formats.csvPreview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("excel")}
|
||||
>
|
||||
{t("formats.excelPreview")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950">
|
||||
{t("preview.summaryScopeHint")}
|
||||
</div>
|
||||
<Table id="reports-preview-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{previewColumns.primary}</TableHead>
|
||||
<TableHead>{previewColumns.secondary}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
||||
<TableHead>{previewColumns.status}</TableHead>
|
||||
<TableHead>{previewColumns.extra}</TableHead>
|
||||
<TableHead>{previewColumns.time}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderTable()}</TableBody>
|
||||
</Table>
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
|
||||
{t("preview.summaryScopeHint")}
|
||||
</div>
|
||||
<Table id="reports-preview-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{previewColumns.primary}</TableHead>
|
||||
<TableHead>{previewColumns.secondary}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
||||
<TableHead>{previewColumns.status}</TableHead>
|
||||
<TableHead>{previewColumns.extra}</TableHead>
|
||||
<TableHead>{previewColumns.time}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderTable()}</TableBody>
|
||||
</Table>
|
||||
|
||||
{result?.meta ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="reports-preview-per-page"
|
||||
total={result.meta.total}
|
||||
page={result.meta.page}
|
||||
lastPage={result.meta.lastPage}
|
||||
perPage={result.meta.perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{result?.meta ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="reports-preview-per-page"
|
||||
total={result.meta.total}
|
||||
page={result.meta.page}
|
||||
lastPage={result.meta.lastPage}
|
||||
perPage={result.meta.perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReportJobsPanel canExport={canExportReports} refreshToken={jobRefreshToken} />
|
||||
<ReportJobsPanel
|
||||
canExport={canExportReports}
|
||||
refreshToken={jobRefreshToken}
|
||||
reportType={REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||
|
||||
const tabs = [
|
||||
{ category: "profit", href: "/admin/reports/profit" },
|
||||
{ category: "wallet", href: "/admin/reports/wallet" },
|
||||
{ category: "legacy", href: "/admin/reports/legacy" },
|
||||
{ category: "risk", href: "/admin/reports/risk" },
|
||||
{ category: "audit", href: "/admin/reports/audit" },
|
||||
] as const;
|
||||
|
||||
export function ReportsSubnav(): React.ReactElement {
|
||||
const { t } = useTranslation("reports");
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<AdminSubnav aria-label={t("title")}>
|
||||
{tabs.map((tab) => {
|
||||
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||
return (
|
||||
<AdminSubnavLink key={tab.href} href={tab.href} active={active}>
|
||||
{t(`categories.${tab.category}`)}
|
||||
</AdminSubnavLink>
|
||||
);
|
||||
})}
|
||||
</AdminSubnav>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface CurrencyFormatDraft {
|
||||
currencyDisplayDecimals: string;
|
||||
currencyDecimalSeparator: string;
|
||||
currencyThousandsSeparator: string;
|
||||
}
|
||||
|
||||
const INITIAL: CurrencyFormatDraft = {
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): CurrencyFormatDraft {
|
||||
return {
|
||||
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
||||
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
||||
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: CurrencyFormatDraft, saved: CurrencyFormatDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
if (draft.currencyDisplayDecimals !== saved.currencyDisplayDecimals) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
||||
value: Math.max(
|
||||
0,
|
||||
Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (draft.currencyDecimalSeparator !== saved.currencyDecimalSeparator) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
||||
value: (draft.currencyDecimalSeparator || ".").slice(0, 1),
|
||||
});
|
||||
}
|
||||
if (draft.currencyThousandsSeparator !== saved.currencyThousandsSeparator) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
||||
value: (draft.currencyThousandsSeparator || ",").slice(0, 1),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function CurrencyFormatSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveCurrencyFormatSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("system.sections.currencyFormat", { ns: "config" })}
|
||||
description={t("system.sections.currencyFormatDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-display-decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={draft.currencyDisplayDecimals}
|
||||
placeholder={t("system.placeholders.currencyDisplayDecimals", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyDisplayDecimals", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-decimal-separator"
|
||||
value={draft.currencyDecimalSeparator}
|
||||
placeholder={t("system.placeholders.currencyDecimalSeparator", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyDecimalSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-thousands-separator"
|
||||
value={draft.currencyThousandsSeparator}
|
||||
placeholder={t("system.placeholders.currencyThousandsSeparator", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyThousandsSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveCurrencyFormatTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveCurrencyFormatDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,6 @@ export const DRAW_KEYS = {
|
||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
||||
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
||||
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
||||
} as const;
|
||||
|
||||
export const SETTLEMENT_KEYS = {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
|
||||
import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel";
|
||||
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
|
||||
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
|
||||
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
|
||||
@@ -15,7 +14,6 @@ function SystemSettingsContent() {
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<DrawSettingsPanel />
|
||||
<CurrencyFormatSettingsPanel />
|
||||
<SettlementSettingsPanel />
|
||||
|
||||
<AdminPageCard
|
||||
|
||||
@@ -197,7 +197,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
|
||||
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
|
||||
<DialogContent
|
||||
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(1040px,calc(100vw-2rem))] sm:!max-w-[1040px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
||||
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(860px,calc(100vw-2rem))] sm:!max-w-[860px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
||||
@@ -124,9 +125,13 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const searchParams = useSearchParams();
|
||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
||||
const numberKeywordFromUrl = (searchParams.get("number") ?? "").trim();
|
||||
const initialFilters: TicketFilters = {
|
||||
...emptyTicketFilters,
|
||||
playerQuery: playerIdFromUrl,
|
||||
drawNo: drawNoFromUrl,
|
||||
numberKeyword: numberKeywordFromUrl,
|
||||
};
|
||||
const [draft, setDraft] = useState<TicketFilters>(initialFilters);
|
||||
const [applied, setApplied] = useState<TicketFilters>(initialFilters);
|
||||
@@ -340,6 +345,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead>{t("fundingMode", { ns: "players" })}</TableHead>
|
||||
<TableHead>{t("orderNo")}</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
@@ -351,14 +357,14 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableHead className="text-center">{t("winAmount")}</TableHead>
|
||||
<TableHead>{t("placedAt")}</TableHead>
|
||||
<TableHead>{t("updatedAt")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={17} />
|
||||
<AdminTableLoadingRow colSpan={18} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={17} />
|
||||
<AdminTableNoResourceRow colSpan={18} />
|
||||
) : (
|
||||
data.items.map((row) => {
|
||||
const winLabel = row.jackpot_win_amount > 0
|
||||
@@ -369,6 +375,9 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell className="text-xs">
|
||||
<PlayerFundingModeBadge row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||
|
||||
@@ -23,13 +23,13 @@ import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -55,7 +55,6 @@ import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayerWalletsData,
|
||||
@@ -323,13 +322,23 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
const exportLabels = useExportLabels("walletTransferOrders");
|
||||
useAdminCurrencyCatalog();
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const searchParams = useSearchParams();
|
||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||
const transferNoFromUrl = (searchParams.get("transfer_no") ?? "").trim();
|
||||
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
|
||||
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
|
||||
const [applied, setApplied] = useState<TransferFilters>(emptyTransferFilters);
|
||||
const initialTransferFilters: TransferFilters = {
|
||||
...emptyTransferFilters,
|
||||
playerId: playerIdFromUrl,
|
||||
transferNo: transferNoFromUrl,
|
||||
externalRefNo: externalRefNoFromUrl,
|
||||
};
|
||||
const [draft, setDraft] = useState<TransferFilters>(initialTransferFilters);
|
||||
const [applied, setApplied] = useState<TransferFilters>(initialTransferFilters);
|
||||
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
|
||||
|
||||
const doAction = async (
|
||||
@@ -645,10 +654,18 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
|
||||
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
|
||||
const searchParams = useSearchParams();
|
||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||
const txnNoFromUrl = (searchParams.get("txn_no") ?? "").trim();
|
||||
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
|
||||
const initialTxnFilters: TxnFilters = {
|
||||
...emptyTxnFilters,
|
||||
playerId: playerIdFromUrl,
|
||||
txnNo: txnNoFromUrl,
|
||||
externalRefNo: externalRefNoFromUrl,
|
||||
};
|
||||
const [draft, setDraft] = useState<TxnFilters>(initialTxnFilters);
|
||||
const [applied, setApplied] = useState<TxnFilters>(initialTxnFilters);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -685,15 +702,6 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
void load();
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!playerIdFromUrl) {
|
||||
return;
|
||||
}
|
||||
setDraft((d) => ({ ...d, playerId: playerIdFromUrl }));
|
||||
setApplied((d) => ({ ...d, playerId: playerIdFromUrl }));
|
||||
setPage(1);
|
||||
}, [playerIdFromUrl]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
setPage(1);
|
||||
|
||||
Reference in New Issue
Block a user