Refactor agents console and related components
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

- Simplified share rate calculation in AgentsConsole by removing unnecessary checks and directly setting the profile share rate.
- Updated the use of `profileParentCaps` to always return total share rate in the agent profile.
- Removed unused variables and memoized calculations for improved performance.
- Cleaned up imports in various files, removing unused components and optimizing the code structure.
- Added `tRef` dependency to several useEffect hooks to ensure proper reactivity to translation changes.
- Enhanced report preview tables with better label handling for various statuses and actions.
- Updated wallet filter options to align with player-side transaction types.
- Introduced new properties in types for better type safety and clarity.
This commit is contained in:
2026-06-30 17:55:00 +08:00
parent 6c3ded5b75
commit 4484a7a77a
56 changed files with 320 additions and 158 deletions

View File

@@ -4,7 +4,7 @@ import { ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
@@ -18,7 +18,6 @@ type PermissionSelectorProps = {
selectableSlugs?: string[] | null;
helperText?: string;
summaryText?: string;
emptyText: string;
heightClassName?: string;
};
@@ -31,7 +30,6 @@ export function AdminPermissionSelector({
selectableSlugs = null,
helperText,
summaryText,
emptyText,
heightClassName = "h-[52vh]",
}: PermissionSelectorProps): React.ReactElement {
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});

View File

@@ -47,7 +47,6 @@ export function PlayerCreditLimitField({
max={maxCredit}
step={1000}
integer
preserveOverMaxOnBlur
/>
{parentAvailableCredit !== null ? (
<p className="text-[11px] text-muted-foreground/80">

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { adminHasAnyPermission } from "@/lib/admin-permissions";

View File

@@ -23,5 +23,6 @@ export function useAsyncEffect(
return () => {
cleanup?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}

View File

@@ -200,6 +200,8 @@
"rebateLimit": "Rebate ceiling (%)",
"relativeShareRate": "Share rate (% of parent)",
"relativeShareRateValue": "{{rate}}% of parent",
"actualShareRate": "Effective share {{rate}}%",
"relativeShareCapHint": "Up to 100% of parent (parent total share {{parent}}%)",
"riskTags": "Risk tags",
"riskTagsPlaceholder": "Comma-separated, e.g. overdue, high_turnover",
"saveSuccess": "Share and credit settings saved",

View File

@@ -273,6 +273,24 @@
"play": "Play name or code",
"operator": "Admin account or name"
},
"labels": {
"transferDirection": {
"in": "Transfer in",
"out": "Transfer out"
},
"transferStatus": {
"processing": "Processing",
"success": "Success",
"failed": "Failed",
"pending_reconcile": "Pending reconcile",
"reversed": "Reversed",
"manually_processed": "Case closed"
},
"operatorType": {
"admin": "Admin",
"system": "System"
}
},
"items": {
"draw_profit": {
"title": "Draw bets / payout / P&L",

View File

@@ -146,8 +146,10 @@
},
"reason": {
"payment_record": "Bill payment",
"bet": "Bet hold",
"bet_hold": "Bet hold",
"game_settlement": "Draw settlement",
"bill_settlement": "Period payment",
"game_settlement_reversal": "Draw reversal",
"game_settlement_win": "Draw settlement credit",
"bet_hold_release": "Hold release",

View File

@@ -24,6 +24,9 @@
"transferOutRefund": "Transfer-out refund",
"bizBetDeduct": "Bet debit",
"bizBetReverse": "Bet reversal",
"bizPrize": "Payout",
"bizRefund": "Refund",
"bizReversal": "Reversal",
"bizSettlePayout": "Settlement payout",
"bizJackpotPayout": "Jackpot payout",
"bizSettlementAdjustment": "Settlement adjustment",

View File

@@ -93,6 +93,24 @@
"wallet_transfer_report": "वालेट ट्रान्सफर",
"wallet_txns_daily": "वालेट लेनदेन (दैनिक)"
},
"labels": {
"operatorType": {
"admin": "प्रशासक",
"system": "प्रणाली"
},
"transferDirection": {
"in": "भित्र",
"out": "बाहिर"
},
"transferStatus": {
"failed": "असफल",
"manually_processed": "केस बन्द",
"pending_reconcile": "मिलान बाँकी",
"processing": "प्रक्रियामा",
"reversed": "रिभर्स भयो",
"success": "सफल"
}
},
"libraryTitle": "रिपोर्ट प्रकार",
"loadFailed": "लोड असफल",
"no": "होइन",

View File

@@ -144,8 +144,10 @@
},
"reason": {
"payment_record": "Bill payment",
"bet": "Bet hold",
"bet_hold": "Bet hold",
"game_settlement": "Draw settlement",
"bill_settlement": "Period payment",
"game_settlement_reversal": "Draw reversal",
"game_settlement_win": "Draw settlement credit",
"bet_hold_release": "Hold release",

View File

@@ -10,6 +10,9 @@
"bizBetDeduct": "बेट कटौती",
"bizBetReverse": "बेट उल्टाउने",
"bizJackpotPayout": "ज्याकपट भुक्तानी",
"bizPrize": "भुक्तानी",
"bizRefund": "फिर्ता",
"bizReversal": "रिभर्सल",
"bizSettlePayout": "बन्दोबस्त भुक्तानी",
"bizSettlementAdjustment": "बन्दोबस्त समायोजन",
"bizType": "व्यवसाय प्रकार",

View File

@@ -160,6 +160,8 @@
"totalShareRate": "占成比例 (%)",
"relativeShareRate": "占成比例(占上级 %",
"relativeShareRateValue": "占上级 {{rate}}%",
"actualShareRate": "实际占成 {{rate}}%",
"relativeShareCapHint": "占上级比例最高 100%(上级总占成 {{parent}}%",
"creditLimit": "授信额度",
"rebateLimit": "回水上限 (%)",
"defaultPlayerRebate": "默认玩家回水 (%)",

View File

@@ -273,6 +273,24 @@
"play": "玩法名称或编码",
"operator": "管理员账号或昵称"
},
"labels": {
"transferDirection": {
"in": "转入",
"out": "转出"
},
"transferStatus": {
"processing": "处理中",
"success": "成功",
"failed": "失败",
"pending_reconcile": "待对账",
"reversed": "已冲正",
"manually_processed": "已结案"
},
"operatorType": {
"admin": "管理员",
"system": "系统"
}
},
"items": {
"draw_profit": {
"title": "每期下注/派彩/盈亏",

View File

@@ -131,9 +131,11 @@
},
"reason": {
"payment_record": "账单收付",
"bet": "下注冻结",
"bet_hold": "下注冻结",
"freezeAmount": "冻结 {{amount}}",
"game_settlement": "开奖结算",
"bill_settlement": "账期收付",
"game_settlement_reversal": "开奖冲正",
"game_settlement_win": "开奖结算入账",
"bet_hold_release": "占用释放",

View File

@@ -24,6 +24,9 @@
"transferOutRefund": "转出失败回补",
"bizBetDeduct": "下注扣款",
"bizBetReverse": "下注冲正",
"bizPrize": "派彩",
"bizRefund": "退本",
"bizReversal": "冲正",
"bizSettlePayout": "派彩入账",
"bizJackpotPayout": "奖池派彩",
"bizSettlementAdjustment": "结算调账",

View File

@@ -1,3 +1,4 @@
import { percentValueToUi } from "@/lib/admin-rate-percent";
import type { AgentParentCaps } from "@/types/api/admin-agent";
/** 是否为站点一级代理(根节点)。 */
@@ -65,6 +66,29 @@ export function maxCreditLimitFromParent(
return baseline + extra;
}
/** 实际占成 → 占上级比例0100无上级或上级占成为 0 时返回 null。 */
export function relativeSharePercentFromTotal(
totalShareRate: number | null | undefined,
parentTotalShareRate: number | null | undefined,
): number | null {
const total = Number(totalShareRate);
const parent = Number(parentTotalShareRate);
if (!Number.isFinite(total) || !Number.isFinite(parent) || parent <= 0) {
return null;
}
return (total / parent) * 100;
}
/** 实际占成 → 占上级比例 UI 字符串(如 "50")。 */
export function relativeShareRateUiFromTotal(
totalShareRate: number | null | undefined,
parentTotalShareRate: number | null | undefined,
): string | null {
const relative = relativeSharePercentFromTotal(totalShareRate, parentTotalShareRate);
return relative === null ? null : percentValueToUi(relative);
}
/** 相对占成(占上级 %)换算后的实际占成,不得超过上级总占成。 */
export function actualShareRateFromRelative(
relativePercent: number,

View File

@@ -1,7 +1,6 @@
import type { LucideIcon } from "lucide-react";
import {
CalendarClock,
CircleDollarSign,
ClipboardList,
Coins,
FileSpreadsheet,

View File

@@ -4,7 +4,7 @@ import { Pencil, Plus, Trash2, Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
@@ -21,6 +21,7 @@ import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agen
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { Button } from "@/components/ui/button";
import { percentValueToUi } from "@/lib/admin-rate-percent";
import { relativeShareRateUiFromTotal } from "@/lib/agent-profile-caps";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
@@ -41,7 +42,6 @@ export type AgentLineDetailPanelProps = {
detailTab: AgentDetailTab;
onDetailTabChange: (tab: AgentDetailTab) => void;
canViewProfileTab: boolean;
canEditProfileTab: boolean;
canSaveProfileTab: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
@@ -73,7 +73,6 @@ export function AgentLineDetailPanel({
detailTab,
onDetailTabChange,
canViewProfileTab,
canEditProfileTab,
canSaveProfileTab,
canViewDownlineTab,
canViewPlayersTab,
@@ -149,7 +148,7 @@ export function AgentLineDetailPanel({
{metaParts.length > 0 ? (
<p className="truncate text-sm text-muted-foreground">{metaParts.join(" · ")}</p>
) : null}
<AgentHeaderMetrics profile={profile} loading={profileLoading} />
<AgentHeaderMetrics node={node} profile={profile} loading={profileLoading} />
</div>
{canManageNode ? (
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
@@ -213,6 +212,9 @@ export function AgentLineDetailPanel({
<DownlineSection
childAgents={childAgents}
childCountById={childCountById}
parentTotalShareRate={
profile?.total_share_rate ?? node.profile_summary?.total_share_rate ?? null
}
canManageNode={canManageNode}
canCreateChild={canCreateChild}
canDeleteChild={canDeleteChild}
@@ -238,9 +240,11 @@ export function AgentLineDetailPanel({
}
function AgentHeaderMetrics({
node,
profile,
loading,
}: {
node: AgentNodeRow;
profile: AgentProfileRow | null;
loading: boolean;
}): React.ReactElement {
@@ -250,8 +254,22 @@ function AgentHeaderMetrics({
return <p className="text-sm text-muted-foreground"></p>;
}
const totalShare = profile?.total_share_rate ?? node.profile_summary?.total_share_rate ?? 0;
const parentShare = profile?.parent_caps?.total_share_rate;
const relativeShareUi = relativeShareRateUiFromTotal(totalShare, parentShare);
const shareMetric =
relativeShareUi !== null
? `${t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: relativeShareUi,
})}${t("profile.actualShareRate", {
defaultValue: "实际占成 {{rate}}%",
rate: percentValueToUi(totalShare),
})}`
: `${t("profile.totalShareRate", { defaultValue: "占成" })} ${percentValueToUi(totalShare)}%`;
const items = [
`${t("profile.totalShareRate", { defaultValue: "占成" })} ${profile?.total_share_rate ?? 0}%`,
shareMetric,
`${t("profile.creditLimit", { defaultValue: "授信" })} ${formatCredit(profile?.credit_limit ?? 0)}`,
`${t("lineUi.availableCredit", { defaultValue: "可下发" })} ${formatCredit(profile?.available_credit ?? 0)}`,
`${t("profile.rebateLimit", { defaultValue: "回水上限" })} ${percentValueToUi(profile?.rebate_limit ?? 0)}%`,
@@ -267,6 +285,7 @@ function AgentHeaderMetrics({
function DownlineSection({
childAgents,
childCountById,
parentTotalShareRate,
canManageNode,
canCreateChild,
canDeleteChild,
@@ -277,6 +296,7 @@ function DownlineSection({
}: {
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
parentTotalShareRate: number | null;
canManageNode: boolean;
canCreateChild: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
@@ -313,6 +333,7 @@ function DownlineSection({
<DownlineTable
childAgents={childAgents}
childCountById={childCountById}
parentTotalShareRate={parentTotalShareRate}
canManageNode={canManageNode}
onEditChild={onEditChild}
onDeleteChild={onDeleteChild}
@@ -326,6 +347,7 @@ function DownlineSection({
function DownlineTable({
childAgents,
childCountById,
parentTotalShareRate,
canManageNode,
canDeleteChild,
onEditChild,
@@ -334,6 +356,7 @@ function DownlineTable({
}: {
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
parentTotalShareRate: number | null;
canManageNode: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void;
@@ -341,6 +364,8 @@ function DownlineTable({
onSelectChild: (node: AgentNodeRow) => void;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const showRelativeShare =
parentTotalShareRate !== null && Number(parentTotalShareRate) > 0;
return (
<div className="admin-table-inset overflow-hidden">
@@ -351,7 +376,9 @@ function DownlineTable({
<TableHead>{t("agentName", { defaultValue: "名称" })}</TableHead>
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.totalShareRate", { defaultValue: "占成" })}
{showRelativeShare
? t("profile.totalShareWithRelative", { defaultValue: "实际占成 / 占上级" })
: t("profile.totalShareRate", { defaultValue: "占成" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.creditLimit", { defaultValue: "授信" })}
@@ -384,8 +411,15 @@ function DownlineTable({
<div className="font-mono text-xs text-muted-foreground">{child.code}</div>
</TableCell>
<TableCell className="text-sm">{child.username ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-sm">
{summary ? `${summary.total_share_rate ?? 0}%` : "—"}
<TableCell className="text-right text-sm">
{summary ? (
<DownlineShareCell
totalShareRate={summary.total_share_rate}
parentTotalShareRate={parentTotalShareRate}
/>
) : (
"—"
)}
</TableCell>
<TableCell className={adminMoneyCellClassName("text-right text-sm")}>
{summary ? <AdminTableMoney>{formatCredit(summary.credit_limit)}</AdminTableMoney> : "—"}
@@ -440,4 +474,31 @@ function DownlineTable({
</div>
</div>
);
}
}
function DownlineShareCell({
totalShareRate,
parentTotalShareRate,
}: {
totalShareRate: number;
parentTotalShareRate: number | null;
}): React.ReactElement {
const { t } = useTranslation("agents");
const relativeShareUi = relativeShareRateUiFromTotal(totalShareRate, parentTotalShareRate);
if (relativeShareUi !== null) {
return (
<div className="tabular-nums">
<div>{percentValueToUi(totalShareRate)}%</div>
<div className="text-xs text-muted-foreground">
{t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: relativeShareUi,
})}
</div>
</div>
);
}
return <div className="tabular-nums">{percentValueToUi(totalShareRate)}%</div>;
}

View File

@@ -37,6 +37,32 @@ type AgentLineProvisionWizardProps = {
onSuccess?: (result: AdminAgentLineProvisionResult) => void | Promise<void>;
};
function clampPercentInput(value: string): string {
if (value.trim() === "") {
return "";
}
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) {
return "0";
}
return String(Math.min(100, Math.max(0, parsed)));
}
function clampIntegerInput(value: string): string {
if (value.trim() === "") {
return "";
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return "0";
}
return String(Math.max(0, parsed));
}
export function AgentLineProvisionWizard({
embedded = false,
defaultSiteCode,
@@ -178,7 +204,7 @@ export function AgentLineProvisionWizard({
can_grant_extra_rebate: form.can_grant_extra_rebate,
});
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
setForm((f) => ({
setForm(() => ({
site_code: "",
name: "",
username: "",
@@ -307,6 +333,9 @@ export function AgentLineProvisionWizard({
step="0.01"
value={form.total_share_rate}
onChange={(e) => setForm((f) => ({ ...f, total_share_rate: e.target.value }))}
onBlur={(e) =>
setForm((f) => ({ ...f, total_share_rate: clampPercentInput(e.target.value) }))
}
/>
</div>
<div className="grid gap-2">
@@ -316,6 +345,9 @@ export function AgentLineProvisionWizard({
min={0}
value={form.credit_limit}
onChange={(e) => setForm((f) => ({ ...f, credit_limit: e.target.value }))}
onBlur={(e) =>
setForm((f) => ({ ...f, credit_limit: clampIntegerInput(e.target.value) }))
}
/>
</div>
<div className="grid gap-2">
@@ -328,6 +360,9 @@ export function AgentLineProvisionWizard({
value={form.rebate_limit}
placeholder="50"
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
onBlur={(e) =>
setForm((f) => ({ ...f, rebate_limit: clampPercentInput(e.target.value) }))
}
/>
</div>
<div className="grid gap-2">
@@ -340,6 +375,12 @@ export function AgentLineProvisionWizard({
value={form.default_player_rebate}
placeholder="50"
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
onBlur={(e) =>
setForm((f) => ({
...f,
default_player_rebate: clampPercentInput(e.target.value),
}))
}
/>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import { ChevronRight, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -88,13 +88,11 @@ function collectSearchExpandIds(
}
export type AgentLineSidebarProps = {
siteLabel: string | null;
/** API 返回的嵌套树(含 children */
tree: AgentNodeRow[];
parentNameMap: Map<number, string>;
selectedId: number | null;
keyword: string;
agentCount: number;
loading?: boolean;
onKeywordChange: (value: string) => void;
onSelect: (node: AgentNodeRow) => void;
@@ -187,12 +185,10 @@ function TreeRow({
}
export function AgentLineSidebar({
siteLabel,
tree,
parentNameMap,
selectedId,
keyword,
agentCount,
loading = false,
onKeywordChange,
onSelect,

View File

@@ -40,7 +40,6 @@ export function isAgentLineSubnavTabVisible(
): boolean {
const perms = session?.permissions ?? [];
const isSuper = session?.is_super_admin === true;
const boundAgent = session?.agent ?? null;
if (isSuper) {
return adminHasAnyPermission(perms, tabRequiredSlugs(href));

View File

@@ -4,13 +4,6 @@ import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import { cn } from "@/lib/utils";
@@ -23,9 +16,7 @@ import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import {
AGENT_PERCENT_HARD_MAX,
actualShareRateFromRelative,
creditLimitRangeIssue,
isNumericStepperOutOfRange,
maxCreditLimitFromParent,
maxDefaultRebatePercent,
maxRebatePercentFromParent,
@@ -105,7 +96,12 @@ export function AgentProfileFields({
const maxRebatePercent = maxRebatePercentFromParent(parentCaps);
const maxDefaultRebate = maxDefaultRebatePercent(rebateLimit, parentCaps);
const maxCreditLimit = maxCreditLimitFromParent(parentCaps, baselineCreditLimit);
const actualShare = actualShareRateFromRelative(Number.parseFloat(shareRate) || 0, parentCaps);
const shareRateNumber = Number.parseFloat(shareRate) || 0;
const parentShareRate = parentCaps !== null ? Number(parentCaps.total_share_rate) : null;
const relativeShare =
parentShareRate !== null && Number.isFinite(parentShareRate) && parentShareRate > 0
? Number(((shareRateNumber / parentShareRate) * 100).toFixed(2))
: null;
const creditRangeIssue = creditLimitRangeIssue(creditLimit, {
min: minCreditLimit,
max: maxCreditLimit,
@@ -176,9 +172,7 @@ export function AgentProfileFields({
htmlFor={`${idPrefix}-share-rate`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{parentCaps
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %" })
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" flat={isCard} />
@@ -191,13 +185,12 @@ export function AgentProfileFields({
max={maxSharePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
{showFieldHints && parentCaps ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.relativeShareCapHint", {
defaultValue: "占上级比例最高 100%上级总占成 {{parent}}%",
{t("profile.totalShareParentCapHint", {
defaultValue: "实际占成不可超过上级总占成 {{parent}}%",
parent: parentCaps.total_share_rate,
})}
</p>
@@ -207,11 +200,11 @@ export function AgentProfileFields({
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
</p>
) : null}
{showFieldHints && parentCaps && shareRate && actualShare !== null ? (
{parentCaps && shareRate && relativeShare !== null ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.actualShareRate", {
defaultValue: "实际占成 {{rate}}%",
rate: actualShare,
{t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: relativeShare,
})}
</p>
) : null}
@@ -234,7 +227,6 @@ export function AgentProfileFields({
max={maxCreditLimit}
step={1000}
integer
preserveOverMaxOnBlur
/>
)}
{showFieldHints && parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
@@ -289,7 +281,6 @@ export function AgentProfileFields({
max={maxRebatePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
{parentCaps ? (
@@ -323,7 +314,6 @@ export function AgentProfileFields({
max={maxDefaultRebate}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
<p className="text-xs text-muted-foreground/80">

View File

@@ -182,13 +182,7 @@ export function AgentsConsole(): React.ReactElement {
};
const applyProfileRowToForm = (row: AgentProfileRow) => {
const parentRate = row.parent_caps?.total_share_rate ?? 0;
if (parentRate > 0 && row.total_share_rate != null) {
const relative = (row.total_share_rate / parentRate) * 100;
setProfileShareRate(percentValueToUi(relative));
} else {
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
}
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
setProfileCreditLimit(String(row.credit_limit ?? 0));
setProfileRebateLimit(percentValueToUi(row.rebate_limit ?? 0));
setProfileDefaultRebate(percentValueToUi(row.default_player_rebate ?? 0));
@@ -216,9 +210,6 @@ export function AgentsConsole(): React.ReactElement {
can_create_player: profileCanCreatePlayer,
risk_tags: parseRiskTagsInput(profileRiskTags),
};
if (profileParentCaps) {
return { ...base, relative_share_rate: shareRate };
}
return { ...base, total_share_rate: shareRate };
};
@@ -279,7 +270,7 @@ export function AgentsConsole(): React.ReactElement {
parentCaps: profileParentCaps,
baselineCreditLimit: profileBaselineCreditLimit,
minCreditLimit: profileMinCreditLimit,
usesRelativeShare: profileParentCaps !== null,
usesRelativeShare: false,
validateCredit: isRootProfileEditableByActor(creditNode, isSuperAdmin),
});
@@ -297,16 +288,6 @@ export function AgentsConsole(): React.ReactElement {
);
const visibleAgentRows = flatNodes;
const boundSite = profile?.site ?? null;
const selectedSiteLabel = useMemo(() => {
const fromOptions = siteOptions.find((site) => site.id === adminSiteId)?.name;
if (fromOptions) {
return fromOptions;
}
if (boundSite != null && boundSite.id === adminSiteId) {
return boundSite.name;
}
return null;
}, [adminSiteId, boundSite, siteOptions]);
const rootNode = useMemo(
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
[flatNodes],
@@ -1090,12 +1071,10 @@ export function AgentsConsole(): React.ReactElement {
<div className="flex max-h-[calc(100dvh-14rem)] min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm lg:flex-row">
{showAgentSidebar ? (
<AgentLineSidebar
siteLabel={selectedSiteLabel}
tree={tree}
parentNameMap={parentNameMap}
selectedId={selectedNodeId}
keyword={keyword}
agentCount={visibleAgentRows.length}
loading={loading && visibleAgentRows.length === 0}
onKeywordChange={(value) => {
setKeyword(value);
@@ -1124,7 +1103,6 @@ export function AgentsConsole(): React.ReactElement {
detailTab={detailTab}
onDetailTabChange={setDetailTab}
canViewProfileTab={canViewProfileTab}
canEditProfileTab={canEditSelectedProfile && !isOwnAgentNode}
canSaveProfileTab={canManageProfile && canEditSelectedProfile && !isOwnAgentNode}
canViewDownlineTab={canShowDownlineTab}
canViewPlayersTab={canShowPlayersTab}

View File

@@ -949,7 +949,6 @@ export function AgentsPlayersPanel({
max={maxPlayerRebatePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
<p className="text-[11px] text-muted-foreground/80">
{agentRebateLimitPercent !== null
@@ -1036,7 +1035,6 @@ export function AgentsPlayersPanel({
max={maxPlayerRebatePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
<p className="text-[11px] text-muted-foreground/80">
{agentRebateLimitPercent !== null

View File

@@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
@@ -105,7 +105,7 @@ export function AuditLogsConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
}, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate, tRef]);
useAsyncEffect(() => {
void load();

View File

@@ -1,6 +1,5 @@
"use client";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -29,7 +28,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 { ratioToPercentUi } from "@/lib/admin-rate-percent";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
@@ -94,8 +93,6 @@ type OddsConfigDocScreenProps = {
embedded?: boolean;
/** 合并页:左侧三步骤 + 右侧配置摘要(参考设计稿) */
mergedLayout?: boolean;
/** 合并页第 3 步:佣金 / 回水 */
rebateSection?: ReactNode;
/** 合并页共享数据层(避免与回水区块重复拉取版本详情) */
workspace?: OddsConfigWorkspace;
/** 与回水分区共用版本选择(无 workspace 时) */
@@ -106,7 +103,6 @@ type OddsConfigDocScreenProps = {
export function OddsConfigDocScreen({
embedded = false,
mergedLayout = false,
rebateSection,
workspace,
versionId: controlledVersionId,
onVersionIdChange,
@@ -159,7 +155,7 @@ export function OddsConfigDocScreen({
} finally {
setLoadingTypes(false);
}
}, []);
}, [tRef]);
const refreshList = useCallback(async () => {
setLoadingList(true);
@@ -175,7 +171,7 @@ export function OddsConfigDocScreen({
} finally {
setLoadingList(false);
}
}, []);
}, [tRef]);
useAsyncEffect(() => {
if (workspace) {
@@ -199,7 +195,7 @@ export function OddsConfigDocScreen({
} finally {
setLoadingDetail(false);
}
}, []);
}, [tRef]);
useEffect(() => {
if (workspace) {
@@ -492,7 +488,7 @@ export function OddsConfigDocScreen({
newValue: next?.odds_value ?? null,
};
});
}, [activeCompareRows, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t, i18n.language]);
}, [activeCompareRows, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]);
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },

View File

@@ -181,7 +181,7 @@ export function PlayConfigDocScreen() {
} finally {
setLoadingList(false);
}
}, []);
}, [tRef]);
useAsyncEffect(() => {
void refreshList();
@@ -214,7 +214,7 @@ export function PlayConfigDocScreen() {
setLoadingDetail(false);
}
}
}, []);
}, [tRef]);
useEffect(() => {
if (list.length === 0) {

View File

@@ -40,7 +40,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 { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
@@ -133,7 +133,6 @@ export function RebateConfigDocScreen({
const resolvedList = workspace?.list ?? listRows;
const resolvedDetail = workspace?.detail ?? detail;
const resolvedDraftRows = workspace?.draftRows ?? draftRows;
const setResolvedDraftRows = workspace?.setDraftRows ?? setDraftRows;
const resolvedLoading = workspace ? workspace.loadingList || workspace.loadingTypes : loading;
const resolvedLoadingDetail = workspace?.loadingDetail ?? loadingDetail;
@@ -152,7 +151,7 @@ export function RebateConfigDocScreen({
);
setTypes([]);
}
}, []);
}, [tRef]);
const refreshList = useCallback(async () => {
try {
@@ -164,7 +163,7 @@ export function RebateConfigDocScreen({
);
setListRows([]);
}
}, []);
}, [tRef]);
useAsyncEffect(() => {
if (workspace) {
@@ -239,7 +238,7 @@ export function RebateConfigDocScreen({
} finally {
setLoadingDetail(false);
}
}, []);
}, [tRef]);
useEffect(() => {
if (workspace) {

View File

@@ -70,7 +70,7 @@ export function useOddsConfigWorkspace(
} finally {
setLoadingTypes(false);
}
}, []);
}, [tRef]);
const refreshList = useCallback(async () => {
setLoadingList(true);
@@ -85,7 +85,7 @@ export function useOddsConfigWorkspace(
} finally {
setLoadingList(false);
}
}, []);
}, [tRef]);
const loadDetail = useCallback(
async (id: number) => {
@@ -110,7 +110,7 @@ export function useOddsConfigWorkspace(
}
}
},
[applyDetail],
[applyDetail, tRef],
);
const reloadDetail = useCallback(async () => {

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -82,7 +82,6 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
playOptions,
sparklines,
formatMoney,
formatSignedMoney,
profitScope,
} = analytics;

View File

@@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, BarChart3, ClipboardList, RefreshCw, Shield, TrendingUp, Wallet } from "lucide-react";
@@ -22,7 +22,7 @@ import {
formatDashboardMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -241,7 +241,7 @@ export function DashboardConsole(): ReactElement {
setLoading(false);
setRefreshing(false);
}
}, []);
}, [tRef]);
useAsyncEffect(() => {
void load(false);

View File

@@ -26,7 +26,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { Skeleton } from "@/components/ui/skeleton";
import {

View File

@@ -10,7 +10,7 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";

View File

@@ -132,7 +132,7 @@ export function useDashboardAnalytics({
} finally {
setLoading(false);
}
}, [enabled, period, playCode, customFrom, customTo, scope.agentNodeId, scope.siteCode]);
}, [enabled, period, playCode, customFrom, customTo, scope.agentNodeId, scope.siteCode, tRef]);
useAsyncEffect(() => {
void load();

View File

@@ -8,8 +8,6 @@ import {
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { getAdminDraw } from "@/api/admin-draws";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";

View File

@@ -8,7 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -47,7 +47,7 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, tRef]);
useAsyncEffect(() => {
void load();

View File

@@ -16,7 +16,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,

View File

@@ -85,7 +85,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
return true;
}),
[canManageDraw, canViewDraw, canViewRisk],
[canManageDraw, canViewDraw, canViewFinance, canViewRisk],
);
return (

View File

@@ -8,7 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
@@ -17,7 +17,7 @@ import { Button } from "@/components/ui/button";
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 {
Table,
TableBody,
@@ -66,8 +66,6 @@ function JackpotRecordTableSection({
children: React.ReactNode;
footer: React.ReactNode;
}) {
const { t } = useTranslation("common");
return (
<div className="admin-table-shell">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/70 bg-muted/20 px-4 py-3">
@@ -126,7 +124,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
} finally {
setLoadingP(false);
}
}, [pPage, pPer, appliedDrawNo]);
}, [pPage, pPer, appliedDrawNo, tRef]);
const loadContribs = useCallback(async () => {
setLoadingC(true);
@@ -142,7 +140,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
} finally {
setLoadingC(false);
}
}, [cPage, cPer, appliedDrawNo]);
}, [cPage, cPer, appliedDrawNo, tRef]);
useAsyncEffect(() => {
void loadPayouts();

View File

@@ -146,7 +146,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
} finally {
setPlayerLoading(false);
}
}, [playerId]);
}, [playerId, tRef]);
const loadTickets = useCallback(async () => {
setTicketsLoading(true);

View File

@@ -227,7 +227,7 @@ export function PlayersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query, siteFilter]);
}, [page, perPage, query, siteFilter, tRef]);
useAsyncEffect(() => {
void load();

View File

@@ -9,11 +9,11 @@ import { Download, RefreshCw } from "lucide-react";
import { downloadAdminReportJob, getAdminReportJobs } from "@/api/admin-report-jobs";
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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,

View File

@@ -7,7 +7,8 @@ import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admi
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { drawStatusLabel } from "@/modules/draws/draw-display";
import { drawStatusLabel, settlementBatchStatusLabel } from "@/modules/draws/draw-display";
import { riskActionTypeLabel } from "@/modules/risk/risk-display";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import {
@@ -36,6 +37,18 @@ type ReportPreviewTableProps = {
displayCurrency: string;
};
function reportValueLabel(
scope: string,
value: string | null | undefined,
t: (key: string, options?: { defaultValue?: string }) => string,
): string {
if (value == null || value === "") {
return "—";
}
return t(`labels.${scope}.${value}`, { defaultValue: value });
}
export function ReportPreviewTable({
report,
result,
@@ -135,7 +148,9 @@ export function ReportPreviewTable({
<TableRow key={batch.id} className="bg-muted/10">
<TableCell>#{batch.id}</TableCell>
<TableCell>
<AdminStatusBadge status={batch.status}>{batch.status}</AdminStatusBadge>
<AdminStatusBadge status={batch.status}>
{settlementBatchStatusLabel(batch.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-right">{batch.total_ticket_count}</TableCell>
<TableCell className="text-right">{batch.total_win_count}</TableCell>
@@ -269,9 +284,11 @@ export function ReportPreviewTable({
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">{item.transfer_no}</TableCell>
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
<TableCell>{item.direction}</TableCell>
<TableCell>{reportValueLabel("transferDirection", item.direction, t)}</TableCell>
<TableCell>
<AdminStatusBadge status={item.status}>{item.status}</AdminStatusBadge>
<AdminStatusBadge status={item.status}>
{reportValueLabel("transferStatus", item.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-right">
{item.currency_code} {item.amount}
@@ -333,7 +350,7 @@ export function ReportPreviewTable({
{result.raw.logs.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>{item.action_type}</TableCell>
<TableCell>{riskActionTypeLabel(item.action_type, t)}</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.amount, result.raw.currency_code)}
</TableCell>
@@ -402,11 +419,11 @@ export function ReportPreviewTable({
{result.raw.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>{item.operator_type}</TableCell>
<TableCell>{reportValueLabel("operatorType", item.operator_type, t)}</TableCell>
<TableCell>{item.operator_id}</TableCell>
<TableCell>{item.module_code}</TableCell>
<TableCell>{item.action_code}</TableCell>
<TableCell>{item.target_type || "—"}</TableCell>
<TableCell>{item.module_label || item.module_code}</TableCell>
<TableCell>{item.action_label || item.action_code}</TableCell>
<TableCell>{item.target_label || item.target_type || "—"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell>
</TableRow>
))}
@@ -417,4 +434,4 @@ export function ReportPreviewTable({
default:
return <AdminLoadingInline />;
}
}
}

View File

@@ -71,7 +71,6 @@ import {
drawRowsFromSummary,
emptyFilters,
emptySearch,
formatExportInstant,
formatPlainMoney,
formatUsagePercent,
headlineStats,
@@ -434,6 +433,7 @@ export function ReportsConsole({ initialCategory = "profit" }: ReportsConsolePro
return;
}
void queryReport();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, perPage]);
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {

View File

@@ -19,7 +19,7 @@ import type {
AdminReportPlayerWinLossRow,
} from "@/types/api/admin-reports";
import type { FieldKey, ReportDefinition, ReportKey } from "@/modules/reports/reports-definitions";
import type { ReportDefinition, ReportKey } from "@/modules/reports/reports-definitions";
export type ExportCell = string | number | null;
export type ExportRow = Record<string, ExportCell>;
@@ -128,7 +128,8 @@ export function formatPlainMoney(value: number, currencyCode: string | null | un
return formatAdminMinorUnits(value, currencyCode || "NPR");
}
export function signedProfitCell(amount: number, currencyCode: string | null | undefined): string {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function signedProfitCell(amount: number, _currencyCode: string | null | undefined): string {
return cn("tabular-nums", signedMoneyClass(amount, true));
}
@@ -238,13 +239,9 @@ export function buildDailyProfitRowsAndSummary(
pageScopedLabel: (statKey: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "rows" | "summary"> {
let totalBet = 0;
let totalPayout = 0;
let totalGross = 0;
const rows = items.map((item) => {
totalBet += item.total_bet_minor;
totalPayout += item.total_payout_minor;
totalGross += item.approx_house_gross_minor;
return {
business_date: item.business_date,
@@ -306,11 +303,9 @@ export function buildPlayDimensionRowsAndSummary(
t: (key: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
let totalBet = 0;
let totalGross = 0;
const rows = items.map((item) => {
totalBet += item.total_bet_minor;
totalGross += item.approx_house_gross_minor;
return {
play_code: item.play_code,

View File

@@ -11,7 +11,7 @@ import { AdminListPaginationFooter } from "@/components/admin/admin-list-paginat
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -63,7 +63,7 @@ export function RiskPoolDetailConsole({
} finally {
setLoading(false);
}
}, [drawId, number4d, page, perPage]);
}, [drawId, number4d, page, perPage, tRef]);
useAsyncEffect(() => {
void load();

View File

@@ -102,7 +102,7 @@ export function CurrencySettingsPanel() {
} finally {
setLoading(false);
}
}, [canManage]);
}, [canManage, tRef]);
useAsyncEffect(() => {
void load();

View File

@@ -1,6 +1,6 @@
"use client";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { useTranslation } from "react-i18next";
import { SignedMoney } from "@/lib/admin-signed-money";
@@ -255,8 +255,6 @@ function ReportTable({
columns: { key: string; header: string; money?: boolean; signed?: boolean; creditMajor?: boolean }[];
currencyCode: string;
}): React.ReactElement {
const { t } = useTranslation("common");
if (rows.length === 0) {
return <AdminNoResourceState className="text-sm text-muted-foreground" />;
}

View File

@@ -41,7 +41,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState, AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
@@ -118,7 +118,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
} finally {
setLoading(false);
}
}, [batchId, page, perPage, appliedAgentNodeId]);
}, [batchId, page, perPage, appliedAgentNodeId, tRef]);
async function runAction(label: string, action: () => Promise<unknown>): Promise<void> {
setActing(label);

View File

@@ -47,7 +47,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money";
@@ -130,7 +130,7 @@ export function SettlementBatchesConsole() {
} finally {
setLoading(false);
}
}, [page, perPage, appliedDrawNo, appliedStatus]);
}, [page, perPage, appliedDrawNo, appliedStatus, tRef]);
useAsyncEffect(() => {
void load();

View File

@@ -41,14 +41,9 @@ import { creditLedgerReasonLabel, settlementBillStatusLabel } from "@/modules/se
const REASON_FILTERS = [
"all",
"bet_hold",
"bet_hold_release",
"game_settlement",
"game_settlement_win",
"game_settlement_loss",
"game_settlement_reversal",
"settlement_confirm",
"settlement_payout",
"bet",
"bill_settlement",
"payment_record",
"adjustment",
"reversal",

View File

@@ -52,9 +52,15 @@ export function creditLedgerReasonLabel(
reason: string,
t: TFunction<"settlementCenter">,
): string {
if (reason === "bet") {
return t("creditLedger.reason.bet", { defaultValue: "下注冻结" });
}
if (reason === "game_settlement") {
return t("creditLedger.reason.game_settlement", { defaultValue: "开奖结算" });
}
if (reason === "bill_settlement") {
return t("creditLedger.reason.bill_settlement", { defaultValue: "账期收付" });
}
if (reason === "game_settlement_reversal") {
return t("creditLedger.reason.game_settlement_reversal", { defaultValue: "开奖冲正" });
}

View File

@@ -26,7 +26,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -176,7 +176,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [applied, page, perPage]);
}, [applied, page, perPage, tRef]);
useAsyncEffect(() => {
void load();

View File

@@ -68,7 +68,7 @@ export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
void load();
}, [ticketNo]);
const combinations = data?.combinations ?? [];
const combinations = useMemo(() => data?.combinations ?? [], [data?.combinations]);
const comboLastPage = Math.max(1, Math.ceil(combinations.length / comboPerPage));
const comboPageSafe = Math.min(comboPage, comboLastPage);
const comboSlice = useMemo(() => {

View File

@@ -78,11 +78,14 @@ export function txnFiltersHaveUrlPreset(filters: TxnFilters): boolean {
/** 下拉「不限」值;请求时转为空串不传参 */
export const WALLET_FILTER_ALL = "__all__";
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
/** 与玩家端资金流水公共类型一致;接口会展开到底层 wallet_txns.biz_type */
export const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
{ value: "transfer_in", label: "transferIn" },
{ value: "transfer_out", label: "transferOut" },
{ value: "transfer_out_refund", label: "transferOutRefund" },
{ value: "bet", label: "bizBetDeduct" },
{ value: "prize", label: "bizPrize" },
{ value: "refund", label: "bizRefund" },
{ value: "reversal", label: "bizReversal" },
];
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
@@ -113,4 +116,4 @@ export function walletAdminSelectDisplayedLabel(
}
const key = options.find((o) => o.value === v)?.label;
return key ? (t ? t(key) : key) : v;
}
}

View File

@@ -73,6 +73,7 @@ export type AgentParentCaps = {
export type AgentProfileRow = AgentProfilePayload & {
agent_node_id: number;
relative_share_rate?: number | null;
allocated_credit: number;
used_credit: number;
available_credit: number;