feat(admin, i18n): enhance reports, draws, config, and player workflows

This commit is contained in:
2026-06-08 17:41:55 +08:00
parent af982bb9f7
commit 7e65c53732
55 changed files with 1986 additions and 804 deletions

View File

@@ -51,7 +51,7 @@ export async function getAdminJackpotPoolAdjustments(
export async function postAdminJackpotManualBurst( export async function postAdminJackpotManualBurst(
poolId: number, poolId: number,
body: { draw_id: number }, body: { draw_id: number | string },
): Promise<{ ): Promise<{
current_amount: number; current_amount: number;
burst_amount: number; burst_amount: number;

View File

@@ -15,6 +15,7 @@ const A = `/admin/report-jobs`;
export async function getAdminReportJobs(params?: { export async function getAdminReportJobs(params?: {
page?: number; page?: number;
per_page?: number; per_page?: number;
report_type?: string;
}): Promise<AdminReportJobListData> { }): Promise<AdminReportJobListData> {
return adminRequest.get<AdminReportJobListData>(A, { params }); return adminRequest.get<AdminReportJobListData>(A, { params });
} }

View File

@@ -1,6 +1,10 @@
import { buildPageMetadata } from "@/lib/page-metadata";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { DrawDetailConsole } from "@/modules/draws/draw-detail-console"; import { DrawDetailConsole } from "@/modules/draws/draw-detail-console";
import { PRD_DRAW_ACCESS_ANY } from "@/lib/admin-prd"; import { PRD_DRAW_ACCESS_ANY } from "@/lib/admin-prd";
import type { Metadata } from "next";
export const metadata: Metadata = buildPageMetadata("draws", "drawDetail");
export default async function AdminDrawDetailPage(props: { export default async function AdminDrawDetailPage(props: {
params: Promise<{ drawId: string }>; params: Promise<{ drawId: string }>;

View File

@@ -1,12 +1,7 @@
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata"; import { buildPageMetadata } from "@/lib/page-metadata";
import { ReportsConsole } from "@/modules/reports/reports-console";
import type { Metadata } from "next"; import type { Metadata } from "next";
type Category = "profit" | "wallet" | "risk" | "audit";
export const metadata: Metadata = buildPageMetadata("reports", "title"); export const metadata: Metadata = buildPageMetadata("reports", "title");
export default async function AdminReportsCategoryPage({ export default async function AdminReportsCategoryPage({
@@ -18,9 +13,5 @@ export default async function AdminReportsCategoryPage({
if (!["profit", "wallet", "risk", "audit"].includes(category)) { if (!["profit", "wallet", "risk", "audit"].includes(category)) {
notFound(); notFound();
} }
return ( redirect("/admin/reports");
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
<ReportsConsole initialCategory={category as Category} />
</AdminPermissionGate>
);
} }

View File

@@ -1,13 +1,9 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { ReportsSubnav } from "@/modules/reports/reports-subnav";
export default function AdminReportsLayout({ children }: { children: ReactNode }) { export default function AdminReportsLayout({ children }: { children: ReactNode }) {
return ( return (
<ModuleScaffold> <ModuleScaffold>
<div className="sticky top-14 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<ReportsSubnav />
</div>
{children} {children}
</ModuleScaffold> </ModuleScaffold>
); );

View File

@@ -1,16 +1,5 @@
import { notFound } from "next/navigation"; import { redirect } from "next/navigation";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import { ReportsConsole } from "@/modules/reports/reports-console";
import type { Metadata } from "next";
export const metadata: Metadata = buildPageMetadata("reports", "legacyTitle"); export default function AdminReportsLegacyPage() {
redirect("/admin/reports/profit");
export default function AdminReportsLegacyPage(): React.ReactElement {
return (
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
<ReportsConsole initialCategory="legacy" />
</AdminPermissionGate>
);
} }

View File

@@ -1,5 +1,11 @@
import { redirect } from "next/navigation"; import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
import { ReportsConsole } from "@/modules/reports/reports-console";
export default function AdminReportsPage() { export default function AdminReportsPage() {
redirect("/admin/reports/profit"); return (
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
<ReportsConsole />
</AdminPermissionGate>
);
} }

View File

@@ -40,6 +40,7 @@ export function AdminNoResourceState({
alt="" alt=""
width={compact ? 120 : 160} width={compact ? 120 : 160}
height={compact ? 120 : 160} height={compact ? 120 : 160}
style={{ width: "auto", height: "auto" }}
className={cn( className={cn(
"h-auto w-auto object-contain self-center object-center -mt-4", "h-auto w-auto object-contain self-center object-center -mt-4",
compact ? "max-h-24 max-w-[120px]" : "max-h-40 max-w-[160px]", compact ? "max-h-24 max-w-[120px]" : "max-h-40 max-w-[160px]",

View File

@@ -15,9 +15,9 @@
"risk-cap": "Payout caps" "risk-cap": "Payout caps"
}, },
"rulesPlaysTitle": "Play rules", "rulesPlaysTitle": "Play rules",
"rulesOddsTitle": "Odds & rebate", "rulesOddsTitle": "Odds & base rebate",
"rulesOddsDescription": "Odds matrix and rebate rates on one page, sharing the same odds version line.", "rulesOddsDescription": "Odds matrix and base rebate are maintained on one page, sharing the same odds version line.",
"rulesOddsDescriptionShort": "Pick a play on the left, edit odds and rebate on the right, then save and publish.", "rulesOddsDescriptionShort": "Pick a play on the left, edit odds and base rebate on the right. Agent/player rebate is added on top of this base, then save and publish.",
"riskCapTitle": "Risk cap rules" "riskCapTitle": "Risk cap rules"
}, },
"hub": { "hub": {
@@ -25,8 +25,8 @@
"description": "Jump to play rules, odds & rebate, jackpot, and risk cap by domain. The sidebar provides direct links; this page is an overview.", "description": "Jump to play rules, odds & rebate, jackpot, and risk cap by domain. The sidebar provides direct links; this page is an overview.",
"playsTitle": "Play rules", "playsTitle": "Play rules",
"playsDesc": "Play switches, limits, and rule copy", "playsDesc": "Play switches, limits, and rule copy",
"oddsTitle": "Odds & rebate", "oddsTitle": "Odds & base rebate",
"oddsDesc": "Odds matrix and rebate rates in one version stream", "oddsDesc": "Odds matrix and base rebate in one version stream",
"jackpotTitle": "Jackpot", "jackpotTitle": "Jackpot",
"jackpotDesc": "Pool parameters and ledger records", "jackpotDesc": "Pool parameters and ledger records",
"riskCapTitle": "Risk cap rules", "riskCapTitle": "Risk cap rules",
@@ -351,6 +351,20 @@
"readOnlyDraftHint": "Current version is read-only. Create a draft first.", "readOnlyDraftHint": "Current version is read-only. Create a draft first.",
"batchEnabledCount": "{{enabledCount}}/{{total}} enabled", "batchEnabledCount": "{{enabledCount}}/{{total}} enabled",
"noPlayTypes": "No play types", "noPlayTypes": "No play types",
"filters": {
"sectionTitle": "Filter plays",
"sectionDescription": "Narrow the list first, then use batch switches or row-level edits.",
"keyword": "Search plays",
"keywordPlaceholder": "Filter by play code, display name, or category",
"category": "Category",
"status": "Status",
"allCategories": "All categories",
"allStatuses": "All statuses",
"uncategorized": "Uncategorized",
"reset": "Clear filters",
"empty": "No matching play types",
"groupCount": "{{count}} plays"
},
"actions": { "actions": {
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",
@@ -362,6 +376,13 @@
"en": "English", "en": "English",
"ne": "Nepali" "ne": "Nepali"
}, },
"categories": {
"standard": "Standard",
"attribute": "Attribute",
"position": "Position",
"box": "Box",
"jackpot": "Jackpot"
},
"table": { "table": {
"playCode": "Play code", "playCode": "Play code",
"category": "Category", "category": "Category",
@@ -413,7 +434,7 @@
}, },
"currentSelection": "Selection: {{category}} / {{play}}", "currentSelection": "Selection: {{category}} / {{play}}",
"playSelectPlaceholder": "Select play type", "playSelectPlaceholder": "Select play type",
"readOnlyBanner": "This version is read-only. Create a draft to edit odds and rebate.", "readOnlyBanner": "This version is read-only. Create a draft to edit odds and base rebate.",
"table": { "table": {
"prizeScope": "Prize scope", "prizeScope": "Prize scope",
"multiplier": "Odds multiplier" "multiplier": "Odds multiplier"
@@ -452,11 +473,11 @@
"loadingDetails": "Loading details…", "loadingDetails": "Loading details…",
"multiplier": "Multiplier x{{value}} · {{currency}}", "multiplier": "Multiplier x{{value}} · {{currency}}",
"missingScopeRow": "Missing {{scope}} row. Check seed or version data.", "missingScopeRow": "Missing {{scope}} row. Check seed or version data.",
"rebateRate": "Rebate rate (%)", "rebateRate": "Base rebate rate (%)",
"rebateRateHint": "Writes rebate_rate to all prize scopes under this play type.", "rebateRateHint": "This is the platform base rebate. It writes rebate_rate to all prize scopes under this play type; agent/player rebate is added on top of it.",
"placeholders": { "placeholders": {
"multiplier": "Enter odds multiplier", "multiplier": "Enter odds multiplier",
"rebateRate": "Enter rebate rate" "rebateRate": "Enter base rebate rate"
}, },
"publishFailed": "Publish failed", "publishFailed": "Publish failed",
"createDraftSuccess": "Created draft v{{version}}", "createDraftSuccess": "Created draft v{{version}}",
@@ -481,33 +502,33 @@
} }
}, },
"rebate": { "rebate": {
"sectionHint": "Rebate rates are stored in the odds version; select or create an odds draft in the section above first.", "sectionHint": "This section configures the base rebate, which is stored in the odds version; select or create an odds draft in the section above first.",
"lazyLoadHint": "Scroll down to the rebate section to load", "lazyLoadHint": "Scroll down to the rebate section to load",
"embeddedVersionHint": "Rebate shares the odds version line—switch versions in the Odds section above.", "embeddedVersionHint": "Base rebate shares the odds version line—switch versions in the Odds section above.",
"sheetDescription": "Rebate is stored in the odds draft version and shares the same version set as odds.", "sheetDescription": "Rebate is stored in the odds draft version and shares the same version set as odds.",
"publishLabel": "Publish", "publishLabel": "Publish",
"publishSuccess": "Published odds version with rebate", "publishSuccess": "Published odds version with rebate",
"publishFailed": "Publish failed", "publishFailed": "Publish failed",
"publishDialog": { "publishDialog": {
"title": "Publish rebate/odds version?", "title": "Publish base rebate/odds version?",
"description": "After publish, rebate calculation applies to new tickets.", "description": "After publish, the base rebate applies to new tickets. Agent/player extra rebate is still added on top.",
"confirm": "Confirm publish" "confirm": "Confirm publish"
}, },
"createDraftSuccess": "Created draft v{{version}}", "createDraftSuccess": "Created draft v{{version}}",
"createDraftFailed": "Failed to create draft", "createDraftFailed": "Failed to create draft",
"deleteFailed": "Delete failed", "deleteFailed": "Delete failed",
"editingVersion": "Editing version v{{version}} · {{status}}", "editingVersion": "Editing version v{{version}} · {{status}}",
"readOnlyHint": "Create a draft before editing rebate.", "readOnlyHint": "Create a draft before editing base rebate.",
"dimensionRatesMixedHint": "Rebate rates within the same dimension (2D/3D/4D) are not identical: the three percentage inputs show the first play (alphabetically) that has the primary prize scope; use the table as the source of truth. Bulk inputs will overwrite all plays in that dimension to one rate.", "dimensionRatesMixedHint": "Base rebate within the same dimension (2D/3D/4D) is not identical: the three percentage inputs show the first play (alphabetically) that has the primary prize scope; use the table as the source of truth. Bulk inputs will overwrite all plays in that dimension to one rate.",
"fields": { "fields": {
"d2": "2D rebate rate (%)", "d2": "2D base rebate rate (%)",
"d3": "3D rebate rate (%)", "d3": "3D base rebate rate (%)",
"d4": "4D rebate rate (%)" "d4": "4D base rebate rate (%)"
}, },
"placeholders": { "placeholders": {
"d2": "Enter 2D rebate", "d2": "Enter 2D base rebate",
"d3": "Enter 3D rebate", "d3": "Enter 3D base rebate",
"d4": "Enter 4D rebate" "d4": "Enter 4D base rebate"
}, },
"winEnjoy": { "winEnjoy": {
"label": "Deduct rebate on winning payouts", "label": "Deduct rebate on winning payouts",
@@ -527,6 +548,8 @@
"validation": { "validation": {
"requireAtLeastOne": "At least one cap row is required", "requireAtLeastOne": "At least one cap row is required",
"defaultGreaterThanZero": "Default cap amount must be greater than 0", "defaultGreaterThanZero": "Default cap amount must be greater than 0",
"defaultCannotBindDraw": "Default cap cannot be bound to a specific draw",
"specialGreaterThanZero": "Special cap amount must be greater than 0: {{number}}",
"numberMustBe4Digits": "Number must be 4 digits: {{number}}", "numberMustBe4Digits": "Number must be 4 digits: {{number}}",
"enterValidCapAmount": "Enter a valid cap amount" "enterValidCapAmount": "Enter a valid cap amount"
}, },
@@ -547,14 +570,37 @@
"defaultCap": { "defaultCap": {
"title": "Default cap", "title": "Default cap",
"description": "Numbers without a special cap use this default cap template.", "description": "Numbers without a special cap use this default cap template.",
"fieldLabel": "Cap amount (minor unit)" "fieldLabel": "Cap amount (major unit)"
}, },
"specialCaps": { "specialCaps": {
"title": "Special caps" "title": "Special caps",
"description": "No draw selected means a global number cap. Selecting a draw makes it a draw-specific cap."
},
"scope": {
"global": "Global number",
"drawId": "Draw ID: {{id}}"
},
"groups": {
"globalTitle": "Global number caps",
"globalDescription": "Long-running caps that are not tied to a specific draw. Use these for normal number-level selling limits.",
"globalEmpty": "No global number caps yet.",
"drawTitle": "Draw-specific caps",
"drawDescription": "Only applies to the selected draw. Use these for temporary tightening or relaxing of a number cap.",
"drawEmpty": "No draw-specific caps yet.",
"count": "{{count}} rows"
},
"summary": {
"defaultCap": "Default cap",
"defaultHint": "Any number without a special rule falls back to this value.",
"globalCaps": "Global number caps",
"globalHint": "Long-running rules that do not change with a single draw.",
"drawCaps": "Draw-specific caps",
"drawHint": "Temporary rules that only apply to selected draws."
}, },
"loadingDetails": "Loading details…", "loadingDetails": "Loading details…",
"noDetailRows": "No detail rows.", "noDetailRows": "No detail rows.",
"table": { "table": {
"scope": "Scope",
"number": "Number", "number": "Number",
"capAmount": "Cap amount", "capAmount": "Cap amount",
"used": "Used", "used": "Used",

View File

@@ -30,9 +30,9 @@
"queryDraw": "Search draw", "queryDraw": "Search draw",
"reset": "Reset", "reset": "Reset",
"fuzzyDrawNo": "Fuzzy draw no.", "fuzzyDrawNo": "Fuzzy draw no.",
"viewDetails": "View details", "viewDetails": "View draw details",
"editDraw": { "editDraw": {
"action": "Edit", "action": "Edit draw",
"title": "Edit draw", "title": "Edit draw",
"description": "Draw {{drawNo}} · edit times in {{tz}}", "description": "Draw {{drawNo}} · edit times in {{tz}}",
"drawNoPlaceholder": "Enter draw number, for example 20260526-008", "drawNoPlaceholder": "Enter draw number, for example 20260526-008",
@@ -42,7 +42,7 @@
"failed": "Update failed" "failed": "Update failed"
}, },
"deleteDraw": { "deleteDraw": {
"action": "Delete", "action": "Delete draw",
"title": "Delete draw", "title": "Delete draw",
"description": "Delete draw {{drawNo}}? Only for pending draws with no bets. This cannot be undone.", "description": "Delete draw {{drawNo}}? Only for pending draws with no bets. This cannot be undone.",
"success": "Draw deleted", "success": "Draw deleted",
@@ -57,13 +57,19 @@
"invalidDrawId": "Invalid draw ID", "invalidDrawId": "Invalid draw ID",
"loadFailed": "Failed to load. Check login and API configuration.", "loadFailed": "Failed to load. Check login and API configuration.",
"drawDetail": "Draw details", "drawDetail": "Draw details",
"backToList": "Back to draw list",
"detailSubtitle": "{{date}} · Round {{seq}}", "detailSubtitle": "{{date}} · Round {{seq}}",
"overviewTitle": "Draw overview",
"overviewBetTotal": "Total bet",
"overviewPayoutTotal": "Total payout",
"overviewProfitLoss": "Profit/Loss",
"scheduleTitle": "Schedule", "scheduleTitle": "Schedule",
"resultBatchesTitle": "Result batches", "resultBatchesTitle": "Result batches",
"batchSummaryTotal": "{{count}} batch(es)", "batchSummaryTotal": "{{count}} batch(es)",
"batchSummaryPending": "{{count}} pending", "batchSummaryPending": "{{count}} pending",
"batchSummaryPublished": "{{count}} published", "batchSummaryPublished": "{{count}} published",
"noResultBatchesYet": "No result batches yet.", "noResultBatchesYet": "No result batches yet.",
"reviewQueueHint": "After results are generated, continue in Review & publish.",
"goToReviewTab": "Review & publish", "goToReviewTab": "Review & publish",
"businessDate": "Business date", "businessDate": "Business date",
"sequenceNo": "Sequence no.", "sequenceNo": "Sequence no.",

View File

@@ -3,17 +3,21 @@
"configTitle": "Jackpot pool configuration", "configTitle": "Jackpot pool configuration",
"pageDescription": "Maintain per-currency pool parameters; contribution and payout logs are below.", "pageDescription": "Maintain per-currency pool parameters; contribution and payout logs are below.",
"poolsSectionDescription": "Contribution rate, burst threshold, switch, and manual burst.", "poolsSectionDescription": "Contribution rate, burst threshold, switch, and manual burst.",
"rulesTitle": "Rules",
"rulesJoin": "Only successfully placed lines that meet the minimum participation bet amount contribute to the pool.",
"rulesBurst": "The pool releases when the burst threshold is reached, the forced burst gap is met, or a configured play combo triggers it.",
"rulesManual": "Manual burst is a super-admin fallback only. You can enter either the numeric draw ID or the draw number.",
"recordsSectionTitle": "Contribution & payout logs", "recordsSectionTitle": "Contribution & payout logs",
"recordsSectionDescription": "Filter payout and contribution entries (read-only).", "recordsSectionDescription": "Filter payout and contribution entries (read-only).",
"loadFailed": "Failed to load", "loadFailed": "Failed to load",
"saveSuccess": "Saved", "saveSuccess": "Saved",
"saveFailed": "Save failed", "saveFailed": "Save failed",
"invalidDrawId": "Enter a valid draw number", "invalidDrawId": "Enter a valid draw ID or draw number",
"manualBurstSuccess": "Jackpot burst triggered manually", "manualBurstSuccess": "Jackpot burst triggered manually",
"manualBurstFailed": "Manual burst failed", "manualBurstFailed": "Manual burst failed",
"noPoolData": "No pool data", "noPoolData": "No pool data",
"displayBalance": "Display balance {{amount}}", "displayBalance": "Display balance {{amount}}",
"currentAmount": "Current pool balance (minor unit)", "currentAmount": "Current pool balance (major unit)",
"balanceAdjustmentTitle": "Balance adjustment", "balanceAdjustmentTitle": "Balance adjustment",
"balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.", "balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.",
"adjustmentDirection": "Direction", "adjustmentDirection": "Direction",
@@ -33,13 +37,13 @@
"recentAdjustments": "Recent adjustments", "recentAdjustments": "Recent adjustments",
"contributionRate": "Contribution rate (%)", "contributionRate": "Contribution rate (%)",
"contributionRatePlaceholder": "e.g. 2 = 2%", "contributionRatePlaceholder": "e.g. 2 = 2%",
"triggerThreshold": "Burst threshold (minor unit)", "triggerThreshold": "Burst threshold (major unit)",
"triggerThresholdPlaceholder": "Enter burst threshold", "triggerThresholdPlaceholder": "Enter burst threshold",
"payoutRate": "Burst payout rate (%)", "payoutRate": "Burst payout rate (%)",
"payoutRatePlaceholder": "e.g. 5 = 5%", "payoutRatePlaceholder": "e.g. 5 = 5%",
"forceTriggerGap": "Force burst gap (settled draws)", "forceTriggerGap": "Force burst gap (settled draws)",
"forceTriggerGapPlaceholder": "Enter forced burst gap in draws", "forceTriggerGapPlaceholder": "Enter forced burst gap in draws",
"minBetAmount": "Minimum bet amount (minor unit)", "minBetAmount": "Minimum participation bet amount (major unit)",
"minBetAmountPlaceholder": "Enter minimum bet amount", "minBetAmountPlaceholder": "Enter minimum bet amount",
"comboTriggerPlays": "Combo trigger plays (comma separated)", "comboTriggerPlays": "Combo trigger plays (comma separated)",
"comboTriggerPlaysPlaceholder": "Enter play codes separated by commas, for example straight,ibox", "comboTriggerPlaysPlaceholder": "Enter play codes separated by commas, for example straight,ibox",
@@ -48,8 +52,8 @@
"enabled": "Enabled", "enabled": "Enabled",
"saving": "Saving…", "saving": "Saving…",
"save": "Save", "save": "Save",
"manualBurstDrawId": "Draw ID for manual burst", "manualBurstDrawId": "Draw for manual burst (ID or number)",
"manualBurstHint": "Super admin only. Requires a settled draw with first-prize winners. Pool release follows the configured payout rate.", "manualBurstHint": "Super admin only. You can enter either the numeric draw ID or the draw number. Requires a settled draw with first-prize winners. Pool release follows the configured payout rate.",
"manualBurstConfirmTitle": "Confirm manual jackpot burst?", "manualBurstConfirmTitle": "Confirm manual jackpot burst?",
"manualBurstConfirmDescription": "Jackpot will be split among first-prize winners for draw {{drawId}} using the payout rate. Pool balance will be reduced. This cannot be undone automatically.", "manualBurstConfirmDescription": "Jackpot will be split among first-prize winners for draw {{drawId}} using the payout rate. Pool balance will be reduced. This cannot be undone automatically.",
"processing": "Processing…", "processing": "Processing…",

View File

@@ -7,6 +7,7 @@
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}", "detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
"tabOverview": "Overview", "tabOverview": "Overview",
"tabTickets": "Tickets", "tabTickets": "Tickets",
"ticketTableHint": "This table shows the player's recent tickets. Use the row action to jump into the main ticket list for full context and troubleshooting.",
"tabWalletTxns": "Wallet transactions", "tabWalletTxns": "Wallet transactions",
"tabCreditLedger": "Credit ledger", "tabCreditLedger": "Credit ledger",
"tabTransferOrders": "Transfer orders", "tabTransferOrders": "Transfer orders",

View File

@@ -29,7 +29,7 @@
"createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.", "createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.",
"createSummaryPending": "Choose a complete reconcile date range before creating a job.", "createSummaryPending": "Choose a complete reconcile date range before creating a job.",
"jobsTitle": "Reconcile jobs", "jobsTitle": "Reconcile jobs",
"jobsDesc": "Use the action on the right to open paginated item details.", "jobsDesc": "Use the action on the right to open discrepancy details and paginated results.",
"refresh": "Refresh", "refresh": "Refresh",
"jobNo": "Job no.", "jobNo": "Job no.",
"type": "Type", "type": "Type",
@@ -42,10 +42,18 @@
"createdAt": "Created at", "createdAt": "Created at",
"operate": "Action", "operate": "Action",
"view": "View", "view": "View",
"detailsTitle": "Job details", "viewDetails": "View discrepancy details",
"detailsTitle": "Discrepancy details",
"sideARef": "Lottery ref", "sideARef": "Lottery ref",
"sideBRef": "Main site ref", "sideBRef": "Main site ref",
"differenceAmount": "Difference (cent)", "differenceAmount": "Difference (cent)",
"itemResult": "Check result",
"diagnosis": "Issue summary",
"suggestedAction": "Suggested action",
"processingStatus": "Processing status",
"quickAccess": "Quick access",
"openTransferOrder": "Open transfer order",
"openWalletTxn": "Open wallet ledger",
"detectedAt": "Detected at", "detectedAt": "Detected at",
"noDetails": "No details", "noDetails": "No details",
"playerSearch": "Player (optional)", "playerSearch": "Player (optional)",
@@ -63,5 +71,33 @@
"statusFailed": "Failed", "statusFailed": "Failed",
"itemMismatch": "Mismatch", "itemMismatch": "Mismatch",
"itemMatched": "Matched", "itemMatched": "Matched",
"itemPendingCheck": "Pending check" "itemPendingCheck": "Pending check",
"itemStaleProcessing": "Stale processing",
"itemPendingReconcile": "Pending manual reconcile",
"itemMissingWalletTxn": "Missing wallet ledger",
"itemUnexpectedWalletTxn": "Unexpected wallet ledger",
"itemMissingRefund": "Missing refund ledger",
"itemMissingReversal": "Missing reversal ledger",
"itemResolved": "Resolved",
"itemUnresolved": "Unresolved",
"diagnosisStaleProcessing": "The transfer order has stayed in processing for too long and the system has no final success or failure result.",
"diagnosisPendingReconcile": "The transfer order is marked for manual reconciliation and needs a human-confirmed final result.",
"diagnosisMissingWalletTxn": "The transfer order status moved forward, but the matching lottery wallet ledger entry is missing.",
"diagnosisUnexpectedWalletTxn": "The lottery side contains extra wallet ledger entries that do not match the current transfer status.",
"diagnosisMissingRefund": "The transfer-out failed, but the expected refund ledger entry was not found.",
"diagnosisMissingReversal": "The transfer order was reversed, but the matching reversal ledger entry is missing on the lottery side.",
"diagnosisMatched": "This record is already balanced and needs no further action.",
"diagnosisPendingCheck": "This record still needs manual verification.",
"actionStaleProcessing": "Check whether the main site already debited successfully, then decide whether the lottery side needs a reversal or a compensating entry.",
"actionPendingReconcile": "Open the transfer order first, confirm the main-site outcome, then decide whether to credit, reverse, or close the case.",
"actionMissingWalletTxn": "Open both the transfer order and wallet ledger to confirm whether a compensating wallet entry should be added.",
"actionUnexpectedWalletTxn": "Check for duplicate posting or an incorrect compensation entry, and reverse it if needed.",
"actionMissingRefund": "Confirm whether the main site already refunded the player, then add the lottery-side refund entry or reverse the order.",
"actionMissingReversal": "Confirm the reversal result externally, then add the matching lottery-side reversal ledger entry.",
"actionMatched": "No action needed.",
"actionPendingCheck": "Continue verification with the transfer order and wallet ledger.",
"actionResolved": "This exception has already been handled. Current transfer-order status: {{status}}. Open the transfer order if you want to verify the result.",
"transferStatusSuccess": "Successful",
"transferStatusReversed": "Reversed",
"transferStatusManual": "Case closed"
} }

View File

@@ -35,6 +35,7 @@
"loadFailed": "Failed to load export jobs", "loadFailed": "Failed to load export jobs",
"downloadSuccess": "Downloaded {{jobNo}}", "downloadSuccess": "Downloaded {{jobNo}}",
"downloadFailed": "Download failed", "downloadFailed": "Download failed",
"currentReportHint": "Only export tasks for the currently selected report are shown here.",
"columns": { "columns": {
"jobNo": "Job no.", "jobNo": "Job no.",
"report": "Report", "report": "Report",
@@ -191,6 +192,8 @@
"stats": { "stats": {
"records": "Records", "records": "Records",
"currentPage": "This page", "currentPage": "This page",
"notQueried": "Not queried",
"notSet": "Not set",
"drawNo": "Draw no.", "drawNo": "Draw no.",
"currency": "Currency", "currency": "Currency",
"exportRows": "Export rows", "exportRows": "Export rows",
@@ -225,12 +228,10 @@
"status": "Status", "status": "Status",
"createdAt": "Created at" "createdAt": "Created at"
}, },
"legacyTitle": "Legacy wallet reports",
"categories": { "categories": {
"all": "All", "all": "All",
"profit": "Profit", "profit": "Profit",
"wallet": "Funds", "wallet": "Funds",
"legacy": "Legacy",
"risk": "Risk", "risk": "Risk",
"audit": "Audit" "audit": "Audit"
}, },

View File

@@ -25,6 +25,7 @@
"actualDeduct": "Actual deduct", "actualDeduct": "Actual deduct",
"status": "Status", "status": "Status",
"actions": "Actions", "actions": "Actions",
"viewTicketInList": "View this ticket",
"failReason": "Fail reason", "failReason": "Fail reason",
"winAmount": "Win amount", "winAmount": "Win amount",
"placedAt": "Placed at", "placedAt": "Placed at",

View File

@@ -345,6 +345,13 @@
"en": "English", "en": "English",
"ne": "नेपाली" "ne": "नेपाली"
}, },
"categories": {
"standard": "मानक",
"attribute": "विशेषता",
"position": "स्थिति",
"box": "बक्स",
"jackpot": "ज्याकपोट"
},
"table": { "table": {
"playCode": "खेल कोड", "playCode": "खेल कोड",
"category": "श्रेणी", "category": "श्रेणी",
@@ -510,6 +517,8 @@
"validation": { "validation": {
"requireAtLeastOne": "कम्तीमा एक क्याप row आवश्यक छ", "requireAtLeastOne": "कम्तीमा एक क्याप row आवश्यक छ",
"defaultGreaterThanZero": "पूर्वनिर्धारित क्याप रकम 0 भन्दा ठूलो हुनुपर्छ", "defaultGreaterThanZero": "पूर्वनिर्धारित क्याप रकम 0 भन्दा ठूलो हुनुपर्छ",
"defaultCannotBindDraw": "पूर्वनिर्धारित क्याप कुनै निश्चित draw मा बाँध्न मिल्दैन",
"specialGreaterThanZero": "विशेष क्याप रकम 0 भन्दा ठूलो हुनुपर्छ: {{number}}",
"numberMustBe4Digits": "नम्बर 4 अङ्कको हुनुपर्छ: {{number}}", "numberMustBe4Digits": "नम्बर 4 अङ्कको हुनुपर्छ: {{number}}",
"enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्" "enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्"
}, },
@@ -530,14 +539,37 @@
"defaultCap": { "defaultCap": {
"title": "पूर्वनिर्धारित क्याप", "title": "पूर्वनिर्धारित क्याप",
"description": "विशेष क्याप नभएका नम्बरहरूमा यही पूर्वनिर्धारित क्याप टेम्प्लेट लागू हुन्छ।", "description": "विशेष क्याप नभएका नम्बरहरूमा यही पूर्वनिर्धारित क्याप टेम्प्लेट लागू हुन्छ।",
"fieldLabel": "क्याप रकम (सानो एकाइ)" "fieldLabel": "क्याप रकम (मुख्य एकाइ)"
}, },
"specialCaps": { "specialCaps": {
"title": "विशेष क्यापहरू" "title": "विशेष क्यापहरू",
"description": "draw नछानेमा ग्लोबल नम्बर क्याप हुन्छ; draw छानेपछि त्यो सोही draw को विशेष क्याप हुन्छ।"
},
"scope": {
"global": "ग्लोबल नम्बर",
"drawId": "Draw ID: {{id}}"
},
"groups": {
"globalTitle": "ग्लोबल नम्बर क्याप",
"globalDescription": "कुनै निश्चित draw मा नबाँधिने, दीर्घकालीन रूपमा लागू हुने क्याप। सामान्य नम्बर-स्तर सीमा यहीं राखिन्छ।",
"globalEmpty": "अहिलेसम्म कुनै ग्लोबल नम्बर क्याप छैन।",
"drawTitle": "Draw-विशेष क्याप",
"drawDescription": "छानिएको draw मा मात्र लागू हुन्छ। कुनै draw का लागि अस्थायी रूपमा सीमा कडा वा खुकुलो बनाउन प्रयोग गर्नुहोस्।",
"drawEmpty": "अहिलेसम्म कुनै draw-विशेष क्याप छैन।",
"count": "{{count}} वटा"
},
"summary": {
"defaultCap": "पूर्वनिर्धारित क्याप",
"defaultHint": "विशेष नियम नभएका नम्बरहरू अन्ततः यही मानमा फर्किन्छन्।",
"globalCaps": "ग्लोबल नम्बर क्याप",
"globalHint": "दीर्घकालीन नियम, कुनै एक draw सँग मात्र जोडिएको हुँदैन।",
"drawCaps": "Draw-विशेष क्याप",
"drawHint": "छानिएका draw हरूमा मात्र अस्थायी रूपमा लागू हुने नियम।"
}, },
"loadingDetails": "विवरण लोड हुँदैछ…", "loadingDetails": "विवरण लोड हुँदैछ…",
"noDetailRows": "विवरण row छैन।", "noDetailRows": "विवरण row छैन।",
"table": { "table": {
"scope": "दायरा",
"number": "नम्बर", "number": "नम्बर",
"capAmount": "क्याप रकम", "capAmount": "क्याप रकम",
"used": "प्रयोग भएको", "used": "प्रयोग भएको",

View File

@@ -3,17 +3,21 @@
"configTitle": "Jackpot पूल कन्फिगरेसन", "configTitle": "Jackpot पूल कन्फिगरेसन",
"pageDescription": "मुद्रा अनुसार पूल प्यारामिटर; तल योगदान र पेआउट लग देख्नुहोस्।", "pageDescription": "मुद्रा अनुसार पूल प्यारामिटर; तल योगदान र पेआउट लग देख्नुहोस्।",
"poolsSectionDescription": "योगदान दर, बर्स्ट थ्रेसहोल्ड, स्विच र म्यानुअल बर्स्ट।", "poolsSectionDescription": "योगदान दर, बर्स्ट थ्रेसहोल्ड, स्विच र म्यानुअल बर्स्ट।",
"rulesTitle": "नियम जानकारी",
"rulesJoin": "सफलतापूर्वक पेश भएका र न्यूनतम सहभागिता बेट रकम पुगेका लाइनहरू मात्र पूलमा योगदान गर्छन्।",
"rulesBurst": "थ्रेसहोल्ड पुगेपछि, जबर्जस्ती बर्स्ट अन्तर पुगेपछि, वा तोकिएको कम्बो ट्रिगर भएपछि पूल रिलिज हुन्छ।",
"rulesManual": "म्यानुअल बर्स्ट सुपर एडमिनको fallback मात्र हो। यसमा संख्यात्मक draw ID वा draw number दुवै लेख्न सकिन्छ।",
"recordsSectionTitle": "योगदान र पेआउट लग", "recordsSectionTitle": "योगदान र पेआउट लग",
"recordsSectionDescription": "पेआउट र योगदान प्रविष्टि फिल्टर (पढ्न मात्र)।", "recordsSectionDescription": "पेआउट र योगदान प्रविष्टि फिल्टर (पढ्न मात्र)।",
"loadFailed": "लोड असफल भयो", "loadFailed": "लोड असफल भयो",
"saveSuccess": "सुरक्षित भयो", "saveSuccess": "सुरक्षित भयो",
"saveFailed": "सुरक्षित गर्न असफल", "saveFailed": "सुरक्षित गर्न असफल",
"invalidDrawId": "मान्य ड्रअ नम्बर लेख्नुहोस्", "invalidDrawId": "मान्य draw ID वा draw number लेख्नुहोस्",
"manualBurstSuccess": "Jackpot म्यानुअल रूपमा ट्रिगर भयो", "manualBurstSuccess": "Jackpot म्यानुअल रूपमा ट्रिगर भयो",
"manualBurstFailed": "म्यानुअल बर्स्ट असफल भयो", "manualBurstFailed": "म्यानुअल बर्स्ट असफल भयो",
"noPoolData": "पूल डाटा छैन", "noPoolData": "पूल डाटा छैन",
"displayBalance": "प्रदर्शित ब्यालेन्स {{amount}}", "displayBalance": "प्रदर्शित ब्यालेन्स {{amount}}",
"currentAmount": "हालको पूल ब्यालेन्स (सानो एकाइ)", "currentAmount": "हालको पूल ब्यालेन्स (मुख्य एकाइ)",
"balanceAdjustmentTitle": "ब्यालेन्स समायोजन", "balanceAdjustmentTitle": "ब्यालेन्स समायोजन",
"balanceAdjustmentHint": "कारण अनिवार्य; प्रत्येक परिवर्तन समायोजन लेजरमा लेखिन्छ। Save बाट सिधै ब्यालेन्स मिलाउन मिल्दैन।", "balanceAdjustmentHint": "कारण अनिवार्य; प्रत्येक परिवर्तन समायोजन लेजरमा लेखिन्छ। Save बाट सिधै ब्यालेन्स मिलाउन मिल्दैन।",
"adjustmentDirection": "दिशा", "adjustmentDirection": "दिशा",
@@ -33,13 +37,13 @@
"recentAdjustments": "भर्खरका समायोजन", "recentAdjustments": "भर्खरका समायोजन",
"contributionRate": "योगदान अनुपात 0-1", "contributionRate": "योगदान अनुपात 0-1",
"contributionRatePlaceholder": "योगदान अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.02", "contributionRatePlaceholder": "योगदान अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.02",
"triggerThreshold": "बर्स्ट थ्रेसहोल्ड (सानो एकाइ)", "triggerThreshold": "बर्स्ट थ्रेसहोल्ड (मुख्य एकाइ)",
"triggerThresholdPlaceholder": "ट्रिगर थ्रेसहोल्ड प्रविष्ट गर्नुहोस्", "triggerThresholdPlaceholder": "ट्रिगर थ्रेसहोल्ड प्रविष्ट गर्नुहोस्",
"payoutRate": "बर्स्ट भुक्तानी अनुपात 0-1", "payoutRate": "बर्स्ट भुक्तानी अनुपात 0-1",
"payoutRatePlaceholder": "पेआउट अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.05", "payoutRatePlaceholder": "पेआउट अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.05",
"forceTriggerGap": "बलपूर्वक बर्स्ट अन्तर (सेटल ड्रअ)", "forceTriggerGap": "बलपूर्वक बर्स्ट अन्तर (सेटल ड्रअ)",
"forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्", "forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्",
"minBetAmount": "न्यूनतम बेट रकम (सानो एकाइ)", "minBetAmount": "न्यूनतम सहभागिता बेट रकम (मुख्य एकाइ)",
"minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्", "minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्",
"comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)", "comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)",
"comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox", "comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox",
@@ -48,8 +52,8 @@
"enabled": "खुला", "enabled": "खुला",
"saving": "सुरक्षित हुँदैछ…", "saving": "सुरक्षित हुँदैछ…",
"save": "सुरक्षित गर्नुहोस्", "save": "सुरक्षित गर्नुहोस्",
"manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ ID", "manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ (ID वा नम्बर)",
"manualBurstHint": "सुपर एडमिन मात्र। बसेको ड्रअ र प्रथम पुरस्कार विजेताहरू चाहिन्छ। पेआउट दर अनुसार वितरण हुन्छ।", "manualBurstHint": "सुपर एडमिन मात्र। संख्यात्मक draw ID वा draw number दुवै लेख्न सकिन्छ। बसेको ड्रअ र प्रथम पुरस्कार विजेताहरू चाहिन्छ। पेआउट दर अनुसार वितरण हुन्छ।",
"manualBurstConfirmTitle": "म्यानुअल बर्स्ट पुष्टि गर्ने?", "manualBurstConfirmTitle": "म्यानुअल बर्स्ट पुष्टि गर्ने?",
"manualBurstConfirmDescription": "ड्रअ {{drawId}} का प्रथम पुरस्कार विजेताहरूलाई Jackpot वितरण गरिनेछ।", "manualBurstConfirmDescription": "ड्रअ {{drawId}} का प्रथम पुरस्कार विजेताहरूलाई Jackpot वितरण गरिनेछ।",
"processing": "प्रक्रियामा…", "processing": "प्रक्रियामा…",

View File

@@ -168,7 +168,7 @@
"draws": "期号列表", "draws": "期号列表",
"config": "运营配置", "config": "运营配置",
"rules_plays": "投注规则", "rules_plays": "投注规则",
"rules_odds": "赔率与回水", "rules_odds": "赔率与基础回水",
"risk_cap": "限额版本", "risk_cap": "限额版本",
"risk": "风控", "risk": "风控",
"settlement": "结算", "settlement": "结算",

View File

@@ -150,7 +150,7 @@
"reports": "报表中心", "reports": "报表中心",
"draws": "期号列表", "draws": "期号列表",
"rules_plays": "投注规则", "rules_plays": "投注规则",
"rules_odds": "赔率与回水", "rules_odds": "赔率与基础回水",
"rules": "投注规则", "rules": "投注规则",
"risk_cap": "限额版本", "risk_cap": "限额版本",
"risk": "风控中心", "risk": "风控中心",

View File

@@ -15,9 +15,9 @@
"risk-cap": "赔付封顶" "risk-cap": "赔付封顶"
}, },
"rulesPlaysTitle": "投注规则", "rulesPlaysTitle": "投注规则",
"rulesOddsTitle": "赔率与回水", "rulesOddsTitle": "赔率与基础回水",
"rulesOddsDescription": "赔率矩阵与回水比例在同一页维护,共用赔率版本线。", "rulesOddsDescription": "赔率矩阵与基础回水在同一页维护,共用赔率版本线。",
"rulesOddsDescriptionShort": "左侧选玩法,右侧改赔率与回水;修改后记得保存草稿并发布。", "rulesOddsDescriptionShort": "左侧选玩法,右侧改赔率与基础回水;代理/玩家回水需在此基础上叠加,修改后记得保存草稿并发布。",
"riskCapTitle": "限额版本" "riskCapTitle": "限额版本"
}, },
"hub": { "hub": {
@@ -25,8 +25,8 @@
"description": "按业务域进入玩法、赔率回水、奖池与限额配置;接入站点在侧栏「平台管理 → 接入配置」。", "description": "按业务域进入玩法、赔率回水、奖池与限额配置;接入站点在侧栏「平台管理 → 接入配置」。",
"playsTitle": "投注规则", "playsTitle": "投注规则",
"playsDesc": "玩法开关、限额与规则说明", "playsDesc": "玩法开关、限额与规则说明",
"oddsTitle": "赔率与回水", "oddsTitle": "赔率与基础回水",
"oddsDesc": "赔率矩阵与回水比例,版本一体发布", "oddsDesc": "赔率矩阵与基础回水,版本一体发布",
"jackpotTitle": "奖池", "jackpotTitle": "奖池",
"jackpotDesc": "奖池参数与进账流水", "jackpotDesc": "奖池参数与进账流水",
"riskCapTitle": "限额版本", "riskCapTitle": "限额版本",
@@ -360,6 +360,20 @@
"readOnlyDraftHint": "当前版本为只读,请先创建草稿。", "readOnlyDraftHint": "当前版本为只读,请先创建草稿。",
"batchEnabledCount": "{{enabledCount}}/{{total}} 已开启", "batchEnabledCount": "{{enabledCount}}/{{total}} 已开启",
"noPlayTypes": "暂无玩法", "noPlayTypes": "暂无玩法",
"filters": {
"sectionTitle": "筛选玩法",
"sectionDescription": "先缩小范围,再进行批量开关或逐项修改。",
"keyword": "搜索玩法",
"keywordPlaceholder": "按玩法编码、显示名或分类筛选",
"category": "分类",
"status": "状态",
"allCategories": "全部分类",
"allStatuses": "全部状态",
"uncategorized": "未分类",
"reset": "清空筛选",
"empty": "没有匹配的玩法",
"groupCount": "{{count}} 个玩法"
},
"actions": { "actions": {
"enable": "开启", "enable": "开启",
"disable": "关闭", "disable": "关闭",
@@ -371,6 +385,13 @@
"en": "English", "en": "English",
"ne": "नेपाली" "ne": "नेपाली"
}, },
"categories": {
"standard": "标准类",
"attribute": "属性类",
"position": "位置类",
"box": "包号类",
"jackpot": "奖池类"
},
"table": { "table": {
"playCode": "玩法编码", "playCode": "玩法编码",
"category": "分类", "category": "分类",
@@ -422,7 +443,7 @@
}, },
"currentSelection": "当前选择:{{category}} / {{play}}", "currentSelection": "当前选择:{{category}} / {{play}}",
"playSelectPlaceholder": "选择玩法", "playSelectPlaceholder": "选择玩法",
"readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与回水。", "readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与基础回水。",
"table": { "table": {
"prizeScope": "奖级范围", "prizeScope": "奖级范围",
"multiplier": "赔率倍数" "multiplier": "赔率倍数"
@@ -461,11 +482,11 @@
"loadingDetails": "正在加载详情…", "loadingDetails": "正在加载详情…",
"multiplier": "倍数 x{{value}} · {{currency}}", "multiplier": "倍数 x{{value}} · {{currency}}",
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。", "missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
"rebateRate": "回水比例 (%)", "rebateRate": "基础回水比例 (%)",
"rebateRateHint": "会把 rebate_rate 写入该玩法下所有奖级范围。", "rebateRateHint": "这里维护的是平台基础回水,会把 rebate_rate 写入该玩法下所有奖级范围;代理/玩家回水需在此基础上叠加。",
"placeholders": { "placeholders": {
"multiplier": "请输入赔率倍数", "multiplier": "请输入赔率倍数",
"rebateRate": "请输入返点比例" "rebateRate": "请输入基础回水比例"
}, },
"publishFailed": "发布失败", "publishFailed": "发布失败",
"createDraftSuccess": "已创建草稿 v{{version}}", "createDraftSuccess": "已创建草稿 v{{version}}",
@@ -490,33 +511,33 @@
} }
}, },
"rebate": { "rebate": {
"sectionHint": "回水比例写入赔率版本;请先在上方选择或创建赔率草稿。", "sectionHint": "这里配置的是基础回水,写入赔率版本;请先在上方选择或创建赔率草稿。",
"lazyLoadHint": "向下滚动至回水区域后加载", "lazyLoadHint": "向下滚动至回水区域后加载",
"embeddedVersionHint": "回水与上方赔率共用版本线,请在「赔率」区块切换版本。", "embeddedVersionHint": "基础回水与上方赔率共用版本线,请在「赔率」区块切换版本。",
"sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。", "sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。",
"publishLabel": "发布", "publishLabel": "发布",
"publishSuccess": "已发布带回水的赔率版本", "publishSuccess": "已发布带回水的赔率版本",
"publishFailed": "发布失败", "publishFailed": "发布失败",
"publishDialog": { "publishDialog": {
"title": "确认发布回水/赔率版本?", "title": "确认发布基础回水/赔率版本?",
"description": "发布后将影响后续新注单的回水计算。", "description": "发布后将影响后续新注单的基础回水计算;代理/玩家额外回水仍在此基础上叠加。",
"confirm": "确认发布" "confirm": "确认发布"
}, },
"createDraftSuccess": "已创建草稿 v{{version}}", "createDraftSuccess": "已创建草稿 v{{version}}",
"createDraftFailed": "创建草稿失败", "createDraftFailed": "创建草稿失败",
"deleteFailed": "删除失败", "deleteFailed": "删除失败",
"editingVersion": "当前编辑版本 v{{version}} · {{status}}", "editingVersion": "当前编辑版本 v{{version}} · {{status}}",
"readOnlyHint": "修改回水前请先创建草稿。", "readOnlyHint": "修改基础回水前请先创建草稿。",
"dimensionRatesMixedHint": "检测到同一维度2D/3D/4D内各玩法的首奖级回水比例不完全相同:上方三个百分比输入仅展示按玩法编码排序后的第一个有值示例,实际回水请以下方表格各行数据为准;使用批量输入会先按维度覆盖为同一比例。", "dimensionRatesMixedHint": "检测到同一维度2D/3D/4D内各玩法的首奖级基础回水不完全相同:上方三个百分比输入仅展示按玩法编码排序后的第一个有值示例,实际基础回水请以下方表格各行数据为准;使用批量输入会先按维度覆盖为同一比例。",
"fields": { "fields": {
"d2": "2D 回水比例 (%)", "d2": "2D 基础回水比例 (%)",
"d3": "3D 回水比例 (%)", "d3": "3D 基础回水比例 (%)",
"d4": "4D 回水比例 (%)" "d4": "4D 基础回水比例 (%)"
}, },
"placeholders": { "placeholders": {
"d2": "请输入 2D 返点", "d2": "请输入 2D 基础回水",
"d3": "请输入 3D 返点", "d3": "请输入 3D 基础回水",
"d4": "请输入 4D 返点" "d4": "请输入 4D 基础回水"
}, },
"winEnjoy": { "winEnjoy": {
"label": "中奖注单结算时再扣回水", "label": "中奖注单结算时再扣回水",
@@ -536,6 +557,8 @@
"validation": { "validation": {
"requireAtLeastOne": "至少需要一条封顶配置", "requireAtLeastOne": "至少需要一条封顶配置",
"defaultGreaterThanZero": "默认封顶金额必须大于 0", "defaultGreaterThanZero": "默认封顶金额必须大于 0",
"defaultCannotBindDraw": "默认封顶不能绑定具体期号",
"specialGreaterThanZero": "特殊封顶金额必须大于 0{{number}}",
"numberMustBe4Digits": "号码必须为 4 位数字:{{number}}", "numberMustBe4Digits": "号码必须为 4 位数字:{{number}}",
"enterValidCapAmount": "请输入有效的封顶金额" "enterValidCapAmount": "请输入有效的封顶金额"
}, },
@@ -556,14 +579,37 @@
"defaultCap": { "defaultCap": {
"title": "默认封顶", "title": "默认封顶",
"description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。", "description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。",
"fieldLabel": "封顶金额(最小单位)" "fieldLabel": "封顶金额(主币单位)"
}, },
"specialCaps": { "specialCaps": {
"title": "特殊封顶" "title": "特殊封顶",
"description": "不选期号时表示全局号码限额;选择期号后表示该期单独限额。"
},
"scope": {
"global": "全局号码",
"drawId": "期号 ID{{id}}"
},
"groups": {
"globalTitle": "全局号码限额",
"globalDescription": "长期生效,不绑定具体期号。适合配置某个号码的常规售卖上限。",
"globalEmpty": "暂无全局号码限额。",
"drawTitle": "期号单独限额",
"drawDescription": "仅对所选期号生效。适合某一期临时收紧或放宽某个号码的限额。",
"drawEmpty": "暂无期号单独限额。",
"count": "{{count}} 条"
},
"summary": {
"defaultCap": "默认封顶",
"defaultHint": "未命中特殊配置的号码,统一回落到这里。",
"globalCaps": "全局号码限额",
"globalHint": "长期规则,不跟随单一期号变化。",
"drawCaps": "期号单独限额",
"drawHint": "仅在指定期号内临时生效。"
}, },
"loadingDetails": "正在加载详情…", "loadingDetails": "正在加载详情…",
"noDetailRows": "暂无明细行。", "noDetailRows": "暂无明细行。",
"table": { "table": {
"scope": "作用范围",
"number": "号码", "number": "号码",
"capAmount": "封顶金额", "capAmount": "封顶金额",
"used": "已占用", "used": "已占用",

View File

@@ -30,9 +30,9 @@
"queryDraw": "查询期号", "queryDraw": "查询期号",
"reset": "重置", "reset": "重置",
"fuzzyDrawNo": "模糊匹配期号", "fuzzyDrawNo": "模糊匹配期号",
"viewDetails": "查看详情", "viewDetails": "查看期号详情",
"editDraw": { "editDraw": {
"action": "编辑", "action": "编辑期号",
"title": "编辑期号", "title": "编辑期号",
"description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑", "description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑",
"drawNoPlaceholder": "请输入期号,如 20260526-008", "drawNoPlaceholder": "请输入期号,如 20260526-008",
@@ -42,7 +42,7 @@
"failed": "更新失败" "failed": "更新失败"
}, },
"deleteDraw": { "deleteDraw": {
"action": "删除", "action": "删除期号",
"title": "删除期号", "title": "删除期号",
"description": "确定删除期号 {{drawNo}}?仅适用于未开始且无注单的记录,删除后不可恢复。", "description": "确定删除期号 {{drawNo}}?仅适用于未开始且无注单的记录,删除后不可恢复。",
"success": "期号已删除", "success": "期号已删除",
@@ -56,14 +56,20 @@
"listActionsHint": "未开始且无注单:可编辑、删除;可下注/封盘/待开奖且无注单:可取消(见详情页更多操作)。", "listActionsHint": "未开始且无注单:可编辑、删除;可下注/封盘/待开奖且无注单:可取消(见详情页更多操作)。",
"invalidDrawId": "无效的期号 ID", "invalidDrawId": "无效的期号 ID",
"loadFailed": "加载失败,请检查登录与 API 配置", "loadFailed": "加载失败,请检查登录与 API 配置",
"drawDetail": "开奖详情", "drawDetail": "期号详情",
"backToList": "返回期号列表",
"detailSubtitle": "{{date}} · 第 {{seq}} 期", "detailSubtitle": "{{date}} · 第 {{seq}} 期",
"overviewTitle": "期号概览",
"overviewBetTotal": "下注总额",
"overviewPayoutTotal": "派彩总额",
"overviewProfitLoss": "盈亏",
"scheduleTitle": "时间安排", "scheduleTitle": "时间安排",
"resultBatchesTitle": "开奖批次", "resultBatchesTitle": "开奖批次",
"batchSummaryTotal": "共 {{count}} 批", "batchSummaryTotal": "共 {{count}} 批",
"batchSummaryPending": "待审 {{count}}", "batchSummaryPending": "待审 {{count}}",
"batchSummaryPublished": "已发 {{count}}", "batchSummaryPublished": "已发 {{count}}",
"noResultBatchesYet": "尚无开奖批次。", "noResultBatchesYet": "尚无开奖批次。",
"reviewQueueHint": "结果生成后,可前往审核与发布处理。",
"goToReviewTab": "去审核与发布", "goToReviewTab": "去审核与发布",
"businessDate": "业务日", "businessDate": "业务日",
"sequenceNo": "流水序号", "sequenceNo": "流水序号",

View File

@@ -3,17 +3,21 @@
"configTitle": "奖池配置", "configTitle": "奖池配置",
"pageDescription": "维护各币种奖池参数,下方可查询蓄水与派彩流水。", "pageDescription": "维护各币种奖池参数,下方可查询蓄水与派彩流水。",
"poolsSectionDescription": "蓄水比例、爆池阈值、开关与手动爆池。", "poolsSectionDescription": "蓄水比例、爆池阈值、开关与手动爆池。",
"rulesTitle": "规则说明",
"rulesJoin": "只有提交成功且满足最低参与下注额的注项,才会按蓄水比例进入奖池。",
"rulesBurst": "奖池会在达到爆池阈值、达到强制爆池间隔,或命中指定组合触发玩法时释放。",
"rulesManual": "手动爆池仅限超管兜底使用,可填写后台期号数字 ID 或期号编码。",
"recordsSectionTitle": "蓄水与派彩流水", "recordsSectionTitle": "蓄水与派彩流水",
"recordsSectionDescription": "按条件筛选派彩记录与蓄水明细,只读查询。", "recordsSectionDescription": "按条件筛选派彩记录与蓄水明细,只读查询。",
"loadFailed": "加载失败", "loadFailed": "加载失败",
"saveSuccess": "已保存", "saveSuccess": "已保存",
"saveFailed": "保存失败", "saveFailed": "保存失败",
"invalidDrawId": "请填写有效的期号 ID", "invalidDrawId": "请填写有效的期号 ID 或期号编码",
"manualBurstSuccess": "已手动触发爆池", "manualBurstSuccess": "已手动触发爆池",
"manualBurstFailed": "手动爆池失败", "manualBurstFailed": "手动爆池失败",
"noPoolData": "暂无奖池数据", "noPoolData": "暂无奖池数据",
"displayBalance": "展示余额 {{amount}}", "displayBalance": "展示余额 {{amount}}",
"currentAmount": "当前池余额(最小单位)", "currentAmount": "当前池余额(主币单位)",
"balanceAdjustmentTitle": "余额调整", "balanceAdjustmentTitle": "余额调整",
"balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。", "balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。",
"adjustmentDirection": "方向", "adjustmentDirection": "方向",
@@ -33,13 +37,13 @@
"recentAdjustments": "最近调整记录", "recentAdjustments": "最近调整记录",
"contributionRate": "蓄水比例 (%)", "contributionRate": "蓄水比例 (%)",
"contributionRatePlaceholder": "如 2 表示 2%", "contributionRatePlaceholder": "如 2 表示 2%",
"triggerThreshold": "爆池阈值(最小单位)", "triggerThreshold": "爆池阈值(主币单位)",
"triggerThresholdPlaceholder": "请输入触发阈值", "triggerThresholdPlaceholder": "请输入触发阈值",
"payoutRate": "爆池派彩比例 (%)", "payoutRate": "爆池派彩比例 (%)",
"payoutRatePlaceholder": "如 5 表示 5%", "payoutRatePlaceholder": "如 5 表示 5%",
"forceTriggerGap": "强制爆池间隔(已结算期数)", "forceTriggerGap": "强制爆池间隔(已结算期数)",
"forceTriggerGapPlaceholder": "请输入强制触发间隔期数", "forceTriggerGapPlaceholder": "请输入强制触发间隔期数",
"minBetAmount": "最低下注额(最小单位)", "minBetAmount": "最低参与下注额(主币单位)",
"minBetAmountPlaceholder": "请输入最低下注金额", "minBetAmountPlaceholder": "请输入最低下注金额",
"comboTriggerPlays": "组合触发玩法(逗号分隔)", "comboTriggerPlays": "组合触发玩法(逗号分隔)",
"comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox", "comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox",
@@ -50,8 +54,8 @@
"save": "保存", "save": "保存",
"confirmSavePoolTitle": "确认保存奖池配置?", "confirmSavePoolTitle": "确认保存奖池配置?",
"confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含池余额);余额请使用「余额调整」。", "confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含池余额);余额请使用「余额调整」。",
"manualBurstDrawId": "手动爆池期号 ID", "manualBurstDrawId": "手动爆池期号ID 或编码)",
"manualBurstHint": "仅超级管理员可在紧急情况下触发;须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。", "manualBurstHint": "仅超级管理员可在紧急情况下触发;可填写后台期号数字 ID 或期号编码。须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。",
"manualBurstConfirmTitle": "确认手动爆池?", "manualBurstConfirmTitle": "确认手动爆池?",
"manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot并扣减奖池余额。此操作不可自动撤销。", "manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot并扣减奖池余额。此操作不可自动撤销。",
"processing": "处理中…", "processing": "处理中…",

View File

@@ -7,6 +7,7 @@
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}", "detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
"tabOverview": "概览", "tabOverview": "概览",
"tabTickets": "注单", "tabTickets": "注单",
"ticketTableHint": "这里展示该玩家最近注单;如需查看完整上下文,可通过行内操作跳到总注单列表继续排查。",
"tabWalletTxns": "钱包流水", "tabWalletTxns": "钱包流水",
"tabCreditLedger": "信用流水", "tabCreditLedger": "信用流水",
"tabTransferOrders": "转账单", "tabTransferOrders": "转账单",

View File

@@ -29,7 +29,7 @@
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。", "createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
"createSummaryPending": "请选择完整的对账日期范围后,再创建任务。", "createSummaryPending": "请选择完整的对账日期范围后,再创建任务。",
"jobsTitle": "对账任务", "jobsTitle": "对账任务",
"jobsDesc": "在右侧操作中查看差异明细与分页。", "jobsDesc": "在右侧操作中查看差异明细与分页结果。",
"refresh": "刷新", "refresh": "刷新",
"jobNo": "任务号", "jobNo": "任务号",
"type": "类型", "type": "类型",
@@ -42,10 +42,18 @@
"createdAt": "创建时间", "createdAt": "创建时间",
"operate": "操作", "operate": "操作",
"view": "查看", "view": "查看",
"detailsTitle": "任务明细", "viewDetails": "查看差异明细",
"detailsTitle": "差异明细",
"sideARef": "彩票侧引用", "sideARef": "彩票侧引用",
"sideBRef": "主站侧引用", "sideBRef": "主站侧引用",
"differenceAmount": "差额(分)", "differenceAmount": "差额(分)",
"itemResult": "检查结果",
"diagnosis": "异常说明",
"suggestedAction": "建议处理方向",
"processingStatus": "处理状态",
"quickAccess": "快捷处理",
"openTransferOrder": "查看转账单",
"openWalletTxn": "查看钱包流水",
"detectedAt": "发现时间", "detectedAt": "发现时间",
"noDetails": "无明细", "noDetails": "无明细",
"playerSearch": "指定玩家(可选)", "playerSearch": "指定玩家(可选)",
@@ -63,5 +71,33 @@
"statusFailed": "失败", "statusFailed": "失败",
"itemMismatch": "不一致", "itemMismatch": "不一致",
"itemMatched": "一致", "itemMatched": "一致",
"itemPendingCheck": "待核对" "itemPendingCheck": "待核对",
"itemStaleProcessing": "长时间处理中",
"itemPendingReconcile": "待人工对账",
"itemMissingWalletTxn": "缺少钱包流水",
"itemUnexpectedWalletTxn": "出现多余钱包流水",
"itemMissingRefund": "缺少退款流水",
"itemMissingReversal": "缺少冲正流水",
"itemResolved": "已处理",
"itemUnresolved": "未处理",
"diagnosisStaleProcessing": "转账单长时间停留在处理中,系统未拿到明确成功或失败结果。",
"diagnosisPendingReconcile": "转账单已被标记为待人工对账,需要人工确认主站与彩票侧最终结果。",
"diagnosisMissingWalletTxn": "转账单状态已推进,但彩票侧缺少对应钱包流水。",
"diagnosisUnexpectedWalletTxn": "彩票侧出现了与当前转账状态不匹配的额外钱包流水。",
"diagnosisMissingRefund": "转出失败后,应有退款流水回补,但当前未找到。",
"diagnosisMissingReversal": "转账单已冲正,但彩票侧缺少冲正流水。",
"diagnosisMatched": "该记录已对平,无需进一步处理。",
"diagnosisPendingCheck": "该记录需要继续人工确认。",
"actionStaleProcessing": "先核对主站是否已成功扣款,再查看转账单和钱包流水是否需要冲正或补记。",
"actionPendingReconcile": "优先打开转账单核对主站回执,再决定是补记入账、冲正,还是结案。",
"actionMissingWalletTxn": "打开转账单与钱包流水交叉核对,确认是否需要补记一笔钱包流水。",
"actionUnexpectedWalletTxn": "检查是否发生重复记账或错误回补,必要时按实际情况冲正。",
"actionMissingRefund": "确认主站侧失败后是否已退款;若已退款,补记彩票侧退款流水或冲正。",
"actionMissingReversal": "确认冲正是否在外部成功,再补记彩票侧冲正流水。",
"actionMatched": "无需处理。",
"actionPendingCheck": "请结合转账单与钱包流水继续核对。",
"actionResolved": "该异常已处理,当前转账单状态为:{{status}}。如需复核,请打开转账单查看处理结果。",
"transferStatusSuccess": "已成功",
"transferStatusReversed": "已冲正",
"transferStatusManual": "已结案"
} }

View File

@@ -35,6 +35,7 @@
"loadFailed": "任务列表加载失败", "loadFailed": "任务列表加载失败",
"downloadSuccess": "已下载 {{jobNo}}", "downloadSuccess": "已下载 {{jobNo}}",
"downloadFailed": "下载失败", "downloadFailed": "下载失败",
"currentReportHint": "这里只显示当前所选报表的导出任务,避免和其他报表任务混在一起。",
"columns": { "columns": {
"jobNo": "任务编号", "jobNo": "任务编号",
"report": "报表", "report": "报表",
@@ -191,6 +192,8 @@
"stats": { "stats": {
"records": "记录数", "records": "记录数",
"currentPage": "当前页", "currentPage": "当前页",
"notQueried": "未查询",
"notSet": "未设置",
"drawNo": "期号", "drawNo": "期号",
"currency": "币种", "currency": "币种",
"exportRows": "导出行数", "exportRows": "导出行数",
@@ -225,12 +228,10 @@
"status": "状态", "status": "状态",
"createdAt": "创建时间" "createdAt": "创建时间"
}, },
"legacyTitle": "旧版钱包报表",
"categories": { "categories": {
"all": "全部", "all": "全部",
"profit": "盈亏", "profit": "盈亏",
"wallet": "资金", "wallet": "资金",
"legacy": "旧版口径",
"risk": "风控", "risk": "风控",
"audit": "审计" "audit": "审计"
}, },

View File

@@ -25,6 +25,7 @@
"actualDeduct": "实扣", "actualDeduct": "实扣",
"status": "状态", "status": "状态",
"actions": "操作", "actions": "操作",
"viewTicketInList": "查看该注单",
"failReason": "失败原因", "failReason": "失败原因",
"winAmount": "中奖", "winAmount": "中奖",
"placedAt": "下单时间", "placedAt": "下单时间",

View File

@@ -101,7 +101,7 @@ export const PRD_WALLET_PLAYER_ACCESS_ANY = [
...PRD_WALLET_TX_ACCESS_ANY, ...PRD_WALLET_TX_ACCESS_ANY,
] as const; ] as const;
/** 赔率与回水配置页 */ /** 赔率与基础回水配置页 */
export const PRD_RULES_ODDS_ACCESS_ANY = [ export const PRD_RULES_ODDS_ACCESS_ANY = [
PRD_ODDS_MANAGE, PRD_ODDS_MANAGE,
PRD_ODDS_VIEW, PRD_ODDS_VIEW,

View File

@@ -59,6 +59,7 @@ export type AgentLineDetailPanelProps = {
canViewPlayersTab: boolean; canViewPlayersTab: boolean;
canManageNode: boolean; canManageNode: boolean;
canCreateChild: boolean; canCreateChild: boolean;
canCreateChildAgent: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean; canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void; onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void; onDeleteChild: (node: AgentNodeRow) => void;
@@ -88,6 +89,7 @@ export function AgentLineDetailPanel({
canViewPlayersTab, canViewPlayersTab,
canManageNode, canManageNode,
canCreateChild, canCreateChild,
canCreateChildAgent,
canDeleteChild, canDeleteChild,
onEditChild, onEditChild,
onDeleteChild, onDeleteChild,
@@ -155,6 +157,17 @@ export function AgentLineDetailPanel({
siteLabel && siteCode.trim() !== "" siteLabel && siteCode.trim() !== ""
? `${siteLabel} (${siteCode})` ? `${siteLabel} (${siteCode})`
: 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 ( return (
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background"> <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: "停用" })} : t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge> </AdminStatusBadge>
</div> </div>
<p className="mt-1.5 text-sm text-muted-foreground"> {(codeText !== "" || usernameText !== "" || parentName) ? (
<span className="font-mono text-xs text-foreground/80">{node.code}</span> <div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{node.username ? ( {codeText !== "" ? (
<> <span className="rounded-md bg-muted/50 px-2 py-1 font-mono text-xs text-foreground/80">
<span className="mx-1.5 text-border">·</span> {t("lineUi.agentCode", { defaultValue: "编码" })} {codeText}
{node.username} </span>
</> ) : null}
{usernameText !== "" ? (
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
{t("lineUi.agentUsername", { defaultValue: "账号" })} {usernameText}
</span>
) : null} ) : null}
{parentName ? ( {parentName ? (
<> <span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
<span className="mx-1.5 text-border">·</span>
{t("parentAgent", { defaultValue: "上级代理" })} {parentName} {t("parentAgent", { defaultValue: "上级代理" })} {parentName}
</> </span>
) : null}
</div>
) : null} ) : null}
</p>
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center"> <div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
@@ -202,10 +219,11 @@ export function AgentLineDetailPanel({
</div> </div>
) : null} ) : null}
{canManageNode ? ( {canManageNode ? (
<div className="flex max-w-[28rem] flex-col items-end gap-2">
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}> <Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
<Pencil className="mr-1.5 size-3.5" /> <Pencil className="mr-1.5 size-3.5" />
{t("lineUi.editAccount", { defaultValue: "账号与状态" })} {t("lineUi.editAgent", { defaultValue: "编辑代理" })}
</Button> </Button>
{canCreateChild ? ( {canCreateChild ? (
<Button type="button" size="sm" onClick={onAddChild}> <Button type="button" size="sm" onClick={onAddChild}>
@@ -214,6 +232,12 @@ export function AgentLineDetailPanel({
</Button> </Button>
) : null} ) : null}
</div> </div>
{childActionHint ? (
<p className="text-right text-xs leading-5 text-muted-foreground">
{childActionHint}
</p>
) : null}
</div>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -267,7 +291,7 @@ export function AgentLineDetailPanel({
}) })
: t("lineUi.profileTabHint", { : t("lineUi.profileTabHint", {
defaultValue: defaultValue:
"占成、授信、回水与风控标签在此维护;登录名密码请用「账号与状态」。", "占成、授信、回水与风控标签在此维护;登录名密码与启停状态请用「编辑代理」。",
})} })}
</p> </p>
</CardHeader> </CardHeader>
@@ -426,7 +450,7 @@ function OverviewTab({
defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。", defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。",
count: childCount, count: childCount,
})} })}
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })} actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
onAction={onGoToDownline} onAction={onGoToDownline}
/> />
) : null} ) : null}
@@ -437,7 +461,7 @@ function OverviewTab({
description={t("lineUi.overviewPlayersHint", { description={t("lineUi.overviewPlayersHint", {
defaultValue: "直属玩家请在「直属玩家」Tab 维护。", defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
})} })}
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })} actionLabel={t("lineUi.viewPlayers", { defaultValue: "查看直属玩家" })}
onAction={onGoToPlayers} onAction={onGoToPlayers}
/> />
) : null} ) : null}
@@ -509,6 +533,9 @@ function DownlineTable({
onAddChild: () => void; onAddChild: () => void;
}): React.ReactElement { }): React.ReactElement {
const { t } = useTranslation(["agents", "common"]); 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) { if (childAgents.length === 0) {
return ( return (
@@ -517,7 +544,7 @@ function DownlineTable({
{canManageNode && canCreateChild ? ( {canManageNode && canCreateChild ? (
<Button type="button" className="mt-2" onClick={onAddChild}> <Button type="button" className="mt-2" onClick={onAddChild}>
<Plus className="mr-1.5 size-4" /> <Plus className="mr-1.5 size-4" />
{t("createChild", { defaultValue: "添加下级代理" })} {createChildLabel}
</Button> </Button>
) : null} ) : null}
</AdminNoResourceState> </AdminNoResourceState>
@@ -604,13 +631,13 @@ function DownlineTable({
actions={[ actions={[
{ {
key: "edit", key: "edit",
label: t("editNode", { defaultValue: "编辑代理" }), label: editChildLabel,
icon: Pencil, icon: Pencil,
onClick: () => onEditChild(child), onClick: () => onEditChild(child),
}, },
{ {
key: "delete", key: "delete",
label: t("deleteNode", { defaultValue: "删除代理" }), label: deleteChildLabel,
icon: Trash2, icon: Trash2,
destructive: true, destructive: true,
disabled: !canDeleteChild(child), disabled: !canDeleteChild(child),

View File

@@ -295,24 +295,6 @@ export function AgentsConsole(): React.ReactElement {
return counts; return counts;
}, [flatNodes]); }, [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) => { const loadTree = useCallback(async (siteId?: number | null) => {
setLoading(true); setLoading(true);
setErr(null); setErr(null);
@@ -421,15 +403,15 @@ export function AgentsConsole(): React.ReactElement {
]); ]);
useAsyncEffect(() => { useAsyncEffect(() => {
if (filteredRows.length === 0) { if (businessRows.length === 0) {
setSelectedNodeId(null); setSelectedNodeId(null);
return; return;
} }
if (selectedNodeId === null || !filteredRows.some((row) => row.id === selectedNodeId)) { if (selectedNodeId === null || !businessRows.some((row) => row.id === selectedNodeId)) {
setSelectedNodeId(filteredRows[0]?.id ?? null); setSelectedNodeId(businessRows[0]?.id ?? null);
} }
}, [filteredRows, selectedNodeId]); }, [businessRows, selectedNodeId]);
useEffect(() => { useEffect(() => {
setDetailTab("overview"); setDetailTab("overview");
@@ -820,6 +802,7 @@ export function AgentsConsole(): React.ReactElement {
canViewPlayersTab={canShowPlayersTab} canViewPlayersTab={canShowPlayersTab}
canManageNode={canManageNode} canManageNode={canManageNode}
canCreateChild={canCreateChildOnSelected} canCreateChild={canCreateChildOnSelected}
canCreateChildAgent={canCreateChildAgent}
canDeleteChild={canDeleteNode} canDeleteChild={canDeleteNode}
onEditChild={(node) => openEditForNode(node)} onEditChild={(node) => openEditForNode(node)}
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)} onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
@@ -843,7 +826,7 @@ export function AgentsConsole(): React.ReactElement {
<DialogTitle> <DialogTitle>
{nodeDialogMode === "create" {nodeDialogMode === "create"
? t("createChild", { defaultValue: "添加下级代理" }) ? t("createChild", { defaultValue: "添加下级代理" })
: t("editNode", { defaultValue: "编辑代理" })} : t("editNode", { defaultValue: "编辑代理账号与配置" })}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>

View File

@@ -1,11 +1,18 @@
"use client"; "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 { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { getAgentNodeProfile } from "@/api/admin-agents"; import { getAgentNodeProfile } from "@/api/admin-agents";
import {
getSettlementBills,
postSettlementBillBadDebtWriteOff,
postSettlementBillConfirm,
postSettlementBillPayment,
type SettlementBillRow,
} from "@/api/admin-agent-settlement";
import { import {
deleteAdminPlayer, deleteAdminPlayer,
getAdminPlayer, getAdminPlayer,
@@ -15,7 +22,7 @@ import {
} from "@/api/admin-player"; } from "@/api/admin-player";
import { formatCredit } from "@/modules/agents/agent-line-sidebar"; import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; 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 { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; 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 { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; 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 { formatAdminMinorUnits } from "@/lib/money";
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent"; import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
@@ -79,6 +86,15 @@ function playerStatusLabel(
return String(status); 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 { function resolvePlayerRebateRate(row: AdminPlayerRow): number | null {
if (row.rebate_rate != null) { if (row.rebate_rate != null) {
return row.rebate_rate; return row.rebate_rate;
@@ -108,7 +124,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
nickname: string; nickname: string;
currency: string; currency: string;
status: number; status: number;
creditLimit: string; creditLimit: number;
rebateRate: string; rebateRate: string;
riskTags: string; riskTags: string;
} { } {
@@ -119,7 +135,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
nickname: row.nickname ?? "", nickname: row.nickname ?? "",
currency: row.default_currency ?? "", currency: row.default_currency ?? "",
status: row.status, status: row.status,
creditLimit: row.credit_limit != null ? String(row.credit_limit) : "", creditLimit: row.credit_limit ?? 0,
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "", rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
riskTags: (row.risk_tags ?? []).join(", "), riskTags: (row.risk_tags ?? []).join(", "),
}; };
@@ -143,6 +159,15 @@ export function AgentsPlayersPanel({
}: AgentsPlayersPanelProps): React.ReactElement { }: AgentsPlayersPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "players", "common"]); const { t } = useTranslation(["agents", "players", "common"]);
const formatDt = useAdminDateTimeFormatter(); 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 profile = useAdminProfile();
const boundAgent = profile?.agent ?? null; const boundAgent = profile?.agent ?? null;
const isSuperAdmin = profile?.is_super_admin === true; const isSuperAdmin = profile?.is_super_admin === true;
@@ -175,7 +200,6 @@ export function AgentsPlayersPanel({
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [sitePlayerId, setSitePlayerId] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [nickname, setNickname] = useState(""); const [nickname, setNickname] = useState("");
@@ -189,10 +213,22 @@ export function AgentsPlayersPanel({
const [editNickname, setEditNickname] = useState(""); const [editNickname, setEditNickname] = useState("");
const [editDefaultCurrency, setEditDefaultCurrency] = useState(""); const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
const [editStatus, setEditStatus] = useState(0); 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 [editRebateRate, setEditRebateRate] = useState("");
const [editRiskTags, setEditRiskTags] = useState(""); const [editRiskTags, setEditRiskTags] = useState("");
const [editDetailLoading, setEditDetailLoading] = useState(false); 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 () => { const load = useCallback(async () => {
if (siteCode.trim() === "") { if (siteCode.trim() === "") {
@@ -241,7 +277,6 @@ export function AgentsPlayersPanel({
try { try {
await postAdminPlayer({ await postAdminPlayer({
site_code: siteCode.trim(), site_code: siteCode.trim(),
...(sitePlayerId.trim() !== "" ? { site_player_id: sitePlayerId.trim() } : {}),
username: username.trim(), username: username.trim(),
password: password, password: password,
nickname: nickname.trim() || null, nickname: nickname.trim() || null,
@@ -259,7 +294,6 @@ export function AgentsPlayersPanel({
}), }),
); );
setDialogOpen(false); setDialogOpen(false);
setSitePlayerId("");
setUsername(""); setUsername("");
setPassword(""); setPassword("");
setNickname(""); setNickname("");
@@ -290,7 +324,9 @@ export function AgentsPlayersPanel({
setEditNickname(form.nickname); setEditNickname(form.nickname);
setEditDefaultCurrency(form.currency); setEditDefaultCurrency(form.currency);
setEditStatus(form.status); setEditStatus(form.status);
setEditCreditLimit(form.creditLimit); setEditCreditBase(form.creditLimit);
setEditCreditAdjustMode("increase");
setEditCreditDelta("");
setEditRebateRate(form.rebateRate); setEditRebateRate(form.rebateRate);
setEditRiskTags(form.riskTags); setEditRiskTags(form.riskTags);
}; };
@@ -339,10 +375,13 @@ export function AgentsPlayersPanel({
if (editStatus !== editingPlayer.status) { if (editStatus !== editingPlayer.status) {
body.status = editStatus; body.status = editStatus;
} }
const nextCredit = const creditDelta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
editCreditLimit.trim() === "" ? 0 : Number.parseInt(editCreditLimit, 10); if (!Number.isNaN(creditDelta) && creditDelta > 0) {
if (!Number.isNaN(nextCredit) && nextCredit !== (editingPlayer.credit_limit ?? 0)) { const signedDelta = editCreditAdjustMode === "increase" ? creditDelta : -creditDelta;
body.credit_limit = Math.max(0, nextCredit); 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 prevRebate = resolvePlayerRebateRate(editingPlayer);
const nextPercent = parsePercentUi(editRebateRate); const nextPercent = parsePercentUi(editRebateRate);
@@ -390,7 +429,141 @@ export function AgentsPlayersPanel({
setTotal((current) => Math.max(0, current - 1)); setTotal((current) => Math.max(0, current - 1));
toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id })); toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id }));
} catch (e) { } 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 ? ( {canCreatePlayer ? (
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}> <Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" /> <Plus className="mr-1.5 size-3.5" />
{t("playersPanel.create", { defaultValue: "创建玩家" })} {createPlayerLabel}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -434,6 +607,9 @@ export function AgentsPlayersPanel({
<TableHead className="whitespace-nowrap"> <TableHead className="whitespace-nowrap">
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })} {t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap">
{t("players:riskTags", { defaultValue: "风控标签" })}
</TableHead>
<TableHead className="whitespace-nowrap"> <TableHead className="whitespace-nowrap">
{t("players:fundingMode", { defaultValue: "资金模式" })} {t("players:fundingMode", { defaultValue: "资金模式" })}
</TableHead> </TableHead>
@@ -455,11 +631,12 @@ export function AgentsPlayersPanel({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.length === 0 ? ( {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) => { items.map((row) => {
const balances = playerBalanceCells(row, formatAdminMinorUnits); const balances = playerBalanceCells(row, formatAdminMinorUnits);
const rebate = resolvePlayerRebateRate(row); const rebate = resolvePlayerRebateRate(row);
const riskTags = row.risk_tags ?? [];
return ( return (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell className="tabular-nums text-xs font-medium">#{row.id}</TableCell> <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"> / </span>
<span className="text-muted-foreground">{row.nickname ?? "—"}</span> <span className="text-muted-foreground">{row.nickname ?? "—"}</span>
</TableCell> </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> <TableCell>
<PlayerFundingModeBadge row={row} /> <PlayerFundingModeBadge row={row} />
</TableCell> </TableCell>
@@ -509,21 +702,33 @@ export function AgentsPlayersPanel({
actions={[ actions={[
{ {
key: "detail", key: "detail",
label: t("players:viewDetail", { defaultValue: "查看详情" }), label: viewPlayerLabel,
icon: Eye, icon: Eye,
href: adminPlayerDetailPath(row.id), 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 ...(canManagePlayerRows
? [ ? [
{ {
key: "edit", key: "edit",
label: t("players:edit", { defaultValue: "编辑" }), label: editPlayerLabel,
icon: Pencil, icon: Pencil,
onClick: () => openEditPlayer(row), onClick: () => openEditPlayer(row),
}, },
{ {
key: "delete", key: "delete",
label: t("players:delete", { defaultValue: "删除" }), label: deletePlayerLabel,
icon: Trash2, icon: Trash2,
destructive: true, destructive: true,
onClick: () => onClick: () =>
@@ -572,24 +777,12 @@ export function AgentsPlayersPanel({
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle> <DialogTitle>{createPlayerLabel}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label> <Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input value={siteCode} readOnly disabled /> <Input value={siteCode} readOnly disabled />
</div> </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"> <div className="space-y-2">
<Label htmlFor="agent-player-username"> <Label htmlFor="agent-player-username">
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })} {t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
@@ -669,6 +862,138 @@ export function AgentsPlayersPanel({
</DialogContent> </DialogContent>
</Dialog> </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}> <Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -713,14 +1038,61 @@ export function AgentsPlayersPanel({
<Label htmlFor="agent-player-edit-credit"> <Label htmlFor="agent-player-edit-credit">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })} {t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label> </Label>
<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 <Input
id="agent-player-edit-credit" id="agent-player-edit-credit-delta"
type="number" type="number"
min={0} min={0}
value={editCreditLimit} value={editCreditDelta}
onChange={(e) => setEditCreditLimit(e.target.value)} onChange={(e) => setEditCreditDelta(e.target.value)}
placeholder="0"
/> />
</div> </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"> <div className="space-y-2">
<Label htmlFor="agent-player-edit-rebate"> <Label htmlFor="agent-player-edit-rebate">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })} {t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}

View File

@@ -227,11 +227,10 @@ export function AuditLogsConsole(): React.ReactElement {
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell>{row.id}</TableCell> <TableCell>{row.id}</TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
{t(`operatorTypes.${row.operator_type}`, { <div className="font-medium text-foreground">{row.operator_label}</div>
ns: "audit", {row.operator_subtitle ? (
defaultValue: row.operator_type, <div className="text-muted-foreground">{row.operator_subtitle}</div>
})} ) : null}
:{row.operator_id}
</TableCell> </TableCell>
<TableCell className="text-sm">{row.module_label}</TableCell> <TableCell className="text-sm">{row.module_label}</TableCell>
<TableCell className="text-sm">{row.action_label}</TableCell> <TableCell className="text-sm">{row.action_label}</TableCell>

View File

@@ -1,6 +1,6 @@
"use client"; "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 { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -32,6 +32,13 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -157,6 +164,9 @@ export function PlayConfigDocScreen() {
const [rollbackOpen, setRollbackOpen] = useState(false); const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null); const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const [error, setError] = useState<string | 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 detailRequestSeq = useRef(0);
const refreshList = useCallback(async () => { const refreshList = useCallback(async () => {
@@ -268,6 +278,61 @@ export function PlayConfigDocScreen() {
[draftRows], [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>) { function updateConfigRow(playCode: string, patch: Partial<PlayConfigItemRow>) {
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r))); setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
} }
@@ -474,9 +539,91 @@ export function PlayConfigDocScreen() {
> >
{detail ? ( {detail ? (
<ConfigSection <ConfigSection
title={t("play.batchSwitchesTitle", { ns: "config" })} title={t("play.filters.sectionTitle", { ns: "config" })}
description={!isDraft ? t("play.readOnlyDraftHint", { ns: "config" }) : undefined} description={isDraft ? t("play.filters.sectionDescription", { ns: "config" }) : undefined}
> >
{!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")}
>
<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> <ConfigChipGroup>
{batchSwitchStates.map((group) => { {batchSwitchStates.map((group) => {
const groupOn = group.allEnabled; const groupOn = group.allEnabled;
@@ -485,11 +632,11 @@ export function PlayConfigDocScreen() {
return ( return (
<div <div
key={group.key} key={group.key}
className="flex items-center justify-between gap-4 rounded-xl border border-border/60 bg-card px-4 py-3" 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"> <div className="min-w-0">
<p className="text-sm font-medium text-foreground">{group.label}</p> <p className="text-sm font-medium text-foreground">{group.label}</p>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
{group.total > 0 {group.total > 0
? isPartial ? isPartial
? t("play.batchPartialEnabled", { ? t("play.batchPartialEnabled", {
@@ -509,7 +656,7 @@ export function PlayConfigDocScreen() {
<Checkbox <Checkbox
checked={groupOn} checked={groupOn}
indeterminate={isPartial} indeterminate={isPartial}
disabled={!isDraft || saving || group.total === 0 || confirmBusy} disabled={saving || group.total === 0 || confirmBusy}
aria-label={t("play.aria.batchGroupSwitch", { aria-label={t("play.aria.batchGroupSwitch", {
ns: "config", ns: "config",
group: group.label, group: group.label,
@@ -537,6 +684,8 @@ export function PlayConfigDocScreen() {
); );
})} })}
</ConfigChipGroup> </ConfigChipGroup>
</div>
) : null}
</ConfigSection> </ConfigSection>
) : null} ) : null}
@@ -558,10 +707,29 @@ export function PlayConfigDocScreen() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{orderedRows.map((row) => ( {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>
{rows.map((row) => (
<TableRow key={row.play_code}> <TableRow key={row.play_code}>
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell> <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"> <TableCell className="text-center">
{isDraft ? ( {isDraft ? (
<div className="flex justify-center"> <div className="flex justify-center">
@@ -691,6 +859,8 @@ export function PlayConfigDocScreen() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</Fragment>
))}
</TableBody> </TableBody>
</Table> </Table>
)} )}

View File

@@ -14,6 +14,7 @@ import {
publishRiskCapVersion, publishRiskCapVersion,
putRiskCapItems, putRiskCapItems,
} from "@/api/admin-config"; } from "@/api/admin-config";
import { getAdminDraws } from "@/api/admin-draws";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
@@ -32,7 +33,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel"; import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
import { import {
@@ -43,6 +44,13 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value"; import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions"; import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; 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 { useTranslationRef } from "@/hooks/use-translation-ref";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money"; 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 { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick"; import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
import type {
AdminDrawListItem,
} from "@/types/api/admin-draws";
import type { import type {
ConfigVersionSummary, ConfigVersionSummary,
RiskCapItemRow, RiskCapItemRow,
@@ -102,6 +113,7 @@ export function RiskCapDocScreen() {
const [loadingDetail, setLoadingDetail] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [drawOptions, setDrawOptions] = useState<AdminDrawListItem[]>([]);
const [defaultCapStr, setDefaultCapStr] = useState(""); const [defaultCapStr, setDefaultCapStr] = useState("");
const [syncOpen, setSyncOpen] = useState(false); const [syncOpen, setSyncOpen] = useState(false);
@@ -124,10 +136,23 @@ export function RiskCapDocScreen() {
} finally { } finally {
setLoadingList(false); setLoadingList(false);
} }
}, []); }, [tRef]);
useAsyncEffect(() => { useAsyncEffect(() => {
void refreshList(); 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[]) { function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
@@ -232,12 +257,20 @@ export function RiskCapDocScreen() {
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" })); toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
return; return;
} }
if (r.draw_id !== null) {
toast.error(t("riskCap.validation.defaultCannotBindDraw", { ns: "config" }));
return;
}
continue; continue;
} }
if (!/^[0-9]{4}$/.test(r.normalized_number)) { if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number })); toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number }));
return; return;
} }
if (r.cap_amount <= 0) {
toast.error(t("riskCap.validation.specialGreaterThanZero", { ns: "config", number: r.normalized_number }));
return;
}
} }
setSaving(true); setSaving(true);
try { try {
@@ -340,6 +373,15 @@ export function RiskCapDocScreen() {
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)), () => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
[draftRows], [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) { async function handleDeleteVersion(row: ConfigVersionSummary) {
try { try {
@@ -459,6 +501,35 @@ export function RiskCapDocScreen() {
> >
{error ? <p className="text-sm text-destructive">{error}</p> : null} {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" })}> <ConfigSection title={t("riskCap.defaultCap.title", { ns: "config" })}>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1"> <div className="grid gap-1">
@@ -466,8 +537,8 @@ export function RiskCapDocScreen() {
{canEditDraft ? ( {canEditDraft ? (
<Input <Input
id="default-cap" id="default-cap"
type="number" type="text"
min={0} inputMode="decimal"
className="w-[220px] font-mono tabular-nums" className="w-[220px] font-mono tabular-nums"
disabled={saving} disabled={saving}
value={defaultCapStr} value={defaultCapStr}
@@ -490,6 +561,7 @@ export function RiskCapDocScreen() {
<ConfigSection <ConfigSection
title={t("riskCap.specialCaps.title", { ns: "config" })} title={t("riskCap.specialCaps.title", { ns: "config" })}
description={t("riskCap.specialCaps.description", { ns: "config" })}
actions={ actions={
canEditDraft ? ( canEditDraft ? (
<Button <Button
@@ -507,18 +579,83 @@ export function RiskCapDocScreen() {
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} /> <AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
) : specialRows.length === 0 ? ( ) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p> <p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
) : (
<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> <Table>
<TableHeader> <TableHeader>
<TableRow> <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-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { 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> <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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{specialRows.map(({ row: r, index: idx }) => ( {group.rows.map(({ row: r, index: idx }) => (
<TableRow key={r.clientKey}> <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> <TableCell>
{canEditDraft ? ( {canEditDraft ? (
<Input <Input
@@ -574,7 +711,9 @@ export function RiskCapDocScreen() {
]} ]}
/> />
) : ( ) : (
<span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span> <span className="text-sm text-muted-foreground">
{t("riskCap.readOnly", { ns: "config" })}
</span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -582,6 +721,10 @@ export function RiskCapDocScreen() {
</TableBody> </TableBody>
</Table> </Table>
)} )}
</div>
))}
</div>
)}
</ConfigSection> </ConfigSection>
<RiskCapRuntimePanel /> <RiskCapRuntimePanel />

View File

@@ -421,6 +421,9 @@ export function DashboardAgentRankingCard({
const v = metricValue(row); const v = metricValue(row);
const pct = (Math.abs(v) / maxAbs) * 100; const pct = (Math.abs(v) / maxAbs) * 100;
const color = barColor(row); const color = barColor(row);
const agentName = row.agent_name?.trim() || "-";
const agentCode = row.agent_code?.trim() || "";
const showCode = agentCode !== "" && agentCode !== agentName;
return ( return (
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2"> <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"> <div className="flex items-start justify-between gap-2">
@@ -429,8 +432,10 @@ export function DashboardAgentRankingCard({
#{idx + 1} #{idx + 1}
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p> <p className="truncate text-xs font-medium">{agentName}</p>
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p> {showCode ? (
<p className="truncate text-[11px] text-muted-foreground">{agentCode}</p>
) : null}
</div> </div>
</div> </div>
<div className="shrink-0 text-right text-xs font-semibold tabular-nums"> <div className="shrink-0 text-right text-xs font-semibold tabular-nums">

View File

@@ -13,6 +13,38 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; 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 { function isOpenLikeStatus(status: string): boolean {
const lower = status.toLowerCase(); const lower = status.toLowerCase();
return lower.includes("open") || lower.includes("sale"); return lower.includes("open") || lower.includes("sale");
@@ -29,7 +61,7 @@ export function DashboardCurrentDrawCard({
drawId, drawId,
loading = false, loading = false,
}: DashboardCurrentDrawCardProps): ReactElement { }: DashboardCurrentDrawCardProps): ReactElement {
const { t } = useTranslation("dashboard"); const { t } = useTranslation(["dashboard", "draws"]);
const formatDt = useAdminDateTimeFormatter(); const formatDt = useAdminDateTimeFormatter();
if (loading) { if (loading) {
@@ -54,6 +86,7 @@ export function DashboardCurrentDrawCard({
} }
const openLike = isOpenLikeStatus(hall.status); const openLike = isOpenLikeStatus(hall.status);
const statusLabel = formatDashboardDrawStatus(hall.status, t);
return ( return (
<Card className="admin-list-card overflow-hidden border-primary/15 py-0 shadow-sm"> <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", openLike ? "bg-emerald-500" : "bg-muted-foreground/70",
)} )}
/> />
{hall.status} {statusLabel}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -817,6 +817,11 @@ export function HotUsageBars({
}): ReactElement { }): ReactElement {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]); 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( const chartData = useMemo(
() => () =>
@@ -824,6 +829,7 @@ export function HotUsageBars({
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100)); const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
return { return {
number: row.normalized_number.trim(), number: row.normalized_number.trim(),
displayNumber: shortenPoolLabel(row.normalized_number),
usage: pct, usage: pct,
fill: usageBarFill(pct), fill: usageBarFill(pct),
}; };
@@ -855,15 +861,23 @@ export function HotUsageBars({
<YAxis <YAxis
type="category" type="category"
dataKey="number" dataKey="number"
width={72} width={compact ? 92 : 112}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value) => {
const row = chartData.find((item) => item.number === value);
return row?.displayNumber ?? value;
}}
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }} tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
/> />
<ChartTooltip <ChartTooltip
content={ content={
<ChartTooltipContent <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)}%`;
}}
/> />
} }
/> />

View File

@@ -9,6 +9,7 @@ import { toast } from "sonner";
import { import {
getAdminDraw, getAdminDraw,
getAdminDrawFinanceSummary,
postAdminCancelDraw, postAdminCancelDraw,
postAdminManualCloseDraw, postAdminManualCloseDraw,
postAdminReopenDraw, postAdminReopenDraw,
@@ -22,11 +23,13 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminDrawShowData } from "@/types/api/admin-draws"; import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { canManageDrawResults } from "@/lib/draw-access"; import { canManageDrawResults } from "@/lib/draw-access";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display"; import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
import { DrawStatusBadge } from "./draw-status-badge"; import { DrawStatusBadge } from "./draw-status-badge";
@@ -80,6 +83,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [acting, setActing] = useState<string | null>(null); const [acting, setActing] = useState<string | null>(null);
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -91,9 +95,20 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { 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) { } catch (e) {
setData(null); setData(null);
setFinanceSummary(null);
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" })); setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally { } finally {
setLoading(false); setLoading(false);
@@ -225,6 +240,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const batch = data.result_batch_counts; const batch = data.result_batch_counts;
const pendingReview = batch.pending_review ?? 0; const pendingReview = batch.pending_review ?? 0;
const totalBatches = batch.total ?? batch.published; const totalBatches = batch.total ?? batch.published;
const financeCurrency = financeSummary?.currency_code ?? "NPR";
const hasResultActivity = const hasResultActivity =
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0; (canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
const showActions = const showActions =
@@ -236,6 +252,14 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"> <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> <CardTitle className="font-mono text-xl tracking-tight">{data.draw_no}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{t("detailSubtitle", { {t("detailSubtitle", {
@@ -263,6 +287,39 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</CardHeader> </CardHeader>
<CardContent className="space-y-6 border-t pt-6"> <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"> <section className="space-y-3">
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3> <h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
<ScheduleTimeline steps={scheduleSteps} /> <ScheduleTimeline steps={scheduleSteps} />
@@ -307,6 +364,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("noResultBatchesYet")} {t("noResultBatchesYet")}
<span className="ml-1">{t("reviewQueueHint")}</span>
{canManageDraw ? ( {canManageDraw ? (
<> <>
{" "} {" "}

View File

@@ -2,10 +2,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Info } from "lucide-react";
import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminPageCard } from "@/components/admin/admin-page-card";
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console"; import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console"; import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */ /** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
export function JackpotConfigScreen() { export function JackpotConfigScreen() {
@@ -26,6 +28,15 @@ export function JackpotConfigScreen() {
return ( return (
<div className="flex w-full max-w-none flex-col gap-6"> <div className="flex w-full max-w-none flex-col gap-6">
<AdminPageCard title={t("poolsSectionTitle")}> <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 /> <JackpotPoolsConsole embedded />
</AdminPageCard> </AdminPageCard>

View File

@@ -214,15 +214,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
const manualBurst = async (p: AdminJackpotPoolRow) => { const manualBurst = async (p: AdminJackpotPoolRow) => {
const d = drafts[p.id]; const d = drafts[p.id];
if (!d) return; if (!d) return;
const drawId = Number.parseInt(d.manual_burst_draw_id, 10); const drawRef = d.manual_burst_draw_id.trim();
if (!Number.isFinite(drawId) || drawId <= 0) { if (drawRef.length === 0) {
toast.error(t("invalidDrawId")); toast.error(t("invalidDrawId"));
return; return;
} }
setBurstingId(p.id); setBurstingId(p.id);
try { try {
const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawId }); const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawRef });
toast.success( toast.success(
`${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`, `${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`,
); );

View File

@@ -12,6 +12,7 @@ import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet"; import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; 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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { buttonVariants } from "@/components/ui/button"; 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 { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets"; import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet"; import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
import { Eye } from "lucide-react";
function playerStatusLabel(status: number, t: (key: string) => string): string { function playerStatusLabel(status: number, t: (key: string) => string): string {
if (status === 0) return t("statusNormal"); if (status === 0) return t("statusNormal");
@@ -309,6 +311,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<ProfileField label={t("authSource")}> <ProfileField label={t("authSource")}>
{playerAuthSourceLabel(player, t)} {playerAuthSourceLabel(player, t)}
</ProfileField> </ProfileField>
<ProfileField label={t("riskTags", { defaultValue: "风控标签" })}>
{player.risk_tags && player.risk_tags.length > 0 ? player.risk_tags.join(", ") : "—"}
</ProfileField>
<ProfileField label={t("status")}> <ProfileField label={t("status")}>
<PlayerStatusBadge status={player.status} t={t} /> <PlayerStatusBadge status={player.status} t={t} />
</ProfileField> </ProfileField>
@@ -408,7 +413,10 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TabsContent value="tickets" className="mt-0"> <TabsContent value="tickets" className="mt-0">
<Card className="admin-list-card"> <Card className="admin-list-card">
<CardHeader className="admin-list-header"> <CardHeader className="admin-list-header">
<div className="space-y-1">
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle> <CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("ticketTableHint")}</p>
</div>
</CardHeader> </CardHeader>
<CardContent className="admin-list-content"> <CardContent className="admin-list-content">
<div className="admin-table-shell"> <div className="admin-table-shell">
@@ -416,21 +424,29 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead> <TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("orderNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead> <TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead> <TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
<TableHead>{t("number", { ns: "tickets" })}</TableHead> <TableHead>{t("number", { ns: "tickets" })}</TableHead>
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead> <TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
<TableHead>{t("status", { 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("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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{ticketsLoading && tickets.length === 0 ? ( {ticketsLoading && tickets.length === 0 ? (
<AdminTableLoadingRow colSpan={7} /> <AdminTableLoadingRow colSpan={11} />
) : null} ) : null}
{tickets.map((row) => ( {tickets.map((row) => (
<TableRow key={row.ticket_no}> <TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell> <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="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell> <TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</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)} {ticketStatusText(row.status, t)}
</AdminStatusBadge> </AdminStatusBadge>
</TableCell> </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"> <TableCell className="text-xs text-muted-foreground">
{row.placed_at ? formatDt(row.placed_at) : "—"} {row.placed_at ? formatDt(row.placed_at) : "—"}
</TableCell> </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> </TableRow>
))} ))}
{!ticketsLoading && tickets.length === 0 ? ( {!ticketsLoading && tickets.length === 0 ? (
<AdminTableNoResourceRow colSpan={7} className="text-muted-foreground" /> <AdminTableNoResourceRow colSpan={11} className="text-muted-foreground" />
) : null} ) : null}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -82,6 +82,17 @@ const PLAYER_STATUS_OPTIONS = [
{ value: 2, label: "statusBanned" }, { 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 { export function PlayersConsole(): React.ReactElement {
const { t } = useTranslation(["players", "common"]); const { t } = useTranslation(["players", "common"]);
const tRef = useTranslationRef(["players", "common"]); const tRef = useTranslationRef(["players", "common"]);
@@ -121,6 +132,7 @@ export function PlayersConsole(): React.ReactElement {
const [formNickname, setFormNickname] = useState(""); const [formNickname, setFormNickname] = useState("");
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR"); const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
const [formStatus, setFormStatus] = useState(0); const [formStatus, setFormStatus] = useState(0);
const [formRiskTags, setFormRiskTags] = useState("");
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined); const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]); const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
const [createAgentLoading, setCreateAgentLoading] = useState(false); const [createAgentLoading, setCreateAgentLoading] = useState(false);
@@ -211,6 +223,7 @@ export function PlayersConsole(): React.ReactElement {
setFormNickname(""); setFormNickname("");
setFormDefaultCurrency("NPR"); setFormDefaultCurrency("NPR");
setFormStatus(0); setFormStatus(0);
setFormRiskTags("");
setAccountOpen(true); setAccountOpen(true);
} }
@@ -269,6 +282,7 @@ export function PlayersConsole(): React.ReactElement {
setFormNickname(row.nickname ?? ""); setFormNickname(row.nickname ?? "");
setFormDefaultCurrency(row.default_currency); setFormDefaultCurrency(row.default_currency);
setFormStatus(row.status); setFormStatus(row.status);
setFormRiskTags((row.risk_tags ?? []).join(", "));
setAccountOpen(true); setAccountOpen(true);
} }
@@ -337,6 +351,11 @@ export function PlayersConsole(): React.ReactElement {
if (formStatus !== editingPlayer?.status) { if (formStatus !== editingPlayer?.status) {
body.status = formStatus; 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) { if (Object.keys(body).length === 0) {
toast.success(t("noChanges")); toast.success(t("noChanges"));
@@ -517,6 +536,7 @@ export function PlayersConsole(): React.ReactElement {
<TableHead>{t("sitePlayerId")}</TableHead> <TableHead>{t("sitePlayerId")}</TableHead>
<TableHead>{t("username")}</TableHead> <TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</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("currency")}</TableHead>
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead> <TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead> <TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
@@ -528,12 +548,13 @@ export function PlayersConsole(): React.ReactElement {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<AdminTableLoadingRow colSpan={13} /> <AdminTableLoadingRow colSpan={14} />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<AdminTableNoResourceRow colSpan={13} className="text-muted-foreground" /> <AdminTableNoResourceRow colSpan={14} className="text-muted-foreground" />
) : ( ) : (
items.map((row) => { items.map((row) => {
const balances = playerBalanceCells(row, formatAdminMinorUnits); const balances = playerBalanceCells(row, formatAdminMinorUnits);
const riskTags = row.risk_tags ?? [];
return ( return (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell className="tabular-nums">#{row.id}</TableCell> <TableCell className="tabular-nums">#{row.id}</TableCell>
@@ -546,6 +567,22 @@ export function PlayersConsole(): React.ReactElement {
</TableCell> </TableCell>
<TableCell>{row.username ?? "—"}</TableCell> <TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{row.nickname ?? "—"}</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>{row.default_currency}</TableCell>
<TableCell> <TableCell>
<PlayerFundingModeBadge row={row} /> <PlayerFundingModeBadge row={row} />
@@ -791,6 +828,42 @@ export function PlayersConsole(): React.ReactElement {
</> </>
)} )}
{accountMode === "edit" && ( {accountMode === "edit" && (
<>
<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"> <div className="space-y-1.5">
<Label htmlFor="player-edit-status">{t("status")}</Label> <Label htmlFor="player-edit-status">{t("status")}</Label>
<Select <Select
@@ -809,6 +882,7 @@ export function PlayersConsole(): React.ReactElement {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</>
)} )}
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react"; import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -71,6 +72,18 @@ function itemStatusLabel(status: string, t: (key: string) => string): string {
return t("itemMatched"); return t("itemMatched");
case "pending_check": case "pending_check":
return t("itemPendingCheck"); 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: default:
return status; 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 { function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
const raw = summary?.[key]; const raw = summary?.[key];
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0; return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
@@ -507,8 +596,8 @@ export function ReconcileConsole(): React.ReactElement {
<AdminRowActionsMenu <AdminRowActionsMenu
actions={[ actions={[
{ {
key: "view", key: "view-details",
label: t("view"), label: t("viewDetails"),
icon: Eye, icon: Eye,
onClick: () => { onClick: () => {
setSelectedId(row.id); setSelectedId(row.id);
@@ -609,16 +698,20 @@ export function ReconcileConsole(): React.ReactElement {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead> <TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("sideARef")}</TableHead> <TableHead className="min-w-[10rem]">{t("sideARef")}</TableHead>
<TableHead>{t("sideBRef")}</TableHead> <TableHead className="min-w-[10rem]">{t("sideBRef")}</TableHead>
<TableHead className="text-right">{t("differenceAmount")}</TableHead> <TableHead className="w-28 text-right">{t("differenceAmount")}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead className="w-32">{t("itemResult")}</TableHead>
<TableHead>{t("detectedAt")}</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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.items.length === 0 ? ( {items.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={6} /> <AdminTableNoResourceRow colSpan={10} />
) : ( ) : (
items.items.map((r) => ( items.items.map((r) => (
<TableRow <TableRow
@@ -628,10 +721,10 @@ export function ReconcileConsole(): React.ReactElement {
r.status === "matched" && "bg-emerald-500/5", r.status === "matched" && "bg-emerald-500/5",
)} )}
> >
<TableCell>{r.id}</TableCell> <TableCell className="align-top">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell> <TableCell className="align-top font-mono text-xs break-all">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell> <TableCell className="align-top font-mono text-xs break-all">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="align-top text-right tabular-nums">
<span <span
className={cn( className={cn(
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground", r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
@@ -640,12 +733,46 @@ export function ReconcileConsole(): React.ReactElement {
{r.difference_amount} {r.difference_amount}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell className="align-top">
<AdminStatusBadge status={r.status}> <AdminStatusBadge status={r.status}>
{itemStatusLabel(r.status, t)} {itemStatusLabel(r.status, t)}
</AdminStatusBadge> </AdminStatusBadge>
</TableCell> </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)} {formatTs(r.created_at)}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -40,9 +40,10 @@ function downloadBlob(blob: Blob, filename: string): void {
type ReportJobsPanelProps = { type ReportJobsPanelProps = {
canExport: boolean; canExport: boolean;
refreshToken?: number; 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 { t } = useTranslation(["reports", "common"]);
const tRef = useTranslationRef(["reports", "common"]); const tRef = useTranslationRef(["reports", "common"]);
const formatTs = useAdminDateTimeFormatter(); const formatTs = useAdminDateTimeFormatter();
@@ -53,7 +54,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
setLoading(true); setLoading(true);
try { 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); setJobs(data.items);
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed")); toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed"));
@@ -61,7 +62,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [reportType, tRef]);
useAsyncEffect(() => { useAsyncEffect(() => {
void loadJobs(); void loadJobs();
@@ -100,7 +101,9 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="pt-2"> <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> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View File

@@ -93,7 +93,7 @@ import type {
AdminReportRebateCommissionRow, AdminReportRebateCommissionRow,
} from "@/types/api/admin-reports"; } 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 FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period"; type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
type ExportFormat = "csv" | "excel"; 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: "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: "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: "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 }, { 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"; return "border-red-200 bg-red-50 text-red-700";
case "audit": case "audit":
return "border-slate-200 bg-slate-50 text-slate-700"; return "border-slate-200 bg-slate-50 text-slate-700";
case "legacy":
return "border-amber-200 bg-amber-50 text-amber-800";
default: default:
return "border-blue-200 bg-blue-50 text-blue-700"; 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; 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 } = {}) { export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
const { t, i18n } = useTranslation(["reports", "common"]); const { t, i18n } = useTranslation(["reports", "common"]);
const profile = useAdminProfile(); const profile = useAdminProfile();
@@ -1426,13 +1508,11 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}; };
return ( return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]"> <Card className="admin-list-card">
<Card className="admin-list-card self-start"> <CardHeader className="admin-list-header pb-3">
<CardHeader className="admin-list-header pb-4"> <div className="flex flex-col gap-3">
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle> <div className="flex flex-wrap gap-2">
</CardHeader>
<CardContent className="space-y-1.5 pt-3">
{filteredReports.map((report) => { {filteredReports.map((report) => {
const Icon = report.icon; const Icon = report.icon;
const active = report.key === selectedReport.key; const active = report.key === selectedReport.key;
@@ -1442,42 +1522,36 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
type="button" type="button"
onClick={() => setSelectedKey(report.key)} onClick={() => setSelectedKey(report.key)}
className={cn( className={cn(
"flex w-full min-w-0 items-center gap-3 rounded-md border px-3 py-2.5 text-left transition", "inline-flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition",
active active
? "border-primary bg-primary/[0.05] shadow-sm ring-1 ring-primary/15" ? "border-primary bg-primary/[0.06] text-primary shadow-sm"
: "border-border/80 bg-card hover:border-primary/35 hover:bg-muted/30", : "border-border/80 bg-card text-muted-foreground hover:border-primary/35 hover:text-foreground",
)} )}
> >
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}> <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
<Icon className="size-4" aria-hidden /> <Icon className="size-3.5" 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> </span>
<span className="truncate">{t(`items.${report.key}.title`)}</span>
</button> </button>
); );
})} })}
</CardContent> </div>
</Card> <div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
<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> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pt-4"> <CardContent className="space-y-3 pt-0">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{selectedReport.fields.map(renderField)} {selectedReport.fields.map(renderField)}
</div> </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 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"> <div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" onClick={resetFilters}> <Button type="button" variant="outline" size="sm" onClick={resetFilters}>
{t("reset")} {t("reset")}
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm"
disabled={!canViewReports || !selectedReport.connected || loading} disabled={!canViewReports || !selectedReport.connected || loading}
onClick={() => { onClick={() => {
setPage(1); setPage(1);
@@ -1492,16 +1566,11 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
</CardContent> </CardContent>
</Card> </Card>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-2 md:grid-cols-4">
{(result?.summary ?? [ {(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
{ label: t("preview.stats.records"), value: "-" }, <div key={item.label} className={cn("rounded-md border px-3 py-2.5", statTone(item.tone))}>
{ 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="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 truncate text-lg font-semibold tabular-nums">{item.value}</div> <div className="mt-0.5 truncate text-base font-semibold tabular-nums">{item.value}</div>
</div> </div>
))} ))}
</div> </div>
@@ -1516,16 +1585,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
) : null} ) : null}
<Card className="admin-list-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"> <CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle> <CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-1.5">
<p className="text-xs text-muted-foreground">{t("exportServerHint")}</p>
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm"
disabled={!canExportReports || exporting !== null} disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("csv")} onClick={() => exportReport("csv")}
> >
@@ -1534,6 +1603,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm"
disabled={!canExportReports || exporting !== null} disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("excel")} onClick={() => exportReport("excel")}
> >
@@ -1568,8 +1638,8 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
) : null} ) : null}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pt-4"> <CardContent className="space-y-3 pt-3">
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950"> <div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
{t("preview.summaryScopeHint")} {t("preview.summaryScopeHint")}
</div> </div>
<Table id="reports-preview-table"> <Table id="reports-preview-table">
@@ -1605,10 +1675,12 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
<ReportJobsPanel canExport={canExportReports} refreshToken={jobRefreshToken} /> <ReportJobsPanel
canExport={canExportReports}
refreshToken={jobRefreshToken}
reportType={REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey]}
/>
</div> </div>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -11,9 +11,6 @@ export const DRAW_KEYS = {
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead", DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review", REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes", 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; } as const;
export const SETTLEMENT_KEYS = { export const SETTLEMENT_KEYS = {

View File

@@ -3,7 +3,6 @@
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen"; import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context"; 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 { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel"; import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel"; import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
@@ -15,7 +14,6 @@ function SystemSettingsContent() {
return ( return (
<div className="flex w-full max-w-none flex-col gap-6"> <div className="flex w-full max-w-none flex-col gap-6">
<DrawSettingsPanel /> <DrawSettingsPanel />
<CurrencyFormatSettingsPanel />
<SettlementSettingsPanel /> <SettlementSettingsPanel />
<AdminPageCard <AdminPageCard

View File

@@ -197,7 +197,7 @@ export function SettlementCenterShell(): React.ReactElement {
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}> <Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<DialogContent <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"> <DialogHeader className="border-b px-6 py-4">
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle> <DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>

View File

@@ -37,6 +37,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog"; 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 { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminTicketItemsData } from "@/types/api/admin-tickets"; import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
@@ -124,9 +125,13 @@ export function PlayerTicketsConsole(): React.ReactElement {
const formatTs = useAdminDateTimeFormatter(); const formatTs = useAdminDateTimeFormatter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim(); const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
const numberKeywordFromUrl = (searchParams.get("number") ?? "").trim();
const initialFilters: TicketFilters = { const initialFilters: TicketFilters = {
...emptyTicketFilters, ...emptyTicketFilters,
playerQuery: playerIdFromUrl, playerQuery: playerIdFromUrl,
drawNo: drawNoFromUrl,
numberKeyword: numberKeywordFromUrl,
}; };
const [draft, setDraft] = useState<TicketFilters>(initialFilters); const [draft, setDraft] = useState<TicketFilters>(initialFilters);
const [applied, setApplied] = useState<TicketFilters>(initialFilters); const [applied, setApplied] = useState<TicketFilters>(initialFilters);
@@ -340,6 +345,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableHead>{t("ticketNo")}</TableHead> <TableHead>{t("ticketNo")}</TableHead>
<AdminAgentIdentityHeads /> <AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads /> <AdminPlayerIdentityHeads />
<TableHead>{t("fundingMode", { ns: "players" })}</TableHead>
<TableHead>{t("orderNo")}</TableHead> <TableHead>{t("orderNo")}</TableHead>
<TableHead>{t("drawNo")}</TableHead> <TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead> <TableHead>{t("playCode")}</TableHead>
@@ -356,9 +362,9 @@ export function PlayerTicketsConsole(): React.ReactElement {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading && !data ? ( {loading && !data ? (
<AdminTableLoadingRow colSpan={17} /> <AdminTableLoadingRow colSpan={18} />
) : !data || data.items.length === 0 ? ( ) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={17} /> <AdminTableNoResourceRow colSpan={18} />
) : ( ) : (
data.items.map((row) => { data.items.map((row) => {
const winLabel = row.jackpot_win_amount > 0 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> <TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
<AdminAgentIdentityCells row={row} /> <AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells 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.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell> <TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>

View File

@@ -23,13 +23,13 @@ import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -55,7 +55,6 @@ import { useExportLabels } from "@/hooks/use-export-labels";
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges"; import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
import { formatAdminMinorUnits } from "@/lib/money"; import { formatAdminMinorUnits } from "@/lib/money";
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label"; import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { import type {
AdminPlayerWalletsData, AdminPlayerWalletsData,
@@ -323,13 +322,23 @@ export function TransferOrdersPanel(): React.ReactElement {
const exportLabels = useExportLabels("walletTransferOrders"); const exportLabels = useExportLabels("walletTransferOrders");
useAdminCurrencyCatalog(); useAdminCurrencyCatalog();
const formatTs = useAdminDateTimeFormatter(); 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 [data, setData] = useState<AdminTransferOrderListData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10); const [perPage, setPerPage] = useState(10);
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters); const initialTransferFilters: TransferFilters = {
const [applied, setApplied] = useState<TransferFilters>(emptyTransferFilters); ...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 [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
const doAction = async ( const doAction = async (
@@ -645,10 +654,18 @@ export function WalletTxnsPanel(): React.ReactElement {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10); const [perPage, setPerPage] = useState(10);
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim(); 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 () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -685,15 +702,6 @@ export function WalletTxnsPanel(): React.ReactElement {
void load(); void load();
}, [page, perPage, applied]); }, [page, perPage, applied]);
useAsyncEffect(() => {
if (!playerIdFromUrl) {
return;
}
setDraft((d) => ({ ...d, playerId: playerIdFromUrl }));
setApplied((d) => ({ ...d, playerId: playerIdFromUrl }));
setPage(1);
}, [playerIdFromUrl]);
const runSearch = () => { const runSearch = () => {
setApplied({ ...draft }); setApplied({ ...draft });
setPage(1); setPage(1);

View File

@@ -2,6 +2,8 @@ export type AdminAuditLogRow = {
id: number; id: number;
operator_type: string; operator_type: string;
operator_id: number; operator_id: number;
operator_label: string;
operator_subtitle: string | null;
module_code: string; module_code: string;
action_code: string; action_code: string;
target_type: string | null; target_type: string | null;

View File

@@ -65,6 +65,9 @@ export type AdminDrawShowData = {
sequence_no: number; sequence_no: number;
status: string; status: string;
hall_preview_status: string; hall_preview_status: string;
total_bet_minor?: number;
total_payout_minor?: number;
profit_loss_minor?: number;
start_time: string | null; start_time: string | null;
close_time: string | null; close_time: string | null;
draw_time: string | null; draw_time: string | null;

View File

@@ -36,6 +36,8 @@ export type AdminReconcileItemRow = {
difference_amount: number; difference_amount: number;
status: string; status: string;
resolved_at: string | null; resolved_at: string | null;
is_resolved?: boolean;
current_transfer_status?: string | null;
created_at: string | null; created_at: string | null;
}; };

View File

@@ -9,6 +9,8 @@ export type AdminTicketItemRow = {
site_player_id: string | null; site_player_id: string | null;
username: string | null; username: string | null;
nickname: string | null; nickname: string | null;
funding_mode?: string | null;
uses_credit?: boolean;
order_no: string | null; order_no: string | null;
draw_no: string | null; draw_no: string | null;
currency_code: string | null; currency_code: string | null;