From 7e65c53732f6ae09d228de75ff20829ed2feedce Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 8 Jun 2026 17:41:55 +0800 Subject: [PATCH] feat(admin, i18n): enhance reports, draws, config, and player workflows --- src/api/admin-jackpot.ts | 2 +- src/api/admin-report-jobs.ts | 1 + src/app/admin/(shell)/draws/[drawId]/page.tsx | 4 + .../admin/(shell)/reports/[category]/page.tsx | 13 +- src/app/admin/(shell)/reports/layout.tsx | 4 - src/app/admin/(shell)/reports/legacy/page.tsx | 17 +- src/app/admin/(shell)/reports/page.tsx | 10 +- .../admin/admin-no-resource-state.tsx | 1 + src/i18n/locales/en/config.json | 92 +++- src/i18n/locales/en/draws.json | 12 +- src/i18n/locales/en/jackpot.json | 16 +- src/i18n/locales/en/players.json | 1 + src/i18n/locales/en/reconcile.json | 42 +- src/i18n/locales/en/reports.json | 5 +- src/i18n/locales/en/tickets.json | 1 + src/i18n/locales/ne/config.json | 36 +- src/i18n/locales/ne/jackpot.json | 16 +- src/i18n/locales/zh/adminUsers.json | 2 +- src/i18n/locales/zh/common.json | 2 +- src/i18n/locales/zh/config.json | 92 +++- src/i18n/locales/zh/draws.json | 14 +- src/i18n/locales/zh/jackpot.json | 16 +- src/i18n/locales/zh/players.json | 1 + src/i18n/locales/zh/reconcile.json | 42 +- src/i18n/locales/zh/reports.json | 5 +- src/i18n/locales/zh/tickets.json | 1 + src/lib/admin-prd.ts | 2 +- .../agents/agent-line-detail-panel.tsx | 87 ++-- src/modules/agents/agents-console.tsx | 37 +- src/modules/agents/agents-players-panel.tsx | 452 ++++++++++++++++-- src/modules/audit/audit-logs-console.tsx | 9 +- .../config/doc/play-config-doc-screen.tsx | 318 +++++++++--- .../config/doc/risk-cap-doc-screen.tsx | 299 +++++++++--- .../dashboard/dashboard-analytics-panel.tsx | 9 +- .../dashboard/dashboard-current-draw-card.tsx | 37 +- src/modules/dashboard/dashboard-visuals.tsx | 18 +- src/modules/draws/draw-detail-console.tsx | 60 ++- src/modules/jackpot/jackpot-config-screen.tsx | 11 + src/modules/jackpot/jackpot-pools-console.tsx | 6 +- src/modules/players/player-detail-console.tsx | 45 +- src/modules/players/players-console.tsx | 114 ++++- src/modules/reconcile/reconcile-console.tsx | 157 +++++- src/modules/reports/report-jobs-panel.tsx | 11 +- src/modules/reports/reports-console.tsx | 416 +++++++++------- src/modules/reports/reports-subnav.tsx | 32 -- .../panels/currency-format-settings-panel.tsx | 151 ------ src/modules/settings/settings-keys.ts | 3 - .../settings/system-settings-screen.tsx | 2 - .../settlement/settlement-center-shell.tsx | 2 +- .../tickets/player-tickets-console.tsx | 15 +- src/modules/wallet/wallet-console.tsx | 40 +- src/types/api/admin-audit.ts | 2 + src/types/api/admin-draws.ts | 3 + src/types/api/admin-reconcile.ts | 2 + src/types/api/admin-tickets.ts | 2 + 55 files changed, 1986 insertions(+), 804 deletions(-) delete mode 100644 src/modules/reports/reports-subnav.tsx delete mode 100644 src/modules/settings/panels/currency-format-settings-panel.tsx diff --git a/src/api/admin-jackpot.ts b/src/api/admin-jackpot.ts index 6d96db4..d7362f8 100644 --- a/src/api/admin-jackpot.ts +++ b/src/api/admin-jackpot.ts @@ -51,7 +51,7 @@ export async function getAdminJackpotPoolAdjustments( export async function postAdminJackpotManualBurst( poolId: number, - body: { draw_id: number }, + body: { draw_id: number | string }, ): Promise<{ current_amount: number; burst_amount: number; diff --git a/src/api/admin-report-jobs.ts b/src/api/admin-report-jobs.ts index f54316e..c1c6292 100644 --- a/src/api/admin-report-jobs.ts +++ b/src/api/admin-report-jobs.ts @@ -15,6 +15,7 @@ const A = `/admin/report-jobs`; export async function getAdminReportJobs(params?: { page?: number; per_page?: number; + report_type?: string; }): Promise { return adminRequest.get(A, { params }); } diff --git a/src/app/admin/(shell)/draws/[drawId]/page.tsx b/src/app/admin/(shell)/draws/[drawId]/page.tsx index 9d1dd87..add4291 100644 --- a/src/app/admin/(shell)/draws/[drawId]/page.tsx +++ b/src/app/admin/(shell)/draws/[drawId]/page.tsx @@ -1,6 +1,10 @@ +import { buildPageMetadata } from "@/lib/page-metadata"; import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { DrawDetailConsole } from "@/modules/draws/draw-detail-console"; 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: { params: Promise<{ drawId: string }>; diff --git a/src/app/admin/(shell)/reports/[category]/page.tsx b/src/app/admin/(shell)/reports/[category]/page.tsx index 051f8dc..aa3b7cc 100644 --- a/src/app/admin/(shell)/reports/[category]/page.tsx +++ b/src/app/admin/(shell)/reports/[category]/page.tsx @@ -1,12 +1,7 @@ -import { notFound } from "next/navigation"; -import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; -import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd"; +import { notFound, redirect } from "next/navigation"; import { buildPageMetadata } from "@/lib/page-metadata"; -import { ReportsConsole } from "@/modules/reports/reports-console"; import type { Metadata } from "next"; -type Category = "profit" | "wallet" | "risk" | "audit"; - export const metadata: Metadata = buildPageMetadata("reports", "title"); export default async function AdminReportsCategoryPage({ @@ -18,9 +13,5 @@ export default async function AdminReportsCategoryPage({ if (!["profit", "wallet", "risk", "audit"].includes(category)) { notFound(); } - return ( - - - - ); + redirect("/admin/reports"); } diff --git a/src/app/admin/(shell)/reports/layout.tsx b/src/app/admin/(shell)/reports/layout.tsx index ecd3a14..0fc6977 100644 --- a/src/app/admin/(shell)/reports/layout.tsx +++ b/src/app/admin/(shell)/reports/layout.tsx @@ -1,13 +1,9 @@ import type { ReactNode } from "react"; import { ModuleScaffold } from "@/components/admin/module-scaffold"; -import { ReportsSubnav } from "@/modules/reports/reports-subnav"; export default function AdminReportsLayout({ children }: { children: ReactNode }) { return ( -
- -
{children}
); diff --git a/src/app/admin/(shell)/reports/legacy/page.tsx b/src/app/admin/(shell)/reports/legacy/page.tsx index 3853888..5856e63 100644 --- a/src/app/admin/(shell)/reports/legacy/page.tsx +++ b/src/app/admin/(shell)/reports/legacy/page.tsx @@ -1,16 +1,5 @@ -import { notFound } 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"; +import { redirect } from "next/navigation"; -export const metadata: Metadata = buildPageMetadata("reports", "legacyTitle"); - -export default function AdminReportsLegacyPage(): React.ReactElement { - return ( - - - - ); +export default function AdminReportsLegacyPage() { + redirect("/admin/reports/profit"); } diff --git a/src/app/admin/(shell)/reports/page.tsx b/src/app/admin/(shell)/reports/page.tsx index ac34cb8..9657258 100644 --- a/src/app/admin/(shell)/reports/page.tsx +++ b/src/app/admin/(shell)/reports/page.tsx @@ -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() { - redirect("/admin/reports/profit"); + return ( + + + + ); } diff --git a/src/components/admin/admin-no-resource-state.tsx b/src/components/admin/admin-no-resource-state.tsx index a6fed41..28ab89c 100644 --- a/src/components/admin/admin-no-resource-state.tsx +++ b/src/components/admin/admin-no-resource-state.tsx @@ -40,6 +40,7 @@ export function AdminNoResourceState({ alt="" width={compact ? 120 : 160} height={compact ? 120 : 160} + style={{ width: "auto", height: "auto" }} className={cn( "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]", diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index ef1f187..1589711 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -15,9 +15,9 @@ "risk-cap": "Payout caps" }, "rulesPlaysTitle": "Play rules", - "rulesOddsTitle": "Odds & rebate", - "rulesOddsDescription": "Odds matrix and rebate rates 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.", + "rulesOddsTitle": "Odds & base rebate", + "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 base rebate on the right. Agent/player rebate is added on top of this base, then save and publish.", "riskCapTitle": "Risk cap rules" }, "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.", "playsTitle": "Play rules", "playsDesc": "Play switches, limits, and rule copy", - "oddsTitle": "Odds & rebate", - "oddsDesc": "Odds matrix and rebate rates in one version stream", + "oddsTitle": "Odds & base rebate", + "oddsDesc": "Odds matrix and base rebate in one version stream", "jackpotTitle": "Jackpot", "jackpotDesc": "Pool parameters and ledger records", "riskCapTitle": "Risk cap rules", @@ -351,6 +351,20 @@ "readOnlyDraftHint": "Current version is read-only. Create a draft first.", "batchEnabledCount": "{{enabledCount}}/{{total}} enabled", "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": { "enable": "Enable", "disable": "Disable", @@ -362,6 +376,13 @@ "en": "English", "ne": "Nepali" }, + "categories": { + "standard": "Standard", + "attribute": "Attribute", + "position": "Position", + "box": "Box", + "jackpot": "Jackpot" + }, "table": { "playCode": "Play code", "category": "Category", @@ -413,7 +434,7 @@ }, "currentSelection": "Selection: {{category}} / {{play}}", "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": { "prizeScope": "Prize scope", "multiplier": "Odds multiplier" @@ -452,11 +473,11 @@ "loadingDetails": "Loading details…", "multiplier": "Multiplier x{{value}} · {{currency}}", "missingScopeRow": "Missing {{scope}} row. Check seed or version data.", - "rebateRate": "Rebate rate (%)", - "rebateRateHint": "Writes rebate_rate to all prize scopes under this play type.", + "rebateRate": "Base rebate rate (%)", + "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": { "multiplier": "Enter odds multiplier", - "rebateRate": "Enter rebate rate" + "rebateRate": "Enter base rebate rate" }, "publishFailed": "Publish failed", "createDraftSuccess": "Created draft v{{version}}", @@ -481,33 +502,33 @@ } }, "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", - "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.", "publishLabel": "Publish", "publishSuccess": "Published odds version with rebate", "publishFailed": "Publish failed", "publishDialog": { - "title": "Publish rebate/odds version?", - "description": "After publish, rebate calculation applies to new tickets.", + "title": "Publish base rebate/odds version?", + "description": "After publish, the base rebate applies to new tickets. Agent/player extra rebate is still added on top.", "confirm": "Confirm publish" }, "createDraftSuccess": "Created draft v{{version}}", "createDraftFailed": "Failed to create draft", "deleteFailed": "Delete failed", "editingVersion": "Editing version v{{version}} · {{status}}", - "readOnlyHint": "Create a draft before editing 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.", + "readOnlyHint": "Create a draft before editing base rebate.", + "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": { - "d2": "2D rebate rate (%)", - "d3": "3D rebate rate (%)", - "d4": "4D rebate rate (%)" + "d2": "2D base rebate rate (%)", + "d3": "3D base rebate rate (%)", + "d4": "4D base rebate rate (%)" }, "placeholders": { - "d2": "Enter 2D rebate", - "d3": "Enter 3D rebate", - "d4": "Enter 4D rebate" + "d2": "Enter 2D base rebate", + "d3": "Enter 3D base rebate", + "d4": "Enter 4D base rebate" }, "winEnjoy": { "label": "Deduct rebate on winning payouts", @@ -527,6 +548,8 @@ "validation": { "requireAtLeastOne": "At least one cap row is required", "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}}", "enterValidCapAmount": "Enter a valid cap amount" }, @@ -547,14 +570,37 @@ "defaultCap": { "title": "Default cap", "description": "Numbers without a special cap use this default cap template.", - "fieldLabel": "Cap amount (minor unit)" + "fieldLabel": "Cap amount (major unit)" }, "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…", "noDetailRows": "No detail rows.", "table": { + "scope": "Scope", "number": "Number", "capAmount": "Cap amount", "used": "Used", diff --git a/src/i18n/locales/en/draws.json b/src/i18n/locales/en/draws.json index 6777052..b19484c 100644 --- a/src/i18n/locales/en/draws.json +++ b/src/i18n/locales/en/draws.json @@ -30,9 +30,9 @@ "queryDraw": "Search draw", "reset": "Reset", "fuzzyDrawNo": "Fuzzy draw no.", - "viewDetails": "View details", + "viewDetails": "View draw details", "editDraw": { - "action": "Edit", + "action": "Edit draw", "title": "Edit draw", "description": "Draw {{drawNo}} · edit times in {{tz}}", "drawNoPlaceholder": "Enter draw number, for example 20260526-008", @@ -42,7 +42,7 @@ "failed": "Update failed" }, "deleteDraw": { - "action": "Delete", + "action": "Delete draw", "title": "Delete draw", "description": "Delete draw {{drawNo}}? Only for pending draws with no bets. This cannot be undone.", "success": "Draw deleted", @@ -57,13 +57,19 @@ "invalidDrawId": "Invalid draw ID", "loadFailed": "Failed to load. Check login and API configuration.", "drawDetail": "Draw details", + "backToList": "Back to draw list", "detailSubtitle": "{{date}} · Round {{seq}}", + "overviewTitle": "Draw overview", + "overviewBetTotal": "Total bet", + "overviewPayoutTotal": "Total payout", + "overviewProfitLoss": "Profit/Loss", "scheduleTitle": "Schedule", "resultBatchesTitle": "Result batches", "batchSummaryTotal": "{{count}} batch(es)", "batchSummaryPending": "{{count}} pending", "batchSummaryPublished": "{{count}} published", "noResultBatchesYet": "No result batches yet.", + "reviewQueueHint": "After results are generated, continue in Review & publish.", "goToReviewTab": "Review & publish", "businessDate": "Business date", "sequenceNo": "Sequence no.", diff --git a/src/i18n/locales/en/jackpot.json b/src/i18n/locales/en/jackpot.json index d8741fe..56d8e5b 100644 --- a/src/i18n/locales/en/jackpot.json +++ b/src/i18n/locales/en/jackpot.json @@ -3,17 +3,21 @@ "configTitle": "Jackpot pool configuration", "pageDescription": "Maintain per-currency pool parameters; contribution and payout logs are below.", "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", "recordsSectionDescription": "Filter payout and contribution entries (read-only).", "loadFailed": "Failed to load", "saveSuccess": "Saved", "saveFailed": "Save failed", - "invalidDrawId": "Enter a valid draw number", + "invalidDrawId": "Enter a valid draw ID or draw number", "manualBurstSuccess": "Jackpot burst triggered manually", "manualBurstFailed": "Manual burst failed", "noPoolData": "No pool data", "displayBalance": "Display balance {{amount}}", - "currentAmount": "Current pool balance (minor unit)", + "currentAmount": "Current pool balance (major unit)", "balanceAdjustmentTitle": "Balance adjustment", "balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.", "adjustmentDirection": "Direction", @@ -33,13 +37,13 @@ "recentAdjustments": "Recent adjustments", "contributionRate": "Contribution rate (%)", "contributionRatePlaceholder": "e.g. 2 = 2%", - "triggerThreshold": "Burst threshold (minor unit)", + "triggerThreshold": "Burst threshold (major unit)", "triggerThresholdPlaceholder": "Enter burst threshold", "payoutRate": "Burst payout rate (%)", "payoutRatePlaceholder": "e.g. 5 = 5%", "forceTriggerGap": "Force burst gap (settled 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", "comboTriggerPlays": "Combo trigger plays (comma separated)", "comboTriggerPlaysPlaceholder": "Enter play codes separated by commas, for example straight,ibox", @@ -48,8 +52,8 @@ "enabled": "Enabled", "saving": "Saving…", "save": "Save", - "manualBurstDrawId": "Draw ID for manual burst", - "manualBurstHint": "Super admin only. Requires a settled draw with first-prize winners. Pool release follows the configured payout rate.", + "manualBurstDrawId": "Draw for manual burst (ID or number)", + "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?", "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…", diff --git a/src/i18n/locales/en/players.json b/src/i18n/locales/en/players.json index d455654..16e1f39 100644 --- a/src/i18n/locales/en/players.json +++ b/src/i18n/locales/en/players.json @@ -7,6 +7,7 @@ "detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}", "tabOverview": "Overview", "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", "tabCreditLedger": "Credit ledger", "tabTransferOrders": "Transfer orders", diff --git a/src/i18n/locales/en/reconcile.json b/src/i18n/locales/en/reconcile.json index 8e2adf9..3c06d81 100644 --- a/src/i18n/locales/en/reconcile.json +++ b/src/i18n/locales/en/reconcile.json @@ -29,7 +29,7 @@ "createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.", "createSummaryPending": "Choose a complete reconcile date range before creating a job.", "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", "jobNo": "Job no.", "type": "Type", @@ -42,10 +42,18 @@ "createdAt": "Created at", "operate": "Action", "view": "View", - "detailsTitle": "Job details", + "viewDetails": "View discrepancy details", + "detailsTitle": "Discrepancy details", "sideARef": "Lottery ref", "sideBRef": "Main site ref", "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", "noDetails": "No details", "playerSearch": "Player (optional)", @@ -63,5 +71,33 @@ "statusFailed": "Failed", "itemMismatch": "Mismatch", "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" } diff --git a/src/i18n/locales/en/reports.json b/src/i18n/locales/en/reports.json index 2bf8f0c..91b8f73 100644 --- a/src/i18n/locales/en/reports.json +++ b/src/i18n/locales/en/reports.json @@ -35,6 +35,7 @@ "loadFailed": "Failed to load export jobs", "downloadSuccess": "Downloaded {{jobNo}}", "downloadFailed": "Download failed", + "currentReportHint": "Only export tasks for the currently selected report are shown here.", "columns": { "jobNo": "Job no.", "report": "Report", @@ -191,6 +192,8 @@ "stats": { "records": "Records", "currentPage": "This page", + "notQueried": "Not queried", + "notSet": "Not set", "drawNo": "Draw no.", "currency": "Currency", "exportRows": "Export rows", @@ -225,12 +228,10 @@ "status": "Status", "createdAt": "Created at" }, - "legacyTitle": "Legacy wallet reports", "categories": { "all": "All", "profit": "Profit", "wallet": "Funds", - "legacy": "Legacy", "risk": "Risk", "audit": "Audit" }, diff --git a/src/i18n/locales/en/tickets.json b/src/i18n/locales/en/tickets.json index 48a8f6a..9ae6328 100644 --- a/src/i18n/locales/en/tickets.json +++ b/src/i18n/locales/en/tickets.json @@ -25,6 +25,7 @@ "actualDeduct": "Actual deduct", "status": "Status", "actions": "Actions", + "viewTicketInList": "View this ticket", "failReason": "Fail reason", "winAmount": "Win amount", "placedAt": "Placed at", diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json index 9fe9572..314bf8f 100644 --- a/src/i18n/locales/ne/config.json +++ b/src/i18n/locales/ne/config.json @@ -345,6 +345,13 @@ "en": "English", "ne": "नेपाली" }, + "categories": { + "standard": "मानक", + "attribute": "विशेषता", + "position": "स्थिति", + "box": "बक्स", + "jackpot": "ज्याकपोट" + }, "table": { "playCode": "खेल कोड", "category": "श्रेणी", @@ -510,6 +517,8 @@ "validation": { "requireAtLeastOne": "कम्तीमा एक क्याप row आवश्यक छ", "defaultGreaterThanZero": "पूर्वनिर्धारित क्याप रकम 0 भन्दा ठूलो हुनुपर्छ", + "defaultCannotBindDraw": "पूर्वनिर्धारित क्याप कुनै निश्चित draw मा बाँध्न मिल्दैन", + "specialGreaterThanZero": "विशेष क्याप रकम 0 भन्दा ठूलो हुनुपर्छ: {{number}}", "numberMustBe4Digits": "नम्बर 4 अङ्कको हुनुपर्छ: {{number}}", "enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्" }, @@ -530,14 +539,37 @@ "defaultCap": { "title": "पूर्वनिर्धारित क्याप", "description": "विशेष क्याप नभएका नम्बरहरूमा यही पूर्वनिर्धारित क्याप टेम्प्लेट लागू हुन्छ।", - "fieldLabel": "क्याप रकम (सानो एकाइ)" + "fieldLabel": "क्याप रकम (मुख्य एकाइ)" }, "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": "विवरण लोड हुँदैछ…", "noDetailRows": "विवरण row छैन।", "table": { + "scope": "दायरा", "number": "नम्बर", "capAmount": "क्याप रकम", "used": "प्रयोग भएको", diff --git a/src/i18n/locales/ne/jackpot.json b/src/i18n/locales/ne/jackpot.json index 9945cce..bebc370 100644 --- a/src/i18n/locales/ne/jackpot.json +++ b/src/i18n/locales/ne/jackpot.json @@ -3,17 +3,21 @@ "configTitle": "Jackpot पूल कन्फिगरेसन", "pageDescription": "मुद्रा अनुसार पूल प्यारामिटर; तल योगदान र पेआउट लग देख्नुहोस्।", "poolsSectionDescription": "योगदान दर, बर्स्ट थ्रेसहोल्ड, स्विच र म्यानुअल बर्स्ट।", + "rulesTitle": "नियम जानकारी", + "rulesJoin": "सफलतापूर्वक पेश भएका र न्यूनतम सहभागिता बेट रकम पुगेका लाइनहरू मात्र पूलमा योगदान गर्छन्।", + "rulesBurst": "थ्रेसहोल्ड पुगेपछि, जबर्जस्ती बर्स्ट अन्तर पुगेपछि, वा तोकिएको कम्बो ट्रिगर भएपछि पूल रिलिज हुन्छ।", + "rulesManual": "म्यानुअल बर्स्ट सुपर एडमिनको fallback मात्र हो। यसमा संख्यात्मक draw ID वा draw number दुवै लेख्न सकिन्छ।", "recordsSectionTitle": "योगदान र पेआउट लग", "recordsSectionDescription": "पेआउट र योगदान प्रविष्टि फिल्टर (पढ्न मात्र)।", "loadFailed": "लोड असफल भयो", "saveSuccess": "सुरक्षित भयो", "saveFailed": "सुरक्षित गर्न असफल", - "invalidDrawId": "मान्य ड्रअ नम्बर लेख्नुहोस्", + "invalidDrawId": "मान्य draw ID वा draw number लेख्नुहोस्", "manualBurstSuccess": "Jackpot म्यानुअल रूपमा ट्रिगर भयो", "manualBurstFailed": "म्यानुअल बर्स्ट असफल भयो", "noPoolData": "पूल डाटा छैन", "displayBalance": "प्रदर्शित ब्यालेन्स {{amount}}", - "currentAmount": "हालको पूल ब्यालेन्स (सानो एकाइ)", + "currentAmount": "हालको पूल ब्यालेन्स (मुख्य एकाइ)", "balanceAdjustmentTitle": "ब्यालेन्स समायोजन", "balanceAdjustmentHint": "कारण अनिवार्य; प्रत्येक परिवर्तन समायोजन लेजरमा लेखिन्छ। Save बाट सिधै ब्यालेन्स मिलाउन मिल्दैन।", "adjustmentDirection": "दिशा", @@ -33,13 +37,13 @@ "recentAdjustments": "भर्खरका समायोजन", "contributionRate": "योगदान अनुपात 0-1", "contributionRatePlaceholder": "योगदान अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.02", - "triggerThreshold": "बर्स्ट थ्रेसहोल्ड (सानो एकाइ)", + "triggerThreshold": "बर्स्ट थ्रेसहोल्ड (मुख्य एकाइ)", "triggerThresholdPlaceholder": "ट्रिगर थ्रेसहोल्ड प्रविष्ट गर्नुहोस्", "payoutRate": "बर्स्ट भुक्तानी अनुपात 0-1", "payoutRatePlaceholder": "पेआउट अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.05", "forceTriggerGap": "बलपूर्वक बर्स्ट अन्तर (सेटल ड्रअ)", "forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्", - "minBetAmount": "न्यूनतम बेट रकम (सानो एकाइ)", + "minBetAmount": "न्यूनतम सहभागिता बेट रकम (मुख्य एकाइ)", "minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्", "comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)", "comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox", @@ -48,8 +52,8 @@ "enabled": "खुला", "saving": "सुरक्षित हुँदैछ…", "save": "सुरक्षित गर्नुहोस्", - "manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ ID", - "manualBurstHint": "सुपर एडमिन मात्र। बसेको ड्रअ र प्रथम पुरस्कार विजेताहरू चाहिन्छ। पेआउट दर अनुसार वितरण हुन्छ।", + "manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ (ID वा नम्बर)", + "manualBurstHint": "सुपर एडमिन मात्र। संख्यात्मक draw ID वा draw number दुवै लेख्न सकिन्छ। बसेको ड्रअ र प्रथम पुरस्कार विजेताहरू चाहिन्छ। पेआउट दर अनुसार वितरण हुन्छ।", "manualBurstConfirmTitle": "म्यानुअल बर्स्ट पुष्टि गर्ने?", "manualBurstConfirmDescription": "ड्रअ {{drawId}} का प्रथम पुरस्कार विजेताहरूलाई Jackpot वितरण गरिनेछ।", "processing": "प्रक्रियामा…", diff --git a/src/i18n/locales/zh/adminUsers.json b/src/i18n/locales/zh/adminUsers.json index d479a93..0e09e5f 100644 --- a/src/i18n/locales/zh/adminUsers.json +++ b/src/i18n/locales/zh/adminUsers.json @@ -168,7 +168,7 @@ "draws": "期号列表", "config": "运营配置", "rules_plays": "投注规则", - "rules_odds": "赔率与回水", + "rules_odds": "赔率与基础回水", "risk_cap": "限额版本", "risk": "风控", "settlement": "结算", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index dd1a406..53a7441 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -150,7 +150,7 @@ "reports": "报表中心", "draws": "期号列表", "rules_plays": "投注规则", - "rules_odds": "赔率与回水", + "rules_odds": "赔率与基础回水", "rules": "投注规则", "risk_cap": "限额版本", "risk": "风控中心", diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index 40c2dc6..f32bad6 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -15,9 +15,9 @@ "risk-cap": "赔付封顶" }, "rulesPlaysTitle": "投注规则", - "rulesOddsTitle": "赔率与回水", - "rulesOddsDescription": "赔率矩阵与回水比例在同一页维护,共用赔率版本线。", - "rulesOddsDescriptionShort": "左侧选玩法,右侧改赔率与回水;修改后记得保存草稿并发布。", + "rulesOddsTitle": "赔率与基础回水", + "rulesOddsDescription": "赔率矩阵与基础回水在同一页维护,共用赔率版本线。", + "rulesOddsDescriptionShort": "左侧选玩法,右侧修改赔率与基础回水;代理/玩家回水需在此基础上叠加,修改后记得保存草稿并发布。", "riskCapTitle": "限额版本" }, "hub": { @@ -25,8 +25,8 @@ "description": "按业务域进入玩法、赔率回水、奖池与限额配置;接入站点在侧栏「平台管理 → 接入配置」。", "playsTitle": "投注规则", "playsDesc": "玩法开关、限额与规则说明", - "oddsTitle": "赔率与回水", - "oddsDesc": "赔率矩阵与回水比例,版本一体发布", + "oddsTitle": "赔率与基础回水", + "oddsDesc": "赔率矩阵与基础回水,版本一体发布", "jackpotTitle": "奖池", "jackpotDesc": "奖池参数与进账流水", "riskCapTitle": "限额版本", @@ -360,6 +360,20 @@ "readOnlyDraftHint": "当前版本为只读,请先创建草稿。", "batchEnabledCount": "{{enabledCount}}/{{total}} 已开启", "noPlayTypes": "暂无玩法", + "filters": { + "sectionTitle": "筛选玩法", + "sectionDescription": "先缩小范围,再进行批量开关或逐项修改。", + "keyword": "搜索玩法", + "keywordPlaceholder": "按玩法编码、显示名或分类筛选", + "category": "分类", + "status": "状态", + "allCategories": "全部分类", + "allStatuses": "全部状态", + "uncategorized": "未分类", + "reset": "清空筛选", + "empty": "没有匹配的玩法", + "groupCount": "{{count}} 个玩法" + }, "actions": { "enable": "开启", "disable": "关闭", @@ -371,6 +385,13 @@ "en": "English", "ne": "नेपाली" }, + "categories": { + "standard": "标准类", + "attribute": "属性类", + "position": "位置类", + "box": "包号类", + "jackpot": "奖池类" + }, "table": { "playCode": "玩法编码", "category": "分类", @@ -422,7 +443,7 @@ }, "currentSelection": "当前选择:{{category}} / {{play}}", "playSelectPlaceholder": "选择玩法", - "readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与回水。", + "readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与基础回水。", "table": { "prizeScope": "奖级范围", "multiplier": "赔率倍数" @@ -461,11 +482,11 @@ "loadingDetails": "正在加载详情…", "multiplier": "倍数 x{{value}} · {{currency}}", "missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。", - "rebateRate": "回水比例 (%)", - "rebateRateHint": "会把 rebate_rate 写入该玩法下所有奖级范围。", + "rebateRate": "基础回水比例 (%)", + "rebateRateHint": "这里维护的是平台基础回水,会把 rebate_rate 写入该玩法下所有奖级范围;代理/玩家回水需在此基础上叠加。", "placeholders": { "multiplier": "请输入赔率倍数", - "rebateRate": "请输入返点比例" + "rebateRate": "请输入基础回水比例" }, "publishFailed": "发布失败", "createDraftSuccess": "已创建草稿 v{{version}}", @@ -490,33 +511,33 @@ } }, "rebate": { - "sectionHint": "回水比例写入赔率版本;请先在上方选择或创建赔率草稿。", + "sectionHint": "这里配置的是基础回水,写入赔率版本;请先在上方选择或创建赔率草稿。", "lazyLoadHint": "向下滚动至回水区域后加载", - "embeddedVersionHint": "回水与上方赔率共用版本线,请在「赔率」区块切换版本。", + "embeddedVersionHint": "基础回水与上方赔率共用版本线,请在「赔率」区块切换版本。", "sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。", "publishLabel": "发布", "publishSuccess": "已发布带回水的赔率版本", "publishFailed": "发布失败", "publishDialog": { - "title": "确认发布回水/赔率版本?", - "description": "发布后将影响后续新注单的回水计算。", + "title": "确认发布基础回水/赔率版本?", + "description": "发布后将影响后续新注单的基础回水计算;代理/玩家额外回水仍在此基础上叠加。", "confirm": "确认发布" }, "createDraftSuccess": "已创建草稿 v{{version}}", "createDraftFailed": "创建草稿失败", "deleteFailed": "删除失败", "editingVersion": "当前编辑版本 v{{version}} · {{status}}", - "readOnlyHint": "修改回水前请先创建草稿。", - "dimensionRatesMixedHint": "检测到同一维度(2D/3D/4D)内各玩法的首奖级回水比例不完全相同:上方三个百分比输入仅展示按玩法编码排序后的第一个有值示例,实际回水请以下方表格各行数据为准;使用批量输入会先按维度覆盖为同一比例。", + "readOnlyHint": "修改基础回水前请先创建草稿。", + "dimensionRatesMixedHint": "检测到同一维度(2D/3D/4D)内各玩法的首奖级基础回水不完全相同:上方三个百分比输入仅展示按玩法编码排序后的第一个有值示例,实际基础回水请以下方表格各行数据为准;使用批量输入会先按维度覆盖为同一比例。", "fields": { - "d2": "2D 回水比例 (%)", - "d3": "3D 回水比例 (%)", - "d4": "4D 回水比例 (%)" + "d2": "2D 基础回水比例 (%)", + "d3": "3D 基础回水比例 (%)", + "d4": "4D 基础回水比例 (%)" }, "placeholders": { - "d2": "请输入 2D 返点", - "d3": "请输入 3D 返点", - "d4": "请输入 4D 返点" + "d2": "请输入 2D 基础回水", + "d3": "请输入 3D 基础回水", + "d4": "请输入 4D 基础回水" }, "winEnjoy": { "label": "中奖注单结算时再扣回水", @@ -536,6 +557,8 @@ "validation": { "requireAtLeastOne": "至少需要一条封顶配置", "defaultGreaterThanZero": "默认封顶金额必须大于 0", + "defaultCannotBindDraw": "默认封顶不能绑定具体期号", + "specialGreaterThanZero": "特殊封顶金额必须大于 0:{{number}}", "numberMustBe4Digits": "号码必须为 4 位数字:{{number}}", "enterValidCapAmount": "请输入有效的封顶金额" }, @@ -556,14 +579,37 @@ "defaultCap": { "title": "默认封顶", "description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。", - "fieldLabel": "封顶金额(最小单位)" + "fieldLabel": "封顶金额(主币单位)" }, "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": "正在加载详情…", "noDetailRows": "暂无明细行。", "table": { + "scope": "作用范围", "number": "号码", "capAmount": "封顶金额", "used": "已占用", diff --git a/src/i18n/locales/zh/draws.json b/src/i18n/locales/zh/draws.json index 7a565a6..98da4ec 100644 --- a/src/i18n/locales/zh/draws.json +++ b/src/i18n/locales/zh/draws.json @@ -30,9 +30,9 @@ "queryDraw": "查询期号", "reset": "重置", "fuzzyDrawNo": "模糊匹配期号", - "viewDetails": "查看详情", + "viewDetails": "查看期号详情", "editDraw": { - "action": "编辑", + "action": "编辑期号", "title": "编辑期号", "description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑", "drawNoPlaceholder": "请输入期号,如 20260526-008", @@ -42,7 +42,7 @@ "failed": "更新失败" }, "deleteDraw": { - "action": "删除", + "action": "删除期号", "title": "删除期号", "description": "确定删除期号 {{drawNo}}?仅适用于未开始且无注单的记录,删除后不可恢复。", "success": "期号已删除", @@ -56,14 +56,20 @@ "listActionsHint": "未开始且无注单:可编辑、删除;可下注/封盘/待开奖且无注单:可取消(见详情页更多操作)。", "invalidDrawId": "无效的期号 ID", "loadFailed": "加载失败,请检查登录与 API 配置", - "drawDetail": "开奖详情", + "drawDetail": "期号详情", + "backToList": "返回期号列表", "detailSubtitle": "{{date}} · 第 {{seq}} 期", + "overviewTitle": "期号概览", + "overviewBetTotal": "下注总额", + "overviewPayoutTotal": "派彩总额", + "overviewProfitLoss": "盈亏", "scheduleTitle": "时间安排", "resultBatchesTitle": "开奖批次", "batchSummaryTotal": "共 {{count}} 批", "batchSummaryPending": "待审 {{count}}", "batchSummaryPublished": "已发 {{count}}", "noResultBatchesYet": "尚无开奖批次。", + "reviewQueueHint": "结果生成后,可前往审核与发布处理。", "goToReviewTab": "去审核与发布", "businessDate": "业务日", "sequenceNo": "流水序号", diff --git a/src/i18n/locales/zh/jackpot.json b/src/i18n/locales/zh/jackpot.json index d7268f8..7fcb2f3 100644 --- a/src/i18n/locales/zh/jackpot.json +++ b/src/i18n/locales/zh/jackpot.json @@ -3,17 +3,21 @@ "configTitle": "奖池配置", "pageDescription": "维护各币种奖池参数,下方可查询蓄水与派彩流水。", "poolsSectionDescription": "蓄水比例、爆池阈值、开关与手动爆池。", + "rulesTitle": "规则说明", + "rulesJoin": "只有提交成功且满足最低参与下注额的注项,才会按蓄水比例进入奖池。", + "rulesBurst": "奖池会在达到爆池阈值、达到强制爆池间隔,或命中指定组合触发玩法时释放。", + "rulesManual": "手动爆池仅限超管兜底使用,可填写后台期号数字 ID 或期号编码。", "recordsSectionTitle": "蓄水与派彩流水", "recordsSectionDescription": "按条件筛选派彩记录与蓄水明细,只读查询。", "loadFailed": "加载失败", "saveSuccess": "已保存", "saveFailed": "保存失败", - "invalidDrawId": "请填写有效的期号 ID", + "invalidDrawId": "请填写有效的期号 ID 或期号编码", "manualBurstSuccess": "已手动触发爆池", "manualBurstFailed": "手动爆池失败", "noPoolData": "暂无奖池数据", "displayBalance": "展示余额 {{amount}}", - "currentAmount": "当前池余额(最小单位)", + "currentAmount": "当前池余额(主币单位)", "balanceAdjustmentTitle": "余额调整", "balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。", "adjustmentDirection": "方向", @@ -33,13 +37,13 @@ "recentAdjustments": "最近调整记录", "contributionRate": "蓄水比例 (%)", "contributionRatePlaceholder": "如 2 表示 2%", - "triggerThreshold": "爆池阈值(最小单位)", + "triggerThreshold": "爆池阈值(主币单位)", "triggerThresholdPlaceholder": "请输入触发阈值", "payoutRate": "爆池派彩比例 (%)", "payoutRatePlaceholder": "如 5 表示 5%", "forceTriggerGap": "强制爆池间隔(已结算期数)", "forceTriggerGapPlaceholder": "请输入强制触发间隔期数", - "minBetAmount": "最低下注额(最小单位)", + "minBetAmount": "最低参与下注额(主币单位)", "minBetAmountPlaceholder": "请输入最低下注金额", "comboTriggerPlays": "组合触发玩法(逗号分隔)", "comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox", @@ -50,8 +54,8 @@ "save": "保存", "confirmSavePoolTitle": "确认保存奖池配置?", "confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含池余额);余额请使用「余额调整」。", - "manualBurstDrawId": "手动爆池期号 ID", - "manualBurstHint": "仅超级管理员可在紧急情况下触发;须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。", + "manualBurstDrawId": "手动爆池期号(ID 或编码)", + "manualBurstHint": "仅超级管理员可在紧急情况下触发;可填写后台期号数字 ID 或期号编码。须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。", "manualBurstConfirmTitle": "确认手动爆池?", "manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot,并扣减奖池余额。此操作不可自动撤销。", "processing": "处理中…", diff --git a/src/i18n/locales/zh/players.json b/src/i18n/locales/zh/players.json index 2067d3c..ed79479 100644 --- a/src/i18n/locales/zh/players.json +++ b/src/i18n/locales/zh/players.json @@ -7,6 +7,7 @@ "detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}", "tabOverview": "概览", "tabTickets": "注单", + "ticketTableHint": "这里展示该玩家最近注单;如需查看完整上下文,可通过行内操作跳到总注单列表继续排查。", "tabWalletTxns": "钱包流水", "tabCreditLedger": "信用流水", "tabTransferOrders": "转账单", diff --git a/src/i18n/locales/zh/reconcile.json b/src/i18n/locales/zh/reconcile.json index 673d42d..ee4311a 100644 --- a/src/i18n/locales/zh/reconcile.json +++ b/src/i18n/locales/zh/reconcile.json @@ -29,7 +29,7 @@ "createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。", "createSummaryPending": "请选择完整的对账日期范围后,再创建任务。", "jobsTitle": "对账任务", - "jobsDesc": "在右侧操作中查看差异明细与分页。", + "jobsDesc": "在右侧操作中查看差异明细与分页结果。", "refresh": "刷新", "jobNo": "任务号", "type": "类型", @@ -42,10 +42,18 @@ "createdAt": "创建时间", "operate": "操作", "view": "查看", - "detailsTitle": "任务明细", + "viewDetails": "查看差异明细", + "detailsTitle": "差异明细", "sideARef": "彩票侧引用", "sideBRef": "主站侧引用", "differenceAmount": "差额(分)", + "itemResult": "检查结果", + "diagnosis": "异常说明", + "suggestedAction": "建议处理方向", + "processingStatus": "处理状态", + "quickAccess": "快捷处理", + "openTransferOrder": "查看转账单", + "openWalletTxn": "查看钱包流水", "detectedAt": "发现时间", "noDetails": "无明细", "playerSearch": "指定玩家(可选)", @@ -63,5 +71,33 @@ "statusFailed": "失败", "itemMismatch": "不一致", "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": "已结案" } diff --git a/src/i18n/locales/zh/reports.json b/src/i18n/locales/zh/reports.json index 4986972..4a6bf22 100644 --- a/src/i18n/locales/zh/reports.json +++ b/src/i18n/locales/zh/reports.json @@ -35,6 +35,7 @@ "loadFailed": "任务列表加载失败", "downloadSuccess": "已下载 {{jobNo}}", "downloadFailed": "下载失败", + "currentReportHint": "这里只显示当前所选报表的导出任务,避免和其他报表任务混在一起。", "columns": { "jobNo": "任务编号", "report": "报表", @@ -191,6 +192,8 @@ "stats": { "records": "记录数", "currentPage": "当前页", + "notQueried": "未查询", + "notSet": "未设置", "drawNo": "期号", "currency": "币种", "exportRows": "导出行数", @@ -225,12 +228,10 @@ "status": "状态", "createdAt": "创建时间" }, - "legacyTitle": "旧版钱包报表", "categories": { "all": "全部", "profit": "盈亏", "wallet": "资金", - "legacy": "旧版口径", "risk": "风控", "audit": "审计" }, diff --git a/src/i18n/locales/zh/tickets.json b/src/i18n/locales/zh/tickets.json index 5d659e8..3efaa91 100644 --- a/src/i18n/locales/zh/tickets.json +++ b/src/i18n/locales/zh/tickets.json @@ -25,6 +25,7 @@ "actualDeduct": "实扣", "status": "状态", "actions": "操作", + "viewTicketInList": "查看该注单", "failReason": "失败原因", "winAmount": "中奖", "placedAt": "下单时间", diff --git a/src/lib/admin-prd.ts b/src/lib/admin-prd.ts index d6e36d2..2183004 100644 --- a/src/lib/admin-prd.ts +++ b/src/lib/admin-prd.ts @@ -101,7 +101,7 @@ export const PRD_WALLET_PLAYER_ACCESS_ANY = [ ...PRD_WALLET_TX_ACCESS_ANY, ] as const; -/** 赔率与回水配置页 */ +/** 赔率与基础回水配置页 */ export const PRD_RULES_ODDS_ACCESS_ANY = [ PRD_ODDS_MANAGE, PRD_ODDS_VIEW, diff --git a/src/modules/agents/agent-line-detail-panel.tsx b/src/modules/agents/agent-line-detail-panel.tsx index 90e5147..02e86c2 100644 --- a/src/modules/agents/agent-line-detail-panel.tsx +++ b/src/modules/agents/agent-line-detail-panel.tsx @@ -59,6 +59,7 @@ export type AgentLineDetailPanelProps = { canViewPlayersTab: boolean; canManageNode: boolean; canCreateChild: boolean; + canCreateChildAgent: boolean; canDeleteChild: (node: AgentNodeRow) => boolean; onEditChild: (node: AgentNodeRow) => void; onDeleteChild: (node: AgentNodeRow) => void; @@ -88,6 +89,7 @@ export function AgentLineDetailPanel({ canViewPlayersTab, canManageNode, canCreateChild, + canCreateChildAgent, canDeleteChild, onEditChild, onDeleteChild, @@ -155,6 +157,17 @@ export function AgentLineDetailPanel({ siteLabel && siteCode.trim() !== "" ? `${siteLabel} (${siteCode})` : siteLabel ?? siteCode; + const codeText = typeof node.code === "string" ? node.code.trim() : ""; + const usernameText = typeof node.username === "string" ? node.username.trim() : ""; + const childActionHint = canCreateChild + ? null + : canCreateChildAgent + ? t("lineUi.addChildUnavailableHint", { + defaultValue: "当前代理未开启“允许创建下级代理”,如需新增请先调整该代理配置。", + }) + : t("lineUi.addChildNoPermissionHint", { + defaultValue: "当前账号没有为该节点创建下级代理的权限。", + }); return (
@@ -171,21 +184,25 @@ export function AgentLineDetailPanel({ : t("common:status.disabled", { defaultValue: "停用" })}
-

- {node.code} - {node.username ? ( - <> - · - {node.username} - - ) : null} - {parentName ? ( - <> - · - {t("parentAgent", { defaultValue: "上级代理" })} {parentName} - - ) : null} -

+ {(codeText !== "" || usernameText !== "" || parentName) ? ( +
+ {codeText !== "" ? ( + + {t("lineUi.agentCode", { defaultValue: "编码" })} {codeText} + + ) : null} + {usernameText !== "" ? ( + + {t("lineUi.agentUsername", { defaultValue: "账号" })} {usernameText} + + ) : null} + {parentName ? ( + + {t("parentAgent", { defaultValue: "上级代理" })} {parentName} + + ) : null} +
+ ) : null}
@@ -202,16 +219,23 @@ export function AgentLineDetailPanel({
) : null} {canManageNode ? ( -
- - {canCreateChild ? ( - + {canCreateChild ? ( + + ) : null} +
+ {childActionHint ? ( +

+ {childActionHint} +

) : null} ) : null} @@ -267,7 +291,7 @@ export function AgentLineDetailPanel({ }) : t("lineUi.profileTabHint", { defaultValue: - "占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。", + "占成、授信、回水与风控标签在此维护;登录名、密码与启停状态请用「编辑代理」。", })}

@@ -426,7 +450,7 @@ function OverviewTab({ defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。", count: childCount, })} - actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })} + actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })} onAction={onGoToDownline} /> ) : null} @@ -437,7 +461,7 @@ function OverviewTab({ description={t("lineUi.overviewPlayersHint", { defaultValue: "直属玩家请在「直属玩家」Tab 维护。", })} - actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })} + actionLabel={t("lineUi.viewPlayers", { defaultValue: "查看直属玩家" })} onAction={onGoToPlayers} /> ) : null} @@ -509,6 +533,9 @@ function DownlineTable({ onAddChild: () => void; }): React.ReactElement { const { t } = useTranslation(["agents", "common"]); + const createChildLabel = t("lineUi.createDownline", { defaultValue: "创建下级代理" }); + const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" }); + const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" }); if (childAgents.length === 0) { return ( @@ -517,7 +544,7 @@ function DownlineTable({ {canManageNode && canCreateChild ? ( ) : null} @@ -604,13 +631,13 @@ function DownlineTable({ actions={[ { key: "edit", - label: t("editNode", { defaultValue: "编辑代理" }), + label: editChildLabel, icon: Pencil, onClick: () => onEditChild(child), }, { key: "delete", - label: t("deleteNode", { defaultValue: "删除代理" }), + label: deleteChildLabel, icon: Trash2, destructive: true, disabled: !canDeleteChild(child), diff --git a/src/modules/agents/agents-console.tsx b/src/modules/agents/agents-console.tsx index 678a597..01844e5 100644 --- a/src/modules/agents/agents-console.tsx +++ b/src/modules/agents/agents-console.tsx @@ -295,24 +295,6 @@ export function AgentsConsole(): React.ReactElement { return counts; }, [flatNodes]); - const filteredRows = useMemo(() => { - const normalized = keyword.trim().toLowerCase(); - - return businessRows.filter((node) => { - if (normalized === "") { - return true; - } - - const parentName = - node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : ""; - - return [node.name, node.code, node.username ?? "", parentName] - .join(" ") - .toLowerCase() - .includes(normalized); - }); - }, [businessRows, keyword, parentNameMap]); - const loadTree = useCallback(async (siteId?: number | null) => { setLoading(true); setErr(null); @@ -421,15 +403,15 @@ export function AgentsConsole(): React.ReactElement { ]); useAsyncEffect(() => { - if (filteredRows.length === 0) { + if (businessRows.length === 0) { setSelectedNodeId(null); return; } - if (selectedNodeId === null || !filteredRows.some((row) => row.id === selectedNodeId)) { - setSelectedNodeId(filteredRows[0]?.id ?? null); + if (selectedNodeId === null || !businessRows.some((row) => row.id === selectedNodeId)) { + setSelectedNodeId(businessRows[0]?.id ?? null); } - }, [filteredRows, selectedNodeId]); + }, [businessRows, selectedNodeId]); useEffect(() => { setDetailTab("overview"); @@ -798,10 +780,10 @@ export function AgentsConsole(): React.ReactElement { /> ) : null} - openEditForNode(node)} onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)} @@ -843,7 +826,7 @@ export function AgentsConsole(): React.ReactElement { {nodeDialogMode === "create" ? t("createChild", { defaultValue: "添加下级代理" }) - : t("editNode", { defaultValue: "编辑代理" })} + : t("editNode", { defaultValue: "编辑代理账号与配置" })} diff --git a/src/modules/agents/agents-players-panel.tsx b/src/modules/agents/agents-players-panel.tsx index 4a0f584..95a3c63 100644 --- a/src/modules/agents/agents-players-panel.tsx +++ b/src/modules/agents/agents-players-panel.tsx @@ -1,11 +1,18 @@ "use client"; -import { Eye, Pencil, Plus, Trash2 } from "lucide-react"; +import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { getAgentNodeProfile } from "@/api/admin-agents"; +import { + getSettlementBills, + postSettlementBillBadDebtWriteOff, + postSettlementBillConfirm, + postSettlementBillPayment, + type SettlementBillRow, +} from "@/api/admin-agent-settlement"; import { deleteAdminPlayer, getAdminPlayer, @@ -15,7 +22,7 @@ import { } from "@/api/admin-player"; import { formatCredit } from "@/modules/agents/agent-line-sidebar"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; -import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; +import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; @@ -48,7 +55,7 @@ import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; -import { playerBalanceCells } from "@/lib/admin-player-display"; +import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display"; import { formatAdminMinorUnits } from "@/lib/money"; import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; @@ -79,6 +86,15 @@ function playerStatusLabel( return String(status); } +function creditAdjustModeLabel( + mode: "increase" | "decrease", + t: (key: string, opts?: { defaultValue?: string }) => string, +): string { + return mode === "increase" + ? t("playersPanel.creditIncrease", { defaultValue: "增加授信" }) + : t("playersPanel.creditDecrease", { defaultValue: "减少授信" }); +} + function resolvePlayerRebateRate(row: AdminPlayerRow): number | null { if (row.rebate_rate != null) { return row.rebate_rate; @@ -108,7 +124,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): { nickname: string; currency: string; status: number; - creditLimit: string; + creditLimit: number; rebateRate: string; riskTags: string; } { @@ -119,7 +135,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): { nickname: row.nickname ?? "", currency: row.default_currency ?? "", status: row.status, - creditLimit: row.credit_limit != null ? String(row.credit_limit) : "", + creditLimit: row.credit_limit ?? 0, rebateRate: rebate != null ? ratioToPercentUi(rebate) : "", riskTags: (row.risk_tags ?? []).join(", "), }; @@ -143,6 +159,15 @@ export function AgentsPlayersPanel({ }: AgentsPlayersPanelProps): React.ReactElement { const { t } = useTranslation(["agents", "players", "common"]); const formatDt = useAdminDateTimeFormatter(); + const createPlayerLabel = embedded + ? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" }) + : t("playersPanel.create", { defaultValue: "创建玩家" }); + const viewPlayerLabel = t("players:viewDetail", { defaultValue: "查看玩家详情" }); + const editPlayerLabel = t("players:editPlayer", { defaultValue: "编辑玩家" }); + const deletePlayerLabel = t("players:deletePlayer", { defaultValue: "删除玩家" }); + const settlementCenterLabel = t("playersPanel.gotoSettlementCenter", { + defaultValue: "去结算中心", + }); const profile = useAdminProfile(); const boundAgent = profile?.agent ?? null; const isSuperAdmin = profile?.is_super_admin === true; @@ -175,7 +200,6 @@ export function AgentsPlayersPanel({ const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); - const [sitePlayerId, setSitePlayerId] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [nickname, setNickname] = useState(""); @@ -189,10 +213,22 @@ export function AgentsPlayersPanel({ const [editNickname, setEditNickname] = useState(""); const [editDefaultCurrency, setEditDefaultCurrency] = useState(""); const [editStatus, setEditStatus] = useState(0); - const [editCreditLimit, setEditCreditLimit] = useState(""); + const [editCreditBase, setEditCreditBase] = useState(0); + const [editCreditAdjustMode, setEditCreditAdjustMode] = useState<"increase" | "decrease">("increase"); + const [editCreditDelta, setEditCreditDelta] = useState(""); const [editRebateRate, setEditRebateRate] = useState(""); const [editRiskTags, setEditRiskTags] = useState(""); const [editDetailLoading, setEditDetailLoading] = useState(false); + const [billingDialogOpen, setBillingDialogOpen] = useState(false); + const [billingPlayer, setBillingPlayer] = useState(null); + const [billingBills, setBillingBills] = useState([]); + const [billingLoading, setBillingLoading] = useState(false); + const [billingBusy, setBillingBusy] = useState(false); + const [selectedBillId, setSelectedBillId] = useState(null); + const [payAmount, setPayAmount] = useState(""); + const [payMethod, setPayMethod] = useState(""); + const [payProof, setPayProof] = useState(""); + const [badDebtReason, setBadDebtReason] = useState(""); const load = useCallback(async () => { if (siteCode.trim() === "") { @@ -241,7 +277,6 @@ export function AgentsPlayersPanel({ try { await postAdminPlayer({ site_code: siteCode.trim(), - ...(sitePlayerId.trim() !== "" ? { site_player_id: sitePlayerId.trim() } : {}), username: username.trim(), password: password, nickname: nickname.trim() || null, @@ -259,7 +294,6 @@ export function AgentsPlayersPanel({ }), ); setDialogOpen(false); - setSitePlayerId(""); setUsername(""); setPassword(""); setNickname(""); @@ -290,7 +324,9 @@ export function AgentsPlayersPanel({ setEditNickname(form.nickname); setEditDefaultCurrency(form.currency); setEditStatus(form.status); - setEditCreditLimit(form.creditLimit); + setEditCreditBase(form.creditLimit); + setEditCreditAdjustMode("increase"); + setEditCreditDelta(""); setEditRebateRate(form.rebateRate); setEditRiskTags(form.riskTags); }; @@ -339,10 +375,13 @@ export function AgentsPlayersPanel({ if (editStatus !== editingPlayer.status) { body.status = editStatus; } - const nextCredit = - editCreditLimit.trim() === "" ? 0 : Number.parseInt(editCreditLimit, 10); - if (!Number.isNaN(nextCredit) && nextCredit !== (editingPlayer.credit_limit ?? 0)) { - body.credit_limit = Math.max(0, nextCredit); + const creditDelta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10); + if (!Number.isNaN(creditDelta) && creditDelta > 0) { + const signedDelta = editCreditAdjustMode === "increase" ? creditDelta : -creditDelta; + const nextCredit = Math.max(0, (editingPlayer.credit_limit ?? 0) + signedDelta); + if (nextCredit !== (editingPlayer.credit_limit ?? 0)) { + body.credit_limit = nextCredit; + } } const prevRebate = resolvePlayerRebateRate(editingPlayer); const nextPercent = parsePercentUi(editRebateRate); @@ -390,7 +429,141 @@ export function AgentsPlayersPanel({ setTotal((current) => Math.max(0, current - 1)); toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id })); } catch (e) { - toast.error(e instanceof LotteryApiBizError ? e.message : t("deleteFailed")); + if (e instanceof LotteryApiBizError) { + const needsSettlement = e.message.includes("已占用信用额度"); + toast.error( + needsSettlement + ? t("playersPanel.deleteBlockedByCreditHint", { + defaultValue: "该玩家仍有已占用信用额度,请先到结算中心结清或核销相关账单后再删除。", + }) + : e.message, + ); + return; + } + toast.error(t("deleteFailed")); + } + } + + const selectedBill = useMemo( + () => billingBills.find((bill) => bill.id === selectedBillId) ?? null, + [billingBills, selectedBillId], + ); + const projectedCreditLimit = useMemo(() => { + const delta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10); + if (Number.isNaN(delta) || delta <= 0) { + return editCreditBase; + } + return Math.max(0, editCreditBase + (editCreditAdjustMode === "increase" ? delta : -delta)); + }, [editCreditAdjustMode, editCreditBase, editCreditDelta]); + + function resetBillingForm(): void { + setPayAmount(""); + setPayMethod(""); + setPayProof(""); + setBadDebtReason(""); + } + + async function openBillingDialog(row: AdminPlayerRow): Promise { + 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 { + 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 { + 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 { + 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 ? ( ) : null} @@ -434,6 +607,9 @@ export function AgentsPlayersPanel({ {t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })} + + {t("players:riskTags", { defaultValue: "风控标签" })} + {t("players:fundingMode", { defaultValue: "资金模式" })} @@ -455,11 +631,12 @@ export function AgentsPlayersPanel({ {items.length === 0 ? ( - + ) : ( items.map((row) => { const balances = playerBalanceCells(row, formatAdminMinorUnits); const rebate = resolvePlayerRebateRate(row); + const riskTags = row.risk_tags ?? []; return ( #{row.id} @@ -471,6 +648,22 @@ export function AgentsPlayersPanel({ / {row.nickname ?? "—"} + + {riskTags.length > 0 ? ( +
+ {riskTags.map((tag) => ( + + {tag} + + ))} +
+ ) : ( + + )} +
@@ -509,21 +702,33 @@ export function AgentsPlayersPanel({ actions={[ { key: "detail", - label: t("players:viewDetail", { defaultValue: "查看详情" }), + label: viewPlayerLabel, icon: Eye, href: adminPlayerDetailPath(row.id), }, + ...(row.funding_mode === "credit" || row.uses_credit === true + ? [ + { + key: "settlement", + label: t("playersPanel.manageSettlement", { + defaultValue: "处理账单", + }), + icon: ReceiptText, + onClick: () => void openBillingDialog(row), + }, + ] + : []), ...(canManagePlayerRows ? [ { key: "edit", - label: t("players:edit", { defaultValue: "编辑" }), + label: editPlayerLabel, icon: Pencil, onClick: () => openEditPlayer(row), }, { key: "delete", - label: t("players:delete", { defaultValue: "删除" }), + label: deletePlayerLabel, icon: Trash2, destructive: true, onClick: () => @@ -572,24 +777,12 @@ export function AgentsPlayersPanel({ - {t("playersPanel.create", { defaultValue: "创建玩家" })} + {createPlayerLabel}
-
- - setSitePlayerId(e.target.value)} - autoComplete="off" - placeholder={t("playersPanel.externalIdHint", { defaultValue: "留空则系统自动生成" })} - /> -
+ { + setBillingDialogOpen(open); + if (!open) { + setBillingPlayer(null); + setBillingBills([]); + setSelectedBillId(null); + resetBillingForm(); + } + }} + > + + + + {t("playersPanel.manageSettlement", { defaultValue: "处理账单" })} + + + +
+ {billingLoading ? ( +

+ {t("playersPanel.billingLoading", { defaultValue: "正在加载账单…" })} +

+ ) : billingBills.length === 0 ? ( +

+ {t("playersPanel.noPendingBills", { defaultValue: "当前没有可处理的未结账单。" })} +

+ ) : ( + <> +
+ + +
+ + {selectedBill ? ( +
+
+
+ + {t("playersPanel.billStatus", { defaultValue: "状态" })}: + {" "} + {selectedBill.status} +
+
+ + {t("playersPanel.billUnpaid", { defaultValue: "未结" })}: + {" "} + {selectedBill.unpaid_amount ?? 0} +
+
+ + {selectedBill.status === "pending_confirm" ? ( + + ) : null} + + {selectedBill.status !== "pending_confirm" && Number(selectedBill.unpaid_amount ?? 0) > 0 ? ( +
+
+ + setPayAmount(e.target.value)} /> +
+
+ + setPayMethod(e.target.value)} + placeholder={t("agents:settlementBills.paymentMethodPlaceholder", { + defaultValue: "例如:现金 / 银行转账", + })} + /> +
+
+ + setPayProof(e.target.value)} + placeholder={t("agents:settlementBills.paymentProofPlaceholder", { + defaultValue: "可填写流水号、截图说明或备注", + })} + /> +
+ + +
+ + setBadDebtReason(e.target.value)} + placeholder={t("agents:settlementBills.badDebtReasonPlaceholder", { + defaultValue: "例如:客户失联、确认坏账", + })} + /> +
+ +
+ ) : null} +
+ ) : null} + + )} +
+
+
+ @@ -713,13 +1038,60 @@ export function AgentsPlayersPanel({ - setEditCreditLimit(e.target.value)} - /> +
+
+ + {t("playersPanel.currentCredit", { defaultValue: "当前授信" })} + + + {formatPlayerCreditAmount(editCreditBase, editDefaultCurrency || "NPR")} + +
+
+
+ + +
+
+ + setEditCreditDelta(e.target.value)} + placeholder="0" + /> +
+
+

+ {t("playersPanel.creditProjected", { + defaultValue: "调整后授信:{{amount}}", + amount: formatPlayerCreditAmount(projectedCreditLimit, editDefaultCurrency || "NPR"), + })} +

+
+ ); + })} + + + ) : null} ) : null} @@ -546,22 +695,41 @@ export function PlayConfigDocScreen() { ) : ( - - - {t("play.table.playCode", { ns: "config" })} - {t("play.table.category", { ns: "config" })} - {t("play.table.status", { ns: "config" })} - {t("play.table.displayName", { ns: "config" })} - {t("play.table.order", { ns: "config" })} - {t("play.table.minBet", { ns: "config" })} - {t("play.table.maxBet", { ns: "config" })} + + + {t("play.table.playCode", { ns: "config" })} + {t("play.table.category", { ns: "config" })} + {t("play.table.status", { ns: "config" })} + {t("play.table.displayName", { ns: "config" })} + {t("play.table.order", { ns: "config" })} + {t("play.table.minBet", { ns: "config" })} + {t("play.table.maxBet", { ns: "config" })} + + + + {groupedRows.length === 0 ? ( + + + {t("play.filters.empty", { ns: "config" })} + + + ) : null} + {groupedRows.map(([groupKey, rows]) => ( + + + + {categoryLabel(groupKey)} + + {t("play.filters.groupCount", { ns: "config", count: rows.length })} + + - - - {orderedRows.map((row) => ( + {rows.map((row) => ( {row.play_code} - {row.category ?? "—"} + + {row.category ? categoryLabel(row.category) : "—"} + {isDraft ? (
@@ -691,7 +859,9 @@ export function PlayConfigDocScreen() { ))} - + + ))} +
)} diff --git a/src/modules/config/doc/risk-cap-doc-screen.tsx b/src/modules/config/doc/risk-cap-doc-screen.tsx index 38c588d..ac99e59 100644 --- a/src/modules/config/doc/risk-cap-doc-screen.tsx +++ b/src/modules/config/doc/risk-cap-doc-screen.tsx @@ -14,6 +14,7 @@ import { publishRiskCapVersion, putRiskCapItems, } from "@/api/admin-config"; +import { getAdminDraws } from "@/api/admin-draws"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { Button } from "@/components/ui/button"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; @@ -32,7 +33,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; +import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"; import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel"; import { @@ -43,6 +44,13 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value"; import { ConfigVersionActions } from "@/modules/config/config-version-actions"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; @@ -51,10 +59,13 @@ import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money"; -import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd"; +import { PRD_RISK_CAP_MANAGE } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick"; +import type { + AdminDrawListItem, +} from "@/types/api/admin-draws"; import type { ConfigVersionSummary, RiskCapItemRow, @@ -102,6 +113,7 @@ export function RiskCapDocScreen() { const [loadingDetail, setLoadingDetail] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [drawOptions, setDrawOptions] = useState([]); const [defaultCapStr, setDefaultCapStr] = useState(""); const [syncOpen, setSyncOpen] = useState(false); @@ -124,10 +136,23 @@ export function RiskCapDocScreen() { } finally { setLoadingList(false); } - }, []); + }, [tRef]); useAsyncEffect(() => { void refreshList(); + }, [tRef]); + + const loadDrawOptions = useCallback(async () => { + try { + const data = await getAdminDraws({ page: 1, per_page: 100 }); + setDrawOptions(data.items); + } catch { + setDrawOptions([]); + } + }, []); + + useAsyncEffect(() => { + void loadDrawOptions(); }, []); function syncDefaultCapFromRows(rows: DraftRiskRow[]) { @@ -232,12 +257,20 @@ export function RiskCapDocScreen() { toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" })); return; } + if (r.draw_id !== null) { + toast.error(t("riskCap.validation.defaultCannotBindDraw", { ns: "config" })); + return; + } continue; } if (!/^[0-9]{4}$/.test(r.normalized_number)) { toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number })); return; } + if (r.cap_amount <= 0) { + toast.error(t("riskCap.validation.specialGreaterThanZero", { ns: "config", number: r.normalized_number })); + return; + } } setSaving(true); try { @@ -340,6 +373,15 @@ export function RiskCapDocScreen() { () => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)), [draftRows], ); + const globalRows = useMemo( + () => specialRows.filter(({ row }) => row.draw_id == null), + [specialRows], + ); + const drawRows = useMemo( + () => specialRows.filter(({ row }) => row.draw_id != null), + [specialRows], + ); + const defaultCapDisplay = defaultCapStr || formatAdminMinorDecimal(0, amountCurrencyCode); async function handleDeleteVersion(row: ConfigVersionSummary) { try { @@ -459,6 +501,35 @@ export function RiskCapDocScreen() { > {error ?

{error}

: null} +
+ {[ + { + 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) => ( +
+

{card.label}

+

{card.value}

+

{card.hint}

+
+ ))} +
+
@@ -466,8 +537,8 @@ export function RiskCapDocScreen() { {canEditDraft ? ( {t("riskCap.noDetailRows", { ns: "config" })}

) : ( - - - - {t("riskCap.table.number", { ns: "config" })} - {t("riskCap.table.capAmount", { ns: "config" })} - {t("riskCap.table.actions", { ns: "config" })} - - - - {specialRows.map(({ row: r, index: idx }) => ( - - - {canEditDraft ? ( - - updateRow(idx, { - normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4), - }) - } - /> - ) : ( - {r.normalized_number} - )} - - - {canEditDraft ? ( - - updateRow(idx, { - cap_amount: - parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0, - }) - } - /> - ) : ( - - {formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)} - - )} - - - {canEditDraft ? ( - removeRow(idx), - }, - ]} - /> - ) : ( - {t("riskCap.readOnly", { ns: "config" })} - )} - - - ))} - -
+
+ {[ + { + 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) => ( +
+
+
+

{group.title}

+

{group.description}

+
+ + {t("riskCap.groups.count", { ns: "config", count: group.rows.length })} + +
+ + {group.rows.length === 0 ? ( +

{group.emptyText}

+ ) : ( + + + + {t("riskCap.table.scope", { ns: "config" })} + {t("riskCap.table.number", { ns: "config" })} + {t("riskCap.table.capAmount", { ns: "config" })} + + {t("riskCap.table.actions", { ns: "config" })} + + + + + {group.rows.map(({ row: r, index: idx }) => ( + + + {canEditDraft ? ( + + ) : ( + + {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 })} + + )} + + + {canEditDraft ? ( + + updateRow(idx, { + normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4), + }) + } + /> + ) : ( + {r.normalized_number} + )} + + + {canEditDraft ? ( + + updateRow(idx, { + cap_amount: + parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0, + }) + } + /> + ) : ( + + {formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)} + + )} + + + {canEditDraft ? ( + removeRow(idx), + }, + ]} + /> + ) : ( + + {t("riskCap.readOnly", { ns: "config" })} + + )} + + + ))} + +
+ )} +
+ ))} +
)} diff --git a/src/modules/dashboard/dashboard-analytics-panel.tsx b/src/modules/dashboard/dashboard-analytics-panel.tsx index 35e7137..1703e2b 100644 --- a/src/modules/dashboard/dashboard-analytics-panel.tsx +++ b/src/modules/dashboard/dashboard-analytics-panel.tsx @@ -421,6 +421,9 @@ export function DashboardAgentRankingCard({ const v = metricValue(row); const pct = (Math.abs(v) / maxAbs) * 100; const color = barColor(row); + const agentName = row.agent_name?.trim() || "-"; + const agentCode = row.agent_code?.trim() || ""; + const showCode = agentCode !== "" && agentCode !== agentName; return (
@@ -429,8 +432,10 @@ export function DashboardAgentRankingCard({ #{idx + 1}
-

{row.agent_name || "-"}

-

{row.agent_code || ""}

+

{agentName}

+ {showCode ? ( +

{agentCode}

+ ) : null}
diff --git a/src/modules/dashboard/dashboard-current-draw-card.tsx b/src/modules/dashboard/dashboard-current-draw-card.tsx index 2552b94..ed0a8fe 100644 --- a/src/modules/dashboard/dashboard-current-draw-card.tsx +++ b/src/modules/dashboard/dashboard-current-draw-card.tsx @@ -13,6 +13,38 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter" import { cn } from "@/lib/utils"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; +function formatDashboardDrawStatus( + status: string, + t: (key: string, options?: Record) => string, +): string { + const lower = status.trim().toLowerCase(); + + switch (lower) { + case "pending": + return t("statusOptions.pending", { ns: "draws" }); + case "open": + return t("statusOptions.open", { ns: "draws" }); + case "closing": + return t("statusOptions.closing", { ns: "draws" }); + case "closed": + return t("statusOptions.closed", { ns: "draws" }); + case "drawing": + return t("statusOptions.drawing", { ns: "draws" }); + case "review": + return t("statusOptions.review", { ns: "draws" }); + case "cooldown": + return t("statusOptions.cooldown", { ns: "draws" }); + case "settling": + return t("statusOptions.settling", { ns: "draws" }); + case "settled": + return t("statusOptions.settled", { ns: "draws" }); + case "cancelled": + return t("statusOptions.cancelled", { ns: "draws" }); + default: + return status; + } +} + function isOpenLikeStatus(status: string): boolean { const lower = status.toLowerCase(); return lower.includes("open") || lower.includes("sale"); @@ -29,7 +61,7 @@ export function DashboardCurrentDrawCard({ drawId, loading = false, }: DashboardCurrentDrawCardProps): ReactElement { - const { t } = useTranslation("dashboard"); + const { t } = useTranslation(["dashboard", "draws"]); const formatDt = useAdminDateTimeFormatter(); if (loading) { @@ -54,6 +86,7 @@ export function DashboardCurrentDrawCard({ } const openLike = isOpenLikeStatus(hall.status); + const statusLabel = formatDashboardDrawStatus(hall.status, t); return ( @@ -95,7 +128,7 @@ export function DashboardCurrentDrawCard({ openLike ? "bg-emerald-500" : "bg-muted-foreground/70", )} /> - {hall.status} + {statusLabel}
diff --git a/src/modules/dashboard/dashboard-visuals.tsx b/src/modules/dashboard/dashboard-visuals.tsx index 51d6abf..4681c15 100644 --- a/src/modules/dashboard/dashboard-visuals.tsx +++ b/src/modules/dashboard/dashboard-visuals.tsx @@ -817,6 +817,11 @@ export function HotUsageBars({ }): ReactElement { const { t } = useTranslation("dashboard"); const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]); + const shortenPoolLabel = (value: string): string => { + const normalized = value.trim(); + const trimmedMeta = normalized.replace(/\s*\(.+\)$/, ""); + return trimmedMeta.length > 10 ? `${trimmedMeta.slice(0, 10)}…` : trimmedMeta; + }; const chartData = useMemo( () => @@ -824,6 +829,7 @@ export function HotUsageBars({ const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100)); return { number: row.normalized_number.trim(), + displayNumber: shortenPoolLabel(row.normalized_number), usage: pct, fill: usageBarFill(pct), }; @@ -855,15 +861,23 @@ export function HotUsageBars({ { + const row = chartData.find((item) => item.number === value); + return row?.displayNumber ?? value; + }} tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }} /> `${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)}%`; + }} /> } /> diff --git a/src/modules/draws/draw-detail-console.tsx b/src/modules/draws/draw-detail-console.tsx index fb98778..8be79a5 100644 --- a/src/modules/draws/draw-detail-console.tsx +++ b/src/modules/draws/draw-detail-console.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; import { getAdminDraw, + getAdminDrawFinanceSummary, postAdminCancelDraw, postAdminManualCloseDraw, postAdminReopenDraw, @@ -22,11 +23,13 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { LotteryApiBizError } from "@/types/api/errors"; +import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; import type { AdminDrawShowData } from "@/types/api/admin-draws"; import { canManageDrawResults } from "@/lib/draw-access"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { useAdminProfile } from "@/stores/admin-session"; import { cn } from "@/lib/utils"; +import { formatAdminMinorUnits } from "@/lib/money"; import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display"; import { DrawStatusBadge } from "./draw-status-badge"; @@ -80,6 +83,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [acting, setActing] = useState(null); + const [financeSummary, setFinanceSummary] = useState(null); const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const load = useCallback(async () => { @@ -91,9 +95,20 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { setLoading(true); setError(null); try { - setData(await getAdminDraw(idNum)); + const draw = await getAdminDraw(idNum); + setData(draw); + if (draw.capabilities?.can_view_draw_finance !== false) { + try { + setFinanceSummary(await getAdminDrawFinanceSummary(idNum)); + } catch { + setFinanceSummary(null); + } + } else { + setFinanceSummary(null); + } } catch (e) { setData(null); + setFinanceSummary(null); setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" })); } finally { setLoading(false); @@ -225,6 +240,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { const batch = data.result_batch_counts; const pendingReview = batch.pending_review ?? 0; const totalBatches = batch.total ?? batch.published; + const financeCurrency = financeSummary?.currency_code ?? "NPR"; const hasResultActivity = (canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0; const showActions = @@ -236,6 +252,14 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
+
+ + {t("backToList")} + +
{data.draw_no}

{t("detailSubtitle", { @@ -263,6 +287,39 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { +

+

{t("overviewTitle")}

+
+
+

{t("overviewBetTotal")}

+

+ {formatAdminMinorUnits( + financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0, + financeCurrency, + )} +

+
+
+

{t("overviewPayoutTotal")}

+

+ {formatAdminMinorUnits( + financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0, + financeCurrency, + )} +

+
+
+

{t("overviewProfitLoss")}

+

+ {formatAdminMinorUnits( + financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0, + financeCurrency, + )} +

+
+
+
+

{t("scheduleTitle")}

@@ -307,6 +364,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { ) : (

{t("noResultBatchesYet")} + {t("reviewQueueHint")} {canManageDraw ? ( <> {" "} diff --git a/src/modules/jackpot/jackpot-config-screen.tsx b/src/modules/jackpot/jackpot-config-screen.tsx index 753eedd..fdad525 100644 --- a/src/modules/jackpot/jackpot-config-screen.tsx +++ b/src/modules/jackpot/jackpot-config-screen.tsx @@ -2,10 +2,12 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { Info } from "lucide-react"; import { AdminPageCard } from "@/components/admin/admin-page-card"; import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console"; import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; /** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */ export function JackpotConfigScreen() { @@ -26,6 +28,15 @@ export function JackpotConfigScreen() { return (

+ + + {t("rulesTitle")} + +

{t("rulesJoin")}

+

{t("rulesBurst")}

+

{t("rulesManual")}

+
+
diff --git a/src/modules/jackpot/jackpot-pools-console.tsx b/src/modules/jackpot/jackpot-pools-console.tsx index 4bfe8e2..5823dcf 100644 --- a/src/modules/jackpot/jackpot-pools-console.tsx +++ b/src/modules/jackpot/jackpot-pools-console.tsx @@ -214,15 +214,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro const manualBurst = async (p: AdminJackpotPoolRow) => { const d = drafts[p.id]; if (!d) return; - const drawId = Number.parseInt(d.manual_burst_draw_id, 10); - if (!Number.isFinite(drawId) || drawId <= 0) { + const drawRef = d.manual_burst_draw_id.trim(); + if (drawRef.length === 0) { toast.error(t("invalidDrawId")); return; } setBurstingId(p.id); try { - const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawId }); + const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawRef }); toast.success( `${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`, ); diff --git a/src/modules/players/player-detail-console.tsx b/src/modules/players/player-detail-console.tsx index 15dd2f5..7d4a875 100644 --- a/src/modules/players/player-detail-console.tsx +++ b/src/modules/players/player-detail-console.tsx @@ -12,6 +12,7 @@ import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets"; import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; +import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { buttonVariants } from "@/components/ui/button"; @@ -42,6 +43,7 @@ import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player"; import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets"; import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet"; +import { Eye } from "lucide-react"; function playerStatusLabel(status: number, t: (key: string) => string): string { if (status === 0) return t("statusNormal"); @@ -309,6 +311,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { {playerAuthSourceLabel(player, t)} + + {player.risk_tags && player.risk_tags.length > 0 ? player.risk_tags.join(", ") : "—"} + @@ -408,7 +413,10 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { - {t("tabTickets")} +
+ {t("tabTickets")} +

{t("ticketTableHint")}

+
@@ -416,21 +424,29 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { {t("ticketNo", { ns: "tickets" })} + {t("orderNo", { ns: "tickets" })} {t("drawNo", { ns: "tickets" })} {t("playCode", { ns: "tickets" })} {t("number", { ns: "tickets" })} {t("actualDeduct", { ns: "tickets" })} {t("status", { ns: "tickets" })} + {t("failReason", { ns: "tickets" })} + {t("winAmount", { ns: "tickets" })} {t("placedAt", { ns: "tickets" })} + {t("updatedAt", { ns: "tickets" })} + + {t("table.actions", { ns: "common" })} + {ticketsLoading && tickets.length === 0 ? ( - + ) : null} {tickets.map((row) => ( {row.ticket_no} + {row.order_no ?? "—"} {row.draw_no ?? "—"} {playCodeLabel(row.play_code)} {row.original_number ?? "—"} @@ -442,13 +458,36 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { {ticketStatusText(row.status, t)} + + {row.fail_reason_text ?? row.fail_reason_code ?? "—"} + + + {row.jackpot_win_amount_minor > 0 + ? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}` + : row.win_amount_formatted} + {row.placed_at ? formatDt(row.placed_at) : "—"} + + {row.updated_at ? formatDt(row.updated_at) : "—"} + + + + ))} {!ticketsLoading && tickets.length === 0 ? ( - + ) : null} diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx index ff996f7..9975d5e 100644 --- a/src/modules/players/players-console.tsx +++ b/src/modules/players/players-console.tsx @@ -82,6 +82,17 @@ const PLAYER_STATUS_OPTIONS = [ { value: 2, label: "statusBanned" }, ]; +function parseRiskTagsInput(text: string): string[] { + return Array.from( + new Set( + text + .split(/[,,\s]+/) + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0), + ), + ); +} + export function PlayersConsole(): React.ReactElement { const { t } = useTranslation(["players", "common"]); const tRef = useTranslationRef(["players", "common"]); @@ -121,6 +132,7 @@ export function PlayersConsole(): React.ReactElement { const [formNickname, setFormNickname] = useState(""); const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR"); const [formStatus, setFormStatus] = useState(0); + const [formRiskTags, setFormRiskTags] = useState(""); const [formAgentNodeId, setFormAgentNodeId] = useState(undefined); const [createAgentOptions, setCreateAgentOptions] = useState([]); const [createAgentLoading, setCreateAgentLoading] = useState(false); @@ -211,6 +223,7 @@ export function PlayersConsole(): React.ReactElement { setFormNickname(""); setFormDefaultCurrency("NPR"); setFormStatus(0); + setFormRiskTags(""); setAccountOpen(true); } @@ -269,6 +282,7 @@ export function PlayersConsole(): React.ReactElement { setFormNickname(row.nickname ?? ""); setFormDefaultCurrency(row.default_currency); setFormStatus(row.status); + setFormRiskTags((row.risk_tags ?? []).join(", ")); setAccountOpen(true); } @@ -337,6 +351,11 @@ export function PlayersConsole(): React.ReactElement { if (formStatus !== editingPlayer?.status) { body.status = formStatus; } + const nextRiskTags = parseRiskTagsInput(formRiskTags); + const prevRiskTags = editingPlayer?.risk_tags ?? []; + if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) { + body.risk_tags = nextRiskTags; + } if (Object.keys(body).length === 0) { toast.success(t("noChanges")); @@ -517,6 +536,7 @@ export function PlayersConsole(): React.ReactElement { {t("sitePlayerId")} {t("username")} {t("nickname")} + {t("riskTags", { defaultValue: "风控标签" })} {t("currency")} {t("fundingMode")} {t("balance")} @@ -528,12 +548,13 @@ export function PlayersConsole(): React.ReactElement { {loading && items.length === 0 ? ( - + ) : items.length === 0 ? ( - + ) : ( items.map((row) => { const balances = playerBalanceCells(row, formatAdminMinorUnits); + const riskTags = row.risk_tags ?? []; return ( #{row.id} @@ -546,6 +567,22 @@ export function PlayersConsole(): React.ReactElement { {row.username ?? "—"} {row.nickname ?? "—"} + + {riskTags.length > 0 ? ( +
+ {riskTags.map((tag) => ( + + {tag} + + ))} +
+ ) : ( + + )} +
{row.default_currency} @@ -791,24 +828,61 @@ export function PlayersConsole(): React.ReactElement { )} {accountMode === "edit" && ( -
- - -
+ <> +
+
+
+

+ {t("fundingMode", { defaultValue: "资金模式" })} +

+

+ {editingPlayer ? : "—"} +

+
+
+

+ {t("authSource", { defaultValue: "登录来源" })} +

+

+ {editingPlayer?.auth_source === "main_site_sso" + ? t("authMainSite", { defaultValue: "主站 SSO" }) + : editingPlayer?.auth_source === "lottery_native" + ? t("authNative", { defaultValue: "彩票端" }) + : editingPlayer?.auth_source ?? "—"} +

+
+
+
+
+ + setFormRiskTags(e.target.value)} + /> +
+
+ + +
+ )}
diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx index 2e791ba..4fe1ef7 100644 --- a/src/modules/reconcile/reconcile-console.tsx +++ b/src/modules/reconcile/reconcile-console.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -18,7 +19,7 @@ import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admi import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; @@ -71,6 +72,18 @@ function itemStatusLabel(status: string, t: (key: string) => string): string { return t("itemMatched"); case "pending_check": return t("itemPendingCheck"); + case "stale_processing": + return t("itemStaleProcessing"); + case "pending_reconcile": + return t("itemPendingReconcile"); + case "missing_wallet_txn": + return t("itemMissingWalletTxn"); + case "unexpected_wallet_txn": + return t("itemUnexpectedWalletTxn"); + case "missing_refund": + return t("itemMissingRefund"); + case "missing_reversal": + return t("itemMissingReversal"); default: return status; } @@ -85,6 +98,82 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string { } } +function itemResolutionLabel( + row: Pick, + t: (key: string) => string, +): string { + return row.is_resolved === true || row.resolved_at ? t("itemResolved") : t("itemUnresolved"); +} + +function itemResolutionTone(row: Pick): "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, + t: (key: string, opts?: Record) => 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 | null | undefined, key: string): number { const raw = summary?.[key]; return typeof raw === "number" && Number.isFinite(raw) ? raw : 0; @@ -507,8 +596,8 @@ export function ReconcileConsole(): React.ReactElement { { setSelectedId(row.id); @@ -609,16 +698,20 @@ export function ReconcileConsole(): React.ReactElement { {t("table.id", { ns: "common" })} - {t("sideARef")} - {t("sideBRef")} - {t("differenceAmount")} - {t("status")} - {t("detectedAt")} + {t("sideARef")} + {t("sideBRef")} + {t("differenceAmount")} + {t("itemResult")} + {t("diagnosis")} + {t("suggestedAction")} + {t("processingStatus")} + {t("quickAccess")} + {t("detectedAt")} {items.items.length === 0 ? ( - + ) : ( items.items.map((r) => ( - {r.id} - {r.side_a_ref ?? "—"} - {r.side_b_ref ?? "—"} - + {r.id} + {r.side_a_ref ?? "—"} + {r.side_b_ref ?? "—"} + - + {itemStatusLabel(r.status, t)} - + + {itemDiagnosisLabel(r.status, t)} + + + {itemSuggestedAction(r, t)} + + + + {itemResolutionLabel(r, t)} + + + +
+ {r.side_a_ref ? ( + + {t("openTransferOrder")} + + ) : null} + {r.side_b_ref ? ( + + {t("openWalletTxn")} + + ) : null} + {!r.side_a_ref && !r.side_b_ref ? ( + + ) : null} +
+
+ {formatTs(r.created_at)}
diff --git a/src/modules/reports/report-jobs-panel.tsx b/src/modules/reports/report-jobs-panel.tsx index b384a5e..6f0c8a4 100644 --- a/src/modules/reports/report-jobs-panel.tsx +++ b/src/modules/reports/report-jobs-panel.tsx @@ -40,9 +40,10 @@ function downloadBlob(blob: Blob, filename: string): void { type ReportJobsPanelProps = { canExport: boolean; refreshToken?: number; + reportType?: string; }; -export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) { +export function ReportJobsPanel({ canExport, refreshToken = 0, reportType }: ReportJobsPanelProps) { const { t } = useTranslation(["reports", "common"]); const tRef = useTranslationRef(["reports", "common"]); const formatTs = useAdminDateTimeFormatter(); @@ -53,7 +54,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel const loadJobs = useCallback(async () => { setLoading(true); try { - const data = await getAdminReportJobs({ page: 1, per_page: 10 }); + const data = await getAdminReportJobs({ page: 1, per_page: 10, report_type: reportType || undefined }); setJobs(data.items); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed")); @@ -61,7 +62,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel } finally { setLoading(false); } - }, []); + }, [reportType, tRef]); useAsyncEffect(() => { void loadJobs(); @@ -100,7 +101,9 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel -

{t("exportHint")}

+

+ {reportType ? t("tasks.currentReportHint") : t("exportHint")} +

diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx index 79b19b7..bb779d4 100644 --- a/src/modules/reports/reports-console.tsx +++ b/src/modules/reports/reports-console.tsx @@ -93,7 +93,7 @@ import type { AdminReportRebateCommissionRow, } from "@/types/api/admin-reports"; -export type ReportCategory = "profit" | "wallet" | "risk" | "audit" | "legacy"; +export type ReportCategory = "profit" | "wallet" | "risk" | "audit"; type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period"; type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period"; type ExportFormat = "csv" | "excel"; @@ -192,7 +192,7 @@ const REPORTS: ReportDefinition[] = [ { key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true }, { key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true }, { key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true }, - { key: "rebate_commission", category: "legacy", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true }, + { key: "rebate_commission", category: "profit", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true }, { key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true }, ]; @@ -226,8 +226,6 @@ function categoryTone(category: ReportCategory): string { return "border-red-200 bg-red-50 text-red-700"; case "audit": return "border-slate-200 bg-slate-50 text-slate-700"; - case "legacy": - return "border-amber-200 bg-amber-50 text-amber-800"; default: return "border-blue-200 bg-blue-50 text-blue-700"; } @@ -405,6 +403,90 @@ function resultRowCount(result: ReportResult | null): number { return result?.rows.length ?? 0; } +function defaultSummaryCards( + reportKey: ReportKey, + filters: ReportFilters, + t: (key: string) => string, +): StatCard[] { + const periodLabel = + filters.dateFrom && filters.dateTo + ? `${filters.dateFrom} ~ ${filters.dateTo}` + : filters.dateFrom || filters.dateTo || t("preview.stats.notQueried"); + + switch (reportKey) { + case "draw_profit": + return [ + { label: t("preview.stats.bet"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.payout"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") }, + ]; + case "daily_profit": + return [ + { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, + { label: t("fields.period"), value: periodLabel }, + { label: t("preview.stats.bet"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") }, + ]; + case "player_win_loss": + return [ + { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, + { label: t("fields.player"), value: filters.player || t("preview.stats.notSet") }, + { label: t("preview.stats.players"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") }, + ]; + case "player_transfer": + return [ + { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, + { label: t("fields.player"), value: filters.player || t("preview.stats.notSet") }, + { label: t("preview.stats.transferIn"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.transferOut"), value: t("preview.stats.notQueried") }, + ]; + case "hot_number_risk": + return [ + { label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") }, + { label: t("fields.number"), value: filters.number || t("preview.stats.notSet") }, + { label: t("preview.stats.usage"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.logs"), value: t("preview.stats.notQueried") }, + ]; + case "play_dimension": + return [ + { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, + { label: t("fields.play"), value: filters.play || t("filterAll") }, + { label: t("preview.stats.bet"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.payout"), value: t("preview.stats.notQueried") }, + ]; + case "sold_out_number": + return [ + { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") }, + { label: t("preview.stats.currency"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.usage"), value: t("preview.stats.notQueried") }, + ]; + case "rebate_commission": + return [ + { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, + { label: t("fields.play"), value: filters.play || t("filterAll") }, + { label: t("preview.stats.rebate"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.orders"), value: t("preview.stats.notQueried") }, + ]; + case "admin_audit": + return [ + { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, + { label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") }, + { label: t("preview.stats.modules"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.operators"), value: t("preview.stats.notQueried") }, + ]; + default: + return [ + { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") }, + { label: t("preview.stats.exportRows"), value: "0" }, + { label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") }, + ]; + } +} + export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) { const { t, i18n } = useTranslation(["reports", "common"]); const profile = useAdminProfile(); @@ -1426,189 +1508,179 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa }; return ( -
-
- - - {t("chooseReport")} - - - {filteredReports.map((report) => { - const Icon = report.icon; - const active = report.key === selectedReport.key; - return ( - - ); - })} - - - -
- - -
- {t("filterPanel")} -
-
- -
- {selectedReport.fields.map(renderField)} -
-
-
- - -
-
-
-
- -
- {(result?.summary ?? [ - { label: t("preview.stats.records"), value: "-" }, - { label: t("preview.stats.currentPage"), value: "-" }, - { label: t("preview.stats.drawNo"), value: filters.drawNo || "-" }, - { label: t("preview.stats.exportRows"), value: String(resultRowCount(result)) }, - ]).map((item) => ( -
-
{item.label}
-
{item.value}
-
- ))} -
- - {selectedReport.key === "rebate_commission" ? ( -
- {t("items.rebate_commission.disclaimer", { - defaultValue: - "本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。", + + + + {t(`items.${report.key}.title`)} + + ); })}
- ) : null} +
{t(`items.${selectedReport.key}.summary`)}
+
+ + +
+ {selectedReport.fields.map(renderField)} +
+
+
{t("filterPanel")}
+
+ + +
+
+
+ - - -
- {t("preview.title")} -
-
-

{t("exportServerHint")}

-
- - -
- {result && result.rows.length > 0 ? ( - <> +
+ {(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ + {selectedReport.key === "rebate_commission" ? ( +
+ {t("items.rebate_commission.disclaimer", { + defaultValue: + "本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。", + })} +
+ ) : null} + + + +
+ {t("preview.title")} +
+
+
+ + +
+ {result && result.rows.length > 0 ? ( + <>

{t("exportPreviewHint")}

- -
- + size="sm" + disabled={!canExportReports || exporting !== null} + onClick={() => exportPreview("csv")} + > + {t("formats.csvPreview")} + + +
+ ) : null}
- -
- {t("preview.summaryScopeHint")} -
-
- - - {previewColumns.primary} - {previewColumns.secondary} - {previewColumns.metricA} - {previewColumns.metricB} - {previewColumns.metricC} - {previewColumns.status} - {previewColumns.extra} - {previewColumns.time} - - - {renderTable()} -
+ +
+ {t("preview.summaryScopeHint")} +
+ + + + {previewColumns.primary} + {previewColumns.secondary} + {previewColumns.metricA} + {previewColumns.metricB} + {previewColumns.metricC} + {previewColumns.status} + {previewColumns.extra} + {previewColumns.time} + + + {renderTable()} +
- {result?.meta ? ( - { - setPerPage(next); - setPage(1); - }} - onPageChange={setPage} - /> - ) : null} -
- -
-
+ {result?.meta ? ( + { + setPerPage(next); + setPage(1); + }} + onPageChange={setPage} + /> + ) : null} + + - +
); } diff --git a/src/modules/reports/reports-subnav.tsx b/src/modules/reports/reports-subnav.tsx deleted file mode 100644 index 543ed22..0000000 --- a/src/modules/reports/reports-subnav.tsx +++ /dev/null @@ -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 ( - - {tabs.map((tab) => { - const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`); - return ( - - {t(`categories.${tab.category}`)} - - ); - })} - - ); -} diff --git a/src/modules/settings/panels/currency-format-settings-panel.tsx b/src/modules/settings/panels/currency-format-settings-panel.tsx deleted file mode 100644 index 34ef659..0000000 --- a/src/modules/settings/panels/currency-format-settings-panel.tsx +++ /dev/null @@ -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): 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 ( - <> - -
-
-
- - updateField("currencyDisplayDecimals", e.target.value)} - disabled={loading || saving} - /> -
-
- - updateField("currencyDecimalSeparator", e.target.value)} - disabled={loading || saving} - maxLength={1} - /> -
-
- - updateField("currencyThousandsSeparator", e.target.value)} - disabled={loading || saving} - maxLength={1} - /> -
-
- - - 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" })} - /> -
-
- - - ); -} diff --git a/src/modules/settings/settings-keys.ts b/src/modules/settings/settings-keys.ts index 50fa19f..8357a20 100644 --- a/src/modules/settings/settings-keys.ts +++ b/src/modules/settings/settings-keys.ts @@ -11,9 +11,6 @@ export const DRAW_KEYS = { DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead", REQUIRE_MANUAL_REVIEW: "draw.require_manual_review", COOLDOWN_MINUTES: "draw.cooldown_minutes", - CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals", - CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator", - CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator", } as const; export const SETTLEMENT_KEYS = { diff --git a/src/modules/settings/system-settings-screen.tsx b/src/modules/settings/system-settings-screen.tsx index ac2c734..4382a83 100644 --- a/src/modules/settings/system-settings-screen.tsx +++ b/src/modules/settings/system-settings-screen.tsx @@ -3,7 +3,6 @@ import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen"; import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context"; -import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel"; import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel"; import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel"; import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel"; @@ -15,7 +14,6 @@ function SystemSettingsContent() { return (
- !open && setDetailBillId(null)}> {t("actions.billDetail", { defaultValue: "账单详情" })} diff --git a/src/modules/tickets/player-tickets-console.tsx b/src/modules/tickets/player-tickets-console.tsx index 6d44ea3..69433ed 100644 --- a/src/modules/tickets/player-tickets-console.tsx +++ b/src/modules/tickets/player-tickets-console.tsx @@ -37,6 +37,7 @@ import { } from "@/components/ui/table"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog"; +import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminTicketItemsData } from "@/types/api/admin-tickets"; @@ -124,9 +125,13 @@ export function PlayerTicketsConsole(): React.ReactElement { const formatTs = useAdminDateTimeFormatter(); const searchParams = useSearchParams(); const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim(); + const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim(); + const numberKeywordFromUrl = (searchParams.get("number") ?? "").trim(); const initialFilters: TicketFilters = { ...emptyTicketFilters, playerQuery: playerIdFromUrl, + drawNo: drawNoFromUrl, + numberKeyword: numberKeywordFromUrl, }; const [draft, setDraft] = useState(initialFilters); const [applied, setApplied] = useState(initialFilters); @@ -340,6 +345,7 @@ export function PlayerTicketsConsole(): React.ReactElement { {t("ticketNo")} + {t("fundingMode", { ns: "players" })} {t("orderNo")} {t("drawNo")} {t("playCode")} @@ -351,14 +357,14 @@ export function PlayerTicketsConsole(): React.ReactElement { {t("winAmount")} {t("placedAt")} {t("updatedAt")} - {t("table.actions", { ns: "common" })} + {t("table.actions", { ns: "common" })} {loading && !data ? ( - + ) : !data || data.items.length === 0 ? ( - + ) : ( data.items.map((row) => { const winLabel = row.jackpot_win_amount > 0 @@ -369,6 +375,9 @@ export function PlayerTicketsConsole(): React.ReactElement { {row.ticket_no} + + + {row.order_no ?? "—"} {row.draw_no ?? "—"} {playCodeLabel(row.play_code)} diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx index 65ab56b..4dd18a4 100644 --- a/src/modules/wallet/wallet-console.tsx +++ b/src/modules/wallet/wallet-console.tsx @@ -23,13 +23,13 @@ import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; -import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; +import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { Select, SelectContent, @@ -55,7 +55,6 @@ import { useExportLabels } from "@/hooks/use-export-labels"; import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges"; import { formatAdminMinorUnits } from "@/lib/money"; import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label"; -import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminPlayerWalletsData, @@ -323,13 +322,23 @@ export function TransferOrdersPanel(): React.ReactElement { const exportLabels = useExportLabels("walletTransferOrders"); useAdminCurrencyCatalog(); const formatTs = useAdminDateTimeFormatter(); + const searchParams = useSearchParams(); + const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim(); + const transferNoFromUrl = (searchParams.get("transfer_no") ?? "").trim(); + const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); - const [draft, setDraft] = useState(emptyTransferFilters); - const [applied, setApplied] = useState(emptyTransferFilters); + const initialTransferFilters: TransferFilters = { + ...emptyTransferFilters, + playerId: playerIdFromUrl, + transferNo: transferNoFromUrl, + externalRefNo: externalRefNoFromUrl, + }; + const [draft, setDraft] = useState(initialTransferFilters); + const [applied, setApplied] = useState(initialTransferFilters); const [actionLoading, setActionLoading] = useState>(new Set()); const doAction = async ( @@ -645,10 +654,18 @@ export function WalletTxnsPanel(): React.ReactElement { const [err, setErr] = useState(null); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); - const [draft, setDraft] = useState(emptyTxnFilters); - const [applied, setApplied] = useState(emptyTxnFilters); const searchParams = useSearchParams(); const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim(); + const txnNoFromUrl = (searchParams.get("txn_no") ?? "").trim(); + const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim(); + const initialTxnFilters: TxnFilters = { + ...emptyTxnFilters, + playerId: playerIdFromUrl, + txnNo: txnNoFromUrl, + externalRefNo: externalRefNoFromUrl, + }; + const [draft, setDraft] = useState(initialTxnFilters); + const [applied, setApplied] = useState(initialTxnFilters); const load = useCallback(async () => { setLoading(true); @@ -685,15 +702,6 @@ export function WalletTxnsPanel(): React.ReactElement { void load(); }, [page, perPage, applied]); - useAsyncEffect(() => { - if (!playerIdFromUrl) { - return; - } - setDraft((d) => ({ ...d, playerId: playerIdFromUrl })); - setApplied((d) => ({ ...d, playerId: playerIdFromUrl })); - setPage(1); - }, [playerIdFromUrl]); - const runSearch = () => { setApplied({ ...draft }); setPage(1); diff --git a/src/types/api/admin-audit.ts b/src/types/api/admin-audit.ts index bae7784..457c6f7 100644 --- a/src/types/api/admin-audit.ts +++ b/src/types/api/admin-audit.ts @@ -2,6 +2,8 @@ export type AdminAuditLogRow = { id: number; operator_type: string; operator_id: number; + operator_label: string; + operator_subtitle: string | null; module_code: string; action_code: string; target_type: string | null; diff --git a/src/types/api/admin-draws.ts b/src/types/api/admin-draws.ts index 37fc795..c786869 100644 --- a/src/types/api/admin-draws.ts +++ b/src/types/api/admin-draws.ts @@ -65,6 +65,9 @@ export type AdminDrawShowData = { sequence_no: number; status: string; hall_preview_status: string; + total_bet_minor?: number; + total_payout_minor?: number; + profit_loss_minor?: number; start_time: string | null; close_time: string | null; draw_time: string | null; diff --git a/src/types/api/admin-reconcile.ts b/src/types/api/admin-reconcile.ts index 3d98e82..e1bacfc 100644 --- a/src/types/api/admin-reconcile.ts +++ b/src/types/api/admin-reconcile.ts @@ -36,6 +36,8 @@ export type AdminReconcileItemRow = { difference_amount: number; status: string; resolved_at: string | null; + is_resolved?: boolean; + current_transfer_status?: string | null; created_at: string | null; }; diff --git a/src/types/api/admin-tickets.ts b/src/types/api/admin-tickets.ts index ad2a80f..c6435da 100644 --- a/src/types/api/admin-tickets.ts +++ b/src/types/api/admin-tickets.ts @@ -9,6 +9,8 @@ export type AdminTicketItemRow = { site_player_id: string | null; username: string | null; nickname: string | null; + funding_mode?: string | null; + uses_credit?: boolean; order_no: string | null; draw_no: string | null; currency_code: string | null;