feat(admin, i18n): enhance reports, draws, config, and player workflows
This commit is contained in:
@@ -51,7 +51,7 @@ export async function getAdminJackpotPoolAdjustments(
|
|||||||
|
|
||||||
export async function postAdminJackpotManualBurst(
|
export async function postAdminJackpotManualBurst(
|
||||||
poolId: number,
|
poolId: number,
|
||||||
body: { draw_id: number },
|
body: { draw_id: number | string },
|
||||||
): Promise<{
|
): Promise<{
|
||||||
current_amount: number;
|
current_amount: number;
|
||||||
burst_amount: number;
|
burst_amount: number;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const A = `/admin/report-jobs`;
|
|||||||
export async function getAdminReportJobs(params?: {
|
export async function getAdminReportJobs(params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
|
report_type?: string;
|
||||||
}): Promise<AdminReportJobListData> {
|
}): Promise<AdminReportJobListData> {
|
||||||
return adminRequest.get<AdminReportJobListData>(A, { params });
|
return adminRequest.get<AdminReportJobListData>(A, { params });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { DrawDetailConsole } from "@/modules/draws/draw-detail-console";
|
import { DrawDetailConsole } from "@/modules/draws/draw-detail-console";
|
||||||
import { PRD_DRAW_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_DRAW_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = buildPageMetadata("draws", "drawDetail");
|
||||||
|
|
||||||
export default async function AdminDrawDetailPage(props: {
|
export default async function AdminDrawDetailPage(props: {
|
||||||
params: Promise<{ drawId: string }>;
|
params: Promise<{ drawId: string }>;
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
|
||||||
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import { ReportsConsole } from "@/modules/reports/reports-console";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
type Category = "profit" | "wallet" | "risk" | "audit";
|
|
||||||
|
|
||||||
export const metadata: Metadata = buildPageMetadata("reports", "title");
|
export const metadata: Metadata = buildPageMetadata("reports", "title");
|
||||||
|
|
||||||
export default async function AdminReportsCategoryPage({
|
export default async function AdminReportsCategoryPage({
|
||||||
@@ -18,9 +13,5 @@ export default async function AdminReportsCategoryPage({
|
|||||||
if (!["profit", "wallet", "risk", "audit"].includes(category)) {
|
if (!["profit", "wallet", "risk", "audit"].includes(category)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
return (
|
redirect("/admin/reports");
|
||||||
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
|
|
||||||
<ReportsConsole initialCategory={category as Category} />
|
|
||||||
</AdminPermissionGate>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { ReportsSubnav } from "@/modules/reports/reports-subnav";
|
|
||||||
|
|
||||||
export default function AdminReportsLayout({ children }: { children: ReactNode }) {
|
export default function AdminReportsLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold>
|
<ModuleScaffold>
|
||||||
<div className="sticky top-14 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
|
||||||
<ReportsSubnav />
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
|
||||||
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
|
||||||
import { ReportsConsole } from "@/modules/reports/reports-console";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
export const metadata: Metadata = buildPageMetadata("reports", "legacyTitle");
|
export default function AdminReportsLegacyPage() {
|
||||||
|
redirect("/admin/reports/profit");
|
||||||
export default function AdminReportsLegacyPage(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
|
|
||||||
<ReportsConsole initialCategory="legacy" />
|
|
||||||
</AdminPermissionGate>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
|
import { ReportsConsole } from "@/modules/reports/reports-console";
|
||||||
|
|
||||||
export default function AdminReportsPage() {
|
export default function AdminReportsPage() {
|
||||||
redirect("/admin/reports/profit");
|
return (
|
||||||
|
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
|
||||||
|
<ReportsConsole />
|
||||||
|
</AdminPermissionGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function AdminNoResourceState({
|
|||||||
alt=""
|
alt=""
|
||||||
width={compact ? 120 : 160}
|
width={compact ? 120 : 160}
|
||||||
height={compact ? 120 : 160}
|
height={compact ? 120 : 160}
|
||||||
|
style={{ width: "auto", height: "auto" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-auto w-auto object-contain self-center object-center -mt-4",
|
"h-auto w-auto object-contain self-center object-center -mt-4",
|
||||||
compact ? "max-h-24 max-w-[120px]" : "max-h-40 max-w-[160px]",
|
compact ? "max-h-24 max-w-[120px]" : "max-h-40 max-w-[160px]",
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
"risk-cap": "Payout caps"
|
"risk-cap": "Payout caps"
|
||||||
},
|
},
|
||||||
"rulesPlaysTitle": "Play rules",
|
"rulesPlaysTitle": "Play rules",
|
||||||
"rulesOddsTitle": "Odds & rebate",
|
"rulesOddsTitle": "Odds & base rebate",
|
||||||
"rulesOddsDescription": "Odds matrix and rebate rates on one page, sharing the same odds version line.",
|
"rulesOddsDescription": "Odds matrix and base rebate are maintained on one page, sharing the same odds version line.",
|
||||||
"rulesOddsDescriptionShort": "Pick a play on the left, edit odds and rebate on the right, then save and publish.",
|
"rulesOddsDescriptionShort": "Pick a play on the left, edit odds and base rebate on the right. Agent/player rebate is added on top of this base, then save and publish.",
|
||||||
"riskCapTitle": "Risk cap rules"
|
"riskCapTitle": "Risk cap rules"
|
||||||
},
|
},
|
||||||
"hub": {
|
"hub": {
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
"description": "Jump to play rules, odds & rebate, jackpot, and risk cap by domain. The sidebar provides direct links; this page is an overview.",
|
"description": "Jump to play rules, odds & rebate, jackpot, and risk cap by domain. The sidebar provides direct links; this page is an overview.",
|
||||||
"playsTitle": "Play rules",
|
"playsTitle": "Play rules",
|
||||||
"playsDesc": "Play switches, limits, and rule copy",
|
"playsDesc": "Play switches, limits, and rule copy",
|
||||||
"oddsTitle": "Odds & rebate",
|
"oddsTitle": "Odds & base rebate",
|
||||||
"oddsDesc": "Odds matrix and rebate rates in one version stream",
|
"oddsDesc": "Odds matrix and base rebate in one version stream",
|
||||||
"jackpotTitle": "Jackpot",
|
"jackpotTitle": "Jackpot",
|
||||||
"jackpotDesc": "Pool parameters and ledger records",
|
"jackpotDesc": "Pool parameters and ledger records",
|
||||||
"riskCapTitle": "Risk cap rules",
|
"riskCapTitle": "Risk cap rules",
|
||||||
@@ -351,6 +351,20 @@
|
|||||||
"readOnlyDraftHint": "Current version is read-only. Create a draft first.",
|
"readOnlyDraftHint": "Current version is read-only. Create a draft first.",
|
||||||
"batchEnabledCount": "{{enabledCount}}/{{total}} enabled",
|
"batchEnabledCount": "{{enabledCount}}/{{total}} enabled",
|
||||||
"noPlayTypes": "No play types",
|
"noPlayTypes": "No play types",
|
||||||
|
"filters": {
|
||||||
|
"sectionTitle": "Filter plays",
|
||||||
|
"sectionDescription": "Narrow the list first, then use batch switches or row-level edits.",
|
||||||
|
"keyword": "Search plays",
|
||||||
|
"keywordPlaceholder": "Filter by play code, display name, or category",
|
||||||
|
"category": "Category",
|
||||||
|
"status": "Status",
|
||||||
|
"allCategories": "All categories",
|
||||||
|
"allStatuses": "All statuses",
|
||||||
|
"uncategorized": "Uncategorized",
|
||||||
|
"reset": "Clear filters",
|
||||||
|
"empty": "No matching play types",
|
||||||
|
"groupCount": "{{count}} plays"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
@@ -362,6 +376,13 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"ne": "Nepali"
|
"ne": "Nepali"
|
||||||
},
|
},
|
||||||
|
"categories": {
|
||||||
|
"standard": "Standard",
|
||||||
|
"attribute": "Attribute",
|
||||||
|
"position": "Position",
|
||||||
|
"box": "Box",
|
||||||
|
"jackpot": "Jackpot"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"playCode": "Play code",
|
"playCode": "Play code",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
@@ -413,7 +434,7 @@
|
|||||||
},
|
},
|
||||||
"currentSelection": "Selection: {{category}} / {{play}}",
|
"currentSelection": "Selection: {{category}} / {{play}}",
|
||||||
"playSelectPlaceholder": "Select play type",
|
"playSelectPlaceholder": "Select play type",
|
||||||
"readOnlyBanner": "This version is read-only. Create a draft to edit odds and rebate.",
|
"readOnlyBanner": "This version is read-only. Create a draft to edit odds and base rebate.",
|
||||||
"table": {
|
"table": {
|
||||||
"prizeScope": "Prize scope",
|
"prizeScope": "Prize scope",
|
||||||
"multiplier": "Odds multiplier"
|
"multiplier": "Odds multiplier"
|
||||||
@@ -452,11 +473,11 @@
|
|||||||
"loadingDetails": "Loading details…",
|
"loadingDetails": "Loading details…",
|
||||||
"multiplier": "Multiplier x{{value}} · {{currency}}",
|
"multiplier": "Multiplier x{{value}} · {{currency}}",
|
||||||
"missingScopeRow": "Missing {{scope}} row. Check seed or version data.",
|
"missingScopeRow": "Missing {{scope}} row. Check seed or version data.",
|
||||||
"rebateRate": "Rebate rate (%)",
|
"rebateRate": "Base rebate rate (%)",
|
||||||
"rebateRateHint": "Writes rebate_rate to all prize scopes under this play type.",
|
"rebateRateHint": "This is the platform base rebate. It writes rebate_rate to all prize scopes under this play type; agent/player rebate is added on top of it.",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"multiplier": "Enter odds multiplier",
|
"multiplier": "Enter odds multiplier",
|
||||||
"rebateRate": "Enter rebate rate"
|
"rebateRate": "Enter base rebate rate"
|
||||||
},
|
},
|
||||||
"publishFailed": "Publish failed",
|
"publishFailed": "Publish failed",
|
||||||
"createDraftSuccess": "Created draft v{{version}}",
|
"createDraftSuccess": "Created draft v{{version}}",
|
||||||
@@ -481,33 +502,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rebate": {
|
"rebate": {
|
||||||
"sectionHint": "Rebate rates are stored in the odds version; select or create an odds draft in the section above first.",
|
"sectionHint": "This section configures the base rebate, which is stored in the odds version; select or create an odds draft in the section above first.",
|
||||||
"lazyLoadHint": "Scroll down to the rebate section to load",
|
"lazyLoadHint": "Scroll down to the rebate section to load",
|
||||||
"embeddedVersionHint": "Rebate shares the odds version line—switch versions in the Odds section above.",
|
"embeddedVersionHint": "Base rebate shares the odds version line—switch versions in the Odds section above.",
|
||||||
"sheetDescription": "Rebate is stored in the odds draft version and shares the same version set as odds.",
|
"sheetDescription": "Rebate is stored in the odds draft version and shares the same version set as odds.",
|
||||||
"publishLabel": "Publish",
|
"publishLabel": "Publish",
|
||||||
"publishSuccess": "Published odds version with rebate",
|
"publishSuccess": "Published odds version with rebate",
|
||||||
"publishFailed": "Publish failed",
|
"publishFailed": "Publish failed",
|
||||||
"publishDialog": {
|
"publishDialog": {
|
||||||
"title": "Publish rebate/odds version?",
|
"title": "Publish base rebate/odds version?",
|
||||||
"description": "After publish, rebate calculation applies to new tickets.",
|
"description": "After publish, the base rebate applies to new tickets. Agent/player extra rebate is still added on top.",
|
||||||
"confirm": "Confirm publish"
|
"confirm": "Confirm publish"
|
||||||
},
|
},
|
||||||
"createDraftSuccess": "Created draft v{{version}}",
|
"createDraftSuccess": "Created draft v{{version}}",
|
||||||
"createDraftFailed": "Failed to create draft",
|
"createDraftFailed": "Failed to create draft",
|
||||||
"deleteFailed": "Delete failed",
|
"deleteFailed": "Delete failed",
|
||||||
"editingVersion": "Editing version v{{version}} · {{status}}",
|
"editingVersion": "Editing version v{{version}} · {{status}}",
|
||||||
"readOnlyHint": "Create a draft before editing rebate.",
|
"readOnlyHint": "Create a draft before editing base rebate.",
|
||||||
"dimensionRatesMixedHint": "Rebate rates within the same dimension (2D/3D/4D) are not identical: the three percentage inputs show the first play (alphabetically) that has the primary prize scope; use the table as the source of truth. Bulk inputs will overwrite all plays in that dimension to one rate.",
|
"dimensionRatesMixedHint": "Base rebate within the same dimension (2D/3D/4D) is not identical: the three percentage inputs show the first play (alphabetically) that has the primary prize scope; use the table as the source of truth. Bulk inputs will overwrite all plays in that dimension to one rate.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"d2": "2D rebate rate (%)",
|
"d2": "2D base rebate rate (%)",
|
||||||
"d3": "3D rebate rate (%)",
|
"d3": "3D base rebate rate (%)",
|
||||||
"d4": "4D rebate rate (%)"
|
"d4": "4D base rebate rate (%)"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"d2": "Enter 2D rebate",
|
"d2": "Enter 2D base rebate",
|
||||||
"d3": "Enter 3D rebate",
|
"d3": "Enter 3D base rebate",
|
||||||
"d4": "Enter 4D rebate"
|
"d4": "Enter 4D base rebate"
|
||||||
},
|
},
|
||||||
"winEnjoy": {
|
"winEnjoy": {
|
||||||
"label": "Deduct rebate on winning payouts",
|
"label": "Deduct rebate on winning payouts",
|
||||||
@@ -527,6 +548,8 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"requireAtLeastOne": "At least one cap row is required",
|
"requireAtLeastOne": "At least one cap row is required",
|
||||||
"defaultGreaterThanZero": "Default cap amount must be greater than 0",
|
"defaultGreaterThanZero": "Default cap amount must be greater than 0",
|
||||||
|
"defaultCannotBindDraw": "Default cap cannot be bound to a specific draw",
|
||||||
|
"specialGreaterThanZero": "Special cap amount must be greater than 0: {{number}}",
|
||||||
"numberMustBe4Digits": "Number must be 4 digits: {{number}}",
|
"numberMustBe4Digits": "Number must be 4 digits: {{number}}",
|
||||||
"enterValidCapAmount": "Enter a valid cap amount"
|
"enterValidCapAmount": "Enter a valid cap amount"
|
||||||
},
|
},
|
||||||
@@ -547,14 +570,37 @@
|
|||||||
"defaultCap": {
|
"defaultCap": {
|
||||||
"title": "Default cap",
|
"title": "Default cap",
|
||||||
"description": "Numbers without a special cap use this default cap template.",
|
"description": "Numbers without a special cap use this default cap template.",
|
||||||
"fieldLabel": "Cap amount (minor unit)"
|
"fieldLabel": "Cap amount (major unit)"
|
||||||
},
|
},
|
||||||
"specialCaps": {
|
"specialCaps": {
|
||||||
"title": "Special caps"
|
"title": "Special caps",
|
||||||
|
"description": "No draw selected means a global number cap. Selecting a draw makes it a draw-specific cap."
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"global": "Global number",
|
||||||
|
"drawId": "Draw ID: {{id}}"
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"globalTitle": "Global number caps",
|
||||||
|
"globalDescription": "Long-running caps that are not tied to a specific draw. Use these for normal number-level selling limits.",
|
||||||
|
"globalEmpty": "No global number caps yet.",
|
||||||
|
"drawTitle": "Draw-specific caps",
|
||||||
|
"drawDescription": "Only applies to the selected draw. Use these for temporary tightening or relaxing of a number cap.",
|
||||||
|
"drawEmpty": "No draw-specific caps yet.",
|
||||||
|
"count": "{{count}} rows"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"defaultCap": "Default cap",
|
||||||
|
"defaultHint": "Any number without a special rule falls back to this value.",
|
||||||
|
"globalCaps": "Global number caps",
|
||||||
|
"globalHint": "Long-running rules that do not change with a single draw.",
|
||||||
|
"drawCaps": "Draw-specific caps",
|
||||||
|
"drawHint": "Temporary rules that only apply to selected draws."
|
||||||
},
|
},
|
||||||
"loadingDetails": "Loading details…",
|
"loadingDetails": "Loading details…",
|
||||||
"noDetailRows": "No detail rows.",
|
"noDetailRows": "No detail rows.",
|
||||||
"table": {
|
"table": {
|
||||||
|
"scope": "Scope",
|
||||||
"number": "Number",
|
"number": "Number",
|
||||||
"capAmount": "Cap amount",
|
"capAmount": "Cap amount",
|
||||||
"used": "Used",
|
"used": "Used",
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
"queryDraw": "Search draw",
|
"queryDraw": "Search draw",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"fuzzyDrawNo": "Fuzzy draw no.",
|
"fuzzyDrawNo": "Fuzzy draw no.",
|
||||||
"viewDetails": "View details",
|
"viewDetails": "View draw details",
|
||||||
"editDraw": {
|
"editDraw": {
|
||||||
"action": "Edit",
|
"action": "Edit draw",
|
||||||
"title": "Edit draw",
|
"title": "Edit draw",
|
||||||
"description": "Draw {{drawNo}} · edit times in {{tz}}",
|
"description": "Draw {{drawNo}} · edit times in {{tz}}",
|
||||||
"drawNoPlaceholder": "Enter draw number, for example 20260526-008",
|
"drawNoPlaceholder": "Enter draw number, for example 20260526-008",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"failed": "Update failed"
|
"failed": "Update failed"
|
||||||
},
|
},
|
||||||
"deleteDraw": {
|
"deleteDraw": {
|
||||||
"action": "Delete",
|
"action": "Delete draw",
|
||||||
"title": "Delete draw",
|
"title": "Delete draw",
|
||||||
"description": "Delete draw {{drawNo}}? Only for pending draws with no bets. This cannot be undone.",
|
"description": "Delete draw {{drawNo}}? Only for pending draws with no bets. This cannot be undone.",
|
||||||
"success": "Draw deleted",
|
"success": "Draw deleted",
|
||||||
@@ -57,13 +57,19 @@
|
|||||||
"invalidDrawId": "Invalid draw ID",
|
"invalidDrawId": "Invalid draw ID",
|
||||||
"loadFailed": "Failed to load. Check login and API configuration.",
|
"loadFailed": "Failed to load. Check login and API configuration.",
|
||||||
"drawDetail": "Draw details",
|
"drawDetail": "Draw details",
|
||||||
|
"backToList": "Back to draw list",
|
||||||
"detailSubtitle": "{{date}} · Round {{seq}}",
|
"detailSubtitle": "{{date}} · Round {{seq}}",
|
||||||
|
"overviewTitle": "Draw overview",
|
||||||
|
"overviewBetTotal": "Total bet",
|
||||||
|
"overviewPayoutTotal": "Total payout",
|
||||||
|
"overviewProfitLoss": "Profit/Loss",
|
||||||
"scheduleTitle": "Schedule",
|
"scheduleTitle": "Schedule",
|
||||||
"resultBatchesTitle": "Result batches",
|
"resultBatchesTitle": "Result batches",
|
||||||
"batchSummaryTotal": "{{count}} batch(es)",
|
"batchSummaryTotal": "{{count}} batch(es)",
|
||||||
"batchSummaryPending": "{{count}} pending",
|
"batchSummaryPending": "{{count}} pending",
|
||||||
"batchSummaryPublished": "{{count}} published",
|
"batchSummaryPublished": "{{count}} published",
|
||||||
"noResultBatchesYet": "No result batches yet.",
|
"noResultBatchesYet": "No result batches yet.",
|
||||||
|
"reviewQueueHint": "After results are generated, continue in Review & publish.",
|
||||||
"goToReviewTab": "Review & publish",
|
"goToReviewTab": "Review & publish",
|
||||||
"businessDate": "Business date",
|
"businessDate": "Business date",
|
||||||
"sequenceNo": "Sequence no.",
|
"sequenceNo": "Sequence no.",
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
"configTitle": "Jackpot pool configuration",
|
"configTitle": "Jackpot pool configuration",
|
||||||
"pageDescription": "Maintain per-currency pool parameters; contribution and payout logs are below.",
|
"pageDescription": "Maintain per-currency pool parameters; contribution and payout logs are below.",
|
||||||
"poolsSectionDescription": "Contribution rate, burst threshold, switch, and manual burst.",
|
"poolsSectionDescription": "Contribution rate, burst threshold, switch, and manual burst.",
|
||||||
|
"rulesTitle": "Rules",
|
||||||
|
"rulesJoin": "Only successfully placed lines that meet the minimum participation bet amount contribute to the pool.",
|
||||||
|
"rulesBurst": "The pool releases when the burst threshold is reached, the forced burst gap is met, or a configured play combo triggers it.",
|
||||||
|
"rulesManual": "Manual burst is a super-admin fallback only. You can enter either the numeric draw ID or the draw number.",
|
||||||
"recordsSectionTitle": "Contribution & payout logs",
|
"recordsSectionTitle": "Contribution & payout logs",
|
||||||
"recordsSectionDescription": "Filter payout and contribution entries (read-only).",
|
"recordsSectionDescription": "Filter payout and contribution entries (read-only).",
|
||||||
"loadFailed": "Failed to load",
|
"loadFailed": "Failed to load",
|
||||||
"saveSuccess": "Saved",
|
"saveSuccess": "Saved",
|
||||||
"saveFailed": "Save failed",
|
"saveFailed": "Save failed",
|
||||||
"invalidDrawId": "Enter a valid draw number",
|
"invalidDrawId": "Enter a valid draw ID or draw number",
|
||||||
"manualBurstSuccess": "Jackpot burst triggered manually",
|
"manualBurstSuccess": "Jackpot burst triggered manually",
|
||||||
"manualBurstFailed": "Manual burst failed",
|
"manualBurstFailed": "Manual burst failed",
|
||||||
"noPoolData": "No pool data",
|
"noPoolData": "No pool data",
|
||||||
"displayBalance": "Display balance {{amount}}",
|
"displayBalance": "Display balance {{amount}}",
|
||||||
"currentAmount": "Current pool balance (minor unit)",
|
"currentAmount": "Current pool balance (major unit)",
|
||||||
"balanceAdjustmentTitle": "Balance adjustment",
|
"balanceAdjustmentTitle": "Balance adjustment",
|
||||||
"balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.",
|
"balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.",
|
||||||
"adjustmentDirection": "Direction",
|
"adjustmentDirection": "Direction",
|
||||||
@@ -33,13 +37,13 @@
|
|||||||
"recentAdjustments": "Recent adjustments",
|
"recentAdjustments": "Recent adjustments",
|
||||||
"contributionRate": "Contribution rate (%)",
|
"contributionRate": "Contribution rate (%)",
|
||||||
"contributionRatePlaceholder": "e.g. 2 = 2%",
|
"contributionRatePlaceholder": "e.g. 2 = 2%",
|
||||||
"triggerThreshold": "Burst threshold (minor unit)",
|
"triggerThreshold": "Burst threshold (major unit)",
|
||||||
"triggerThresholdPlaceholder": "Enter burst threshold",
|
"triggerThresholdPlaceholder": "Enter burst threshold",
|
||||||
"payoutRate": "Burst payout rate (%)",
|
"payoutRate": "Burst payout rate (%)",
|
||||||
"payoutRatePlaceholder": "e.g. 5 = 5%",
|
"payoutRatePlaceholder": "e.g. 5 = 5%",
|
||||||
"forceTriggerGap": "Force burst gap (settled draws)",
|
"forceTriggerGap": "Force burst gap (settled draws)",
|
||||||
"forceTriggerGapPlaceholder": "Enter forced burst gap in draws",
|
"forceTriggerGapPlaceholder": "Enter forced burst gap in draws",
|
||||||
"minBetAmount": "Minimum bet amount (minor unit)",
|
"minBetAmount": "Minimum participation bet amount (major unit)",
|
||||||
"minBetAmountPlaceholder": "Enter minimum bet amount",
|
"minBetAmountPlaceholder": "Enter minimum bet amount",
|
||||||
"comboTriggerPlays": "Combo trigger plays (comma separated)",
|
"comboTriggerPlays": "Combo trigger plays (comma separated)",
|
||||||
"comboTriggerPlaysPlaceholder": "Enter play codes separated by commas, for example straight,ibox",
|
"comboTriggerPlaysPlaceholder": "Enter play codes separated by commas, for example straight,ibox",
|
||||||
@@ -48,8 +52,8 @@
|
|||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"manualBurstDrawId": "Draw ID for manual burst",
|
"manualBurstDrawId": "Draw for manual burst (ID or number)",
|
||||||
"manualBurstHint": "Super admin only. Requires a settled draw with first-prize winners. Pool release follows the configured payout rate.",
|
"manualBurstHint": "Super admin only. You can enter either the numeric draw ID or the draw number. Requires a settled draw with first-prize winners. Pool release follows the configured payout rate.",
|
||||||
"manualBurstConfirmTitle": "Confirm manual jackpot burst?",
|
"manualBurstConfirmTitle": "Confirm manual jackpot burst?",
|
||||||
"manualBurstConfirmDescription": "Jackpot will be split among first-prize winners for draw {{drawId}} using the payout rate. Pool balance will be reduced. This cannot be undone automatically.",
|
"manualBurstConfirmDescription": "Jackpot will be split among first-prize winners for draw {{drawId}} using the payout rate. Pool balance will be reduced. This cannot be undone automatically.",
|
||||||
"processing": "Processing…",
|
"processing": "Processing…",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
|
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
|
||||||
"tabOverview": "Overview",
|
"tabOverview": "Overview",
|
||||||
"tabTickets": "Tickets",
|
"tabTickets": "Tickets",
|
||||||
|
"ticketTableHint": "This table shows the player's recent tickets. Use the row action to jump into the main ticket list for full context and troubleshooting.",
|
||||||
"tabWalletTxns": "Wallet transactions",
|
"tabWalletTxns": "Wallet transactions",
|
||||||
"tabCreditLedger": "Credit ledger",
|
"tabCreditLedger": "Credit ledger",
|
||||||
"tabTransferOrders": "Transfer orders",
|
"tabTransferOrders": "Transfer orders",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.",
|
"createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.",
|
||||||
"createSummaryPending": "Choose a complete reconcile date range before creating a job.",
|
"createSummaryPending": "Choose a complete reconcile date range before creating a job.",
|
||||||
"jobsTitle": "Reconcile jobs",
|
"jobsTitle": "Reconcile jobs",
|
||||||
"jobsDesc": "Use the action on the right to open paginated item details.",
|
"jobsDesc": "Use the action on the right to open discrepancy details and paginated results.",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"jobNo": "Job no.",
|
"jobNo": "Job no.",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
@@ -42,10 +42,18 @@
|
|||||||
"createdAt": "Created at",
|
"createdAt": "Created at",
|
||||||
"operate": "Action",
|
"operate": "Action",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"detailsTitle": "Job details",
|
"viewDetails": "View discrepancy details",
|
||||||
|
"detailsTitle": "Discrepancy details",
|
||||||
"sideARef": "Lottery ref",
|
"sideARef": "Lottery ref",
|
||||||
"sideBRef": "Main site ref",
|
"sideBRef": "Main site ref",
|
||||||
"differenceAmount": "Difference (cent)",
|
"differenceAmount": "Difference (cent)",
|
||||||
|
"itemResult": "Check result",
|
||||||
|
"diagnosis": "Issue summary",
|
||||||
|
"suggestedAction": "Suggested action",
|
||||||
|
"processingStatus": "Processing status",
|
||||||
|
"quickAccess": "Quick access",
|
||||||
|
"openTransferOrder": "Open transfer order",
|
||||||
|
"openWalletTxn": "Open wallet ledger",
|
||||||
"detectedAt": "Detected at",
|
"detectedAt": "Detected at",
|
||||||
"noDetails": "No details",
|
"noDetails": "No details",
|
||||||
"playerSearch": "Player (optional)",
|
"playerSearch": "Player (optional)",
|
||||||
@@ -63,5 +71,33 @@
|
|||||||
"statusFailed": "Failed",
|
"statusFailed": "Failed",
|
||||||
"itemMismatch": "Mismatch",
|
"itemMismatch": "Mismatch",
|
||||||
"itemMatched": "Matched",
|
"itemMatched": "Matched",
|
||||||
"itemPendingCheck": "Pending check"
|
"itemPendingCheck": "Pending check",
|
||||||
|
"itemStaleProcessing": "Stale processing",
|
||||||
|
"itemPendingReconcile": "Pending manual reconcile",
|
||||||
|
"itemMissingWalletTxn": "Missing wallet ledger",
|
||||||
|
"itemUnexpectedWalletTxn": "Unexpected wallet ledger",
|
||||||
|
"itemMissingRefund": "Missing refund ledger",
|
||||||
|
"itemMissingReversal": "Missing reversal ledger",
|
||||||
|
"itemResolved": "Resolved",
|
||||||
|
"itemUnresolved": "Unresolved",
|
||||||
|
"diagnosisStaleProcessing": "The transfer order has stayed in processing for too long and the system has no final success or failure result.",
|
||||||
|
"diagnosisPendingReconcile": "The transfer order is marked for manual reconciliation and needs a human-confirmed final result.",
|
||||||
|
"diagnosisMissingWalletTxn": "The transfer order status moved forward, but the matching lottery wallet ledger entry is missing.",
|
||||||
|
"diagnosisUnexpectedWalletTxn": "The lottery side contains extra wallet ledger entries that do not match the current transfer status.",
|
||||||
|
"diagnosisMissingRefund": "The transfer-out failed, but the expected refund ledger entry was not found.",
|
||||||
|
"diagnosisMissingReversal": "The transfer order was reversed, but the matching reversal ledger entry is missing on the lottery side.",
|
||||||
|
"diagnosisMatched": "This record is already balanced and needs no further action.",
|
||||||
|
"diagnosisPendingCheck": "This record still needs manual verification.",
|
||||||
|
"actionStaleProcessing": "Check whether the main site already debited successfully, then decide whether the lottery side needs a reversal or a compensating entry.",
|
||||||
|
"actionPendingReconcile": "Open the transfer order first, confirm the main-site outcome, then decide whether to credit, reverse, or close the case.",
|
||||||
|
"actionMissingWalletTxn": "Open both the transfer order and wallet ledger to confirm whether a compensating wallet entry should be added.",
|
||||||
|
"actionUnexpectedWalletTxn": "Check for duplicate posting or an incorrect compensation entry, and reverse it if needed.",
|
||||||
|
"actionMissingRefund": "Confirm whether the main site already refunded the player, then add the lottery-side refund entry or reverse the order.",
|
||||||
|
"actionMissingReversal": "Confirm the reversal result externally, then add the matching lottery-side reversal ledger entry.",
|
||||||
|
"actionMatched": "No action needed.",
|
||||||
|
"actionPendingCheck": "Continue verification with the transfer order and wallet ledger.",
|
||||||
|
"actionResolved": "This exception has already been handled. Current transfer-order status: {{status}}. Open the transfer order if you want to verify the result.",
|
||||||
|
"transferStatusSuccess": "Successful",
|
||||||
|
"transferStatusReversed": "Reversed",
|
||||||
|
"transferStatusManual": "Case closed"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"loadFailed": "Failed to load export jobs",
|
"loadFailed": "Failed to load export jobs",
|
||||||
"downloadSuccess": "Downloaded {{jobNo}}",
|
"downloadSuccess": "Downloaded {{jobNo}}",
|
||||||
"downloadFailed": "Download failed",
|
"downloadFailed": "Download failed",
|
||||||
|
"currentReportHint": "Only export tasks for the currently selected report are shown here.",
|
||||||
"columns": {
|
"columns": {
|
||||||
"jobNo": "Job no.",
|
"jobNo": "Job no.",
|
||||||
"report": "Report",
|
"report": "Report",
|
||||||
@@ -191,6 +192,8 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"records": "Records",
|
"records": "Records",
|
||||||
"currentPage": "This page",
|
"currentPage": "This page",
|
||||||
|
"notQueried": "Not queried",
|
||||||
|
"notSet": "Not set",
|
||||||
"drawNo": "Draw no.",
|
"drawNo": "Draw no.",
|
||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"exportRows": "Export rows",
|
"exportRows": "Export rows",
|
||||||
@@ -225,12 +228,10 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"createdAt": "Created at"
|
"createdAt": "Created at"
|
||||||
},
|
},
|
||||||
"legacyTitle": "Legacy wallet reports",
|
|
||||||
"categories": {
|
"categories": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"profit": "Profit",
|
"profit": "Profit",
|
||||||
"wallet": "Funds",
|
"wallet": "Funds",
|
||||||
"legacy": "Legacy",
|
|
||||||
"risk": "Risk",
|
"risk": "Risk",
|
||||||
"audit": "Audit"
|
"audit": "Audit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"actualDeduct": "Actual deduct",
|
"actualDeduct": "Actual deduct",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
|
"viewTicketInList": "View this ticket",
|
||||||
"failReason": "Fail reason",
|
"failReason": "Fail reason",
|
||||||
"winAmount": "Win amount",
|
"winAmount": "Win amount",
|
||||||
"placedAt": "Placed at",
|
"placedAt": "Placed at",
|
||||||
|
|||||||
@@ -345,6 +345,13 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"ne": "नेपाली"
|
"ne": "नेपाली"
|
||||||
},
|
},
|
||||||
|
"categories": {
|
||||||
|
"standard": "मानक",
|
||||||
|
"attribute": "विशेषता",
|
||||||
|
"position": "स्थिति",
|
||||||
|
"box": "बक्स",
|
||||||
|
"jackpot": "ज्याकपोट"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"playCode": "खेल कोड",
|
"playCode": "खेल कोड",
|
||||||
"category": "श्रेणी",
|
"category": "श्रेणी",
|
||||||
@@ -510,6 +517,8 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"requireAtLeastOne": "कम्तीमा एक क्याप row आवश्यक छ",
|
"requireAtLeastOne": "कम्तीमा एक क्याप row आवश्यक छ",
|
||||||
"defaultGreaterThanZero": "पूर्वनिर्धारित क्याप रकम 0 भन्दा ठूलो हुनुपर्छ",
|
"defaultGreaterThanZero": "पूर्वनिर्धारित क्याप रकम 0 भन्दा ठूलो हुनुपर्छ",
|
||||||
|
"defaultCannotBindDraw": "पूर्वनिर्धारित क्याप कुनै निश्चित draw मा बाँध्न मिल्दैन",
|
||||||
|
"specialGreaterThanZero": "विशेष क्याप रकम 0 भन्दा ठूलो हुनुपर्छ: {{number}}",
|
||||||
"numberMustBe4Digits": "नम्बर 4 अङ्कको हुनुपर्छ: {{number}}",
|
"numberMustBe4Digits": "नम्बर 4 अङ्कको हुनुपर्छ: {{number}}",
|
||||||
"enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्"
|
"enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्"
|
||||||
},
|
},
|
||||||
@@ -530,14 +539,37 @@
|
|||||||
"defaultCap": {
|
"defaultCap": {
|
||||||
"title": "पूर्वनिर्धारित क्याप",
|
"title": "पूर्वनिर्धारित क्याप",
|
||||||
"description": "विशेष क्याप नभएका नम्बरहरूमा यही पूर्वनिर्धारित क्याप टेम्प्लेट लागू हुन्छ।",
|
"description": "विशेष क्याप नभएका नम्बरहरूमा यही पूर्वनिर्धारित क्याप टेम्प्लेट लागू हुन्छ।",
|
||||||
"fieldLabel": "क्याप रकम (सानो एकाइ)"
|
"fieldLabel": "क्याप रकम (मुख्य एकाइ)"
|
||||||
},
|
},
|
||||||
"specialCaps": {
|
"specialCaps": {
|
||||||
"title": "विशेष क्यापहरू"
|
"title": "विशेष क्यापहरू",
|
||||||
|
"description": "draw नछानेमा ग्लोबल नम्बर क्याप हुन्छ; draw छानेपछि त्यो सोही draw को विशेष क्याप हुन्छ।"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"global": "ग्लोबल नम्बर",
|
||||||
|
"drawId": "Draw ID: {{id}}"
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"globalTitle": "ग्लोबल नम्बर क्याप",
|
||||||
|
"globalDescription": "कुनै निश्चित draw मा नबाँधिने, दीर्घकालीन रूपमा लागू हुने क्याप। सामान्य नम्बर-स्तर सीमा यहीं राखिन्छ।",
|
||||||
|
"globalEmpty": "अहिलेसम्म कुनै ग्लोबल नम्बर क्याप छैन।",
|
||||||
|
"drawTitle": "Draw-विशेष क्याप",
|
||||||
|
"drawDescription": "छानिएको draw मा मात्र लागू हुन्छ। कुनै draw का लागि अस्थायी रूपमा सीमा कडा वा खुकुलो बनाउन प्रयोग गर्नुहोस्।",
|
||||||
|
"drawEmpty": "अहिलेसम्म कुनै draw-विशेष क्याप छैन।",
|
||||||
|
"count": "{{count}} वटा"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"defaultCap": "पूर्वनिर्धारित क्याप",
|
||||||
|
"defaultHint": "विशेष नियम नभएका नम्बरहरू अन्ततः यही मानमा फर्किन्छन्।",
|
||||||
|
"globalCaps": "ग्लोबल नम्बर क्याप",
|
||||||
|
"globalHint": "दीर्घकालीन नियम, कुनै एक draw सँग मात्र जोडिएको हुँदैन।",
|
||||||
|
"drawCaps": "Draw-विशेष क्याप",
|
||||||
|
"drawHint": "छानिएका draw हरूमा मात्र अस्थायी रूपमा लागू हुने नियम।"
|
||||||
},
|
},
|
||||||
"loadingDetails": "विवरण लोड हुँदैछ…",
|
"loadingDetails": "विवरण लोड हुँदैछ…",
|
||||||
"noDetailRows": "विवरण row छैन।",
|
"noDetailRows": "विवरण row छैन।",
|
||||||
"table": {
|
"table": {
|
||||||
|
"scope": "दायरा",
|
||||||
"number": "नम्बर",
|
"number": "नम्बर",
|
||||||
"capAmount": "क्याप रकम",
|
"capAmount": "क्याप रकम",
|
||||||
"used": "प्रयोग भएको",
|
"used": "प्रयोग भएको",
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
"configTitle": "Jackpot पूल कन्फिगरेसन",
|
"configTitle": "Jackpot पूल कन्फिगरेसन",
|
||||||
"pageDescription": "मुद्रा अनुसार पूल प्यारामिटर; तल योगदान र पेआउट लग देख्नुहोस्।",
|
"pageDescription": "मुद्रा अनुसार पूल प्यारामिटर; तल योगदान र पेआउट लग देख्नुहोस्।",
|
||||||
"poolsSectionDescription": "योगदान दर, बर्स्ट थ्रेसहोल्ड, स्विच र म्यानुअल बर्स्ट।",
|
"poolsSectionDescription": "योगदान दर, बर्स्ट थ्रेसहोल्ड, स्विच र म्यानुअल बर्स्ट।",
|
||||||
|
"rulesTitle": "नियम जानकारी",
|
||||||
|
"rulesJoin": "सफलतापूर्वक पेश भएका र न्यूनतम सहभागिता बेट रकम पुगेका लाइनहरू मात्र पूलमा योगदान गर्छन्।",
|
||||||
|
"rulesBurst": "थ्रेसहोल्ड पुगेपछि, जबर्जस्ती बर्स्ट अन्तर पुगेपछि, वा तोकिएको कम्बो ट्रिगर भएपछि पूल रिलिज हुन्छ।",
|
||||||
|
"rulesManual": "म्यानुअल बर्स्ट सुपर एडमिनको fallback मात्र हो। यसमा संख्यात्मक draw ID वा draw number दुवै लेख्न सकिन्छ।",
|
||||||
"recordsSectionTitle": "योगदान र पेआउट लग",
|
"recordsSectionTitle": "योगदान र पेआउट लग",
|
||||||
"recordsSectionDescription": "पेआउट र योगदान प्रविष्टि फिल्टर (पढ्न मात्र)।",
|
"recordsSectionDescription": "पेआउट र योगदान प्रविष्टि फिल्टर (पढ्न मात्र)।",
|
||||||
"loadFailed": "लोड असफल भयो",
|
"loadFailed": "लोड असफल भयो",
|
||||||
"saveSuccess": "सुरक्षित भयो",
|
"saveSuccess": "सुरक्षित भयो",
|
||||||
"saveFailed": "सुरक्षित गर्न असफल",
|
"saveFailed": "सुरक्षित गर्न असफल",
|
||||||
"invalidDrawId": "मान्य ड्रअ नम्बर लेख्नुहोस्",
|
"invalidDrawId": "मान्य draw ID वा draw number लेख्नुहोस्",
|
||||||
"manualBurstSuccess": "Jackpot म्यानुअल रूपमा ट्रिगर भयो",
|
"manualBurstSuccess": "Jackpot म्यानुअल रूपमा ट्रिगर भयो",
|
||||||
"manualBurstFailed": "म्यानुअल बर्स्ट असफल भयो",
|
"manualBurstFailed": "म्यानुअल बर्स्ट असफल भयो",
|
||||||
"noPoolData": "पूल डाटा छैन",
|
"noPoolData": "पूल डाटा छैन",
|
||||||
"displayBalance": "प्रदर्शित ब्यालेन्स {{amount}}",
|
"displayBalance": "प्रदर्शित ब्यालेन्स {{amount}}",
|
||||||
"currentAmount": "हालको पूल ब्यालेन्स (सानो एकाइ)",
|
"currentAmount": "हालको पूल ब्यालेन्स (मुख्य एकाइ)",
|
||||||
"balanceAdjustmentTitle": "ब्यालेन्स समायोजन",
|
"balanceAdjustmentTitle": "ब्यालेन्स समायोजन",
|
||||||
"balanceAdjustmentHint": "कारण अनिवार्य; प्रत्येक परिवर्तन समायोजन लेजरमा लेखिन्छ। Save बाट सिधै ब्यालेन्स मिलाउन मिल्दैन।",
|
"balanceAdjustmentHint": "कारण अनिवार्य; प्रत्येक परिवर्तन समायोजन लेजरमा लेखिन्छ। Save बाट सिधै ब्यालेन्स मिलाउन मिल्दैन।",
|
||||||
"adjustmentDirection": "दिशा",
|
"adjustmentDirection": "दिशा",
|
||||||
@@ -33,13 +37,13 @@
|
|||||||
"recentAdjustments": "भर्खरका समायोजन",
|
"recentAdjustments": "भर्खरका समायोजन",
|
||||||
"contributionRate": "योगदान अनुपात 0-1",
|
"contributionRate": "योगदान अनुपात 0-1",
|
||||||
"contributionRatePlaceholder": "योगदान अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.02",
|
"contributionRatePlaceholder": "योगदान अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.02",
|
||||||
"triggerThreshold": "बर्स्ट थ्रेसहोल्ड (सानो एकाइ)",
|
"triggerThreshold": "बर्स्ट थ्रेसहोल्ड (मुख्य एकाइ)",
|
||||||
"triggerThresholdPlaceholder": "ट्रिगर थ्रेसहोल्ड प्रविष्ट गर्नुहोस्",
|
"triggerThresholdPlaceholder": "ट्रिगर थ्रेसहोल्ड प्रविष्ट गर्नुहोस्",
|
||||||
"payoutRate": "बर्स्ट भुक्तानी अनुपात 0-1",
|
"payoutRate": "बर्स्ट भुक्तानी अनुपात 0-1",
|
||||||
"payoutRatePlaceholder": "पेआउट अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.05",
|
"payoutRatePlaceholder": "पेआउट अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.05",
|
||||||
"forceTriggerGap": "बलपूर्वक बर्स्ट अन्तर (सेटल ड्रअ)",
|
"forceTriggerGap": "बलपूर्वक बर्स्ट अन्तर (सेटल ड्रअ)",
|
||||||
"forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्",
|
"forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्",
|
||||||
"minBetAmount": "न्यूनतम बेट रकम (सानो एकाइ)",
|
"minBetAmount": "न्यूनतम सहभागिता बेट रकम (मुख्य एकाइ)",
|
||||||
"minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्",
|
"minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्",
|
||||||
"comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)",
|
"comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)",
|
||||||
"comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox",
|
"comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox",
|
||||||
@@ -48,8 +52,8 @@
|
|||||||
"enabled": "खुला",
|
"enabled": "खुला",
|
||||||
"saving": "सुरक्षित हुँदैछ…",
|
"saving": "सुरक्षित हुँदैछ…",
|
||||||
"save": "सुरक्षित गर्नुहोस्",
|
"save": "सुरक्षित गर्नुहोस्",
|
||||||
"manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ ID",
|
"manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ (ID वा नम्बर)",
|
||||||
"manualBurstHint": "सुपर एडमिन मात्र। बसेको ड्रअ र प्रथम पुरस्कार विजेताहरू चाहिन्छ। पेआउट दर अनुसार वितरण हुन्छ।",
|
"manualBurstHint": "सुपर एडमिन मात्र। संख्यात्मक draw ID वा draw number दुवै लेख्न सकिन्छ। बसेको ड्रअ र प्रथम पुरस्कार विजेताहरू चाहिन्छ। पेआउट दर अनुसार वितरण हुन्छ।",
|
||||||
"manualBurstConfirmTitle": "म्यानुअल बर्स्ट पुष्टि गर्ने?",
|
"manualBurstConfirmTitle": "म्यानुअल बर्स्ट पुष्टि गर्ने?",
|
||||||
"manualBurstConfirmDescription": "ड्रअ {{drawId}} का प्रथम पुरस्कार विजेताहरूलाई Jackpot वितरण गरिनेछ।",
|
"manualBurstConfirmDescription": "ड्रअ {{drawId}} का प्रथम पुरस्कार विजेताहरूलाई Jackpot वितरण गरिनेछ।",
|
||||||
"processing": "प्रक्रियामा…",
|
"processing": "प्रक्रियामा…",
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
"draws": "期号列表",
|
"draws": "期号列表",
|
||||||
"config": "运营配置",
|
"config": "运营配置",
|
||||||
"rules_plays": "投注规则",
|
"rules_plays": "投注规则",
|
||||||
"rules_odds": "赔率与回水",
|
"rules_odds": "赔率与基础回水",
|
||||||
"risk_cap": "限额版本",
|
"risk_cap": "限额版本",
|
||||||
"risk": "风控",
|
"risk": "风控",
|
||||||
"settlement": "结算",
|
"settlement": "结算",
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
"reports": "报表中心",
|
"reports": "报表中心",
|
||||||
"draws": "期号列表",
|
"draws": "期号列表",
|
||||||
"rules_plays": "投注规则",
|
"rules_plays": "投注规则",
|
||||||
"rules_odds": "赔率与回水",
|
"rules_odds": "赔率与基础回水",
|
||||||
"rules": "投注规则",
|
"rules": "投注规则",
|
||||||
"risk_cap": "限额版本",
|
"risk_cap": "限额版本",
|
||||||
"risk": "风控中心",
|
"risk": "风控中心",
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
"risk-cap": "赔付封顶"
|
"risk-cap": "赔付封顶"
|
||||||
},
|
},
|
||||||
"rulesPlaysTitle": "投注规则",
|
"rulesPlaysTitle": "投注规则",
|
||||||
"rulesOddsTitle": "赔率与回水",
|
"rulesOddsTitle": "赔率与基础回水",
|
||||||
"rulesOddsDescription": "赔率矩阵与回水比例在同一页维护,共用赔率版本线。",
|
"rulesOddsDescription": "赔率矩阵与基础回水在同一页维护,共用赔率版本线。",
|
||||||
"rulesOddsDescriptionShort": "左侧选玩法,右侧改赔率与回水;修改后记得保存草稿并发布。",
|
"rulesOddsDescriptionShort": "左侧选玩法,右侧修改赔率与基础回水;代理/玩家回水需在此基础上叠加,修改后记得保存草稿并发布。",
|
||||||
"riskCapTitle": "限额版本"
|
"riskCapTitle": "限额版本"
|
||||||
},
|
},
|
||||||
"hub": {
|
"hub": {
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
"description": "按业务域进入玩法、赔率回水、奖池与限额配置;接入站点在侧栏「平台管理 → 接入配置」。",
|
"description": "按业务域进入玩法、赔率回水、奖池与限额配置;接入站点在侧栏「平台管理 → 接入配置」。",
|
||||||
"playsTitle": "投注规则",
|
"playsTitle": "投注规则",
|
||||||
"playsDesc": "玩法开关、限额与规则说明",
|
"playsDesc": "玩法开关、限额与规则说明",
|
||||||
"oddsTitle": "赔率与回水",
|
"oddsTitle": "赔率与基础回水",
|
||||||
"oddsDesc": "赔率矩阵与回水比例,版本一体发布",
|
"oddsDesc": "赔率矩阵与基础回水,版本一体发布",
|
||||||
"jackpotTitle": "奖池",
|
"jackpotTitle": "奖池",
|
||||||
"jackpotDesc": "奖池参数与进账流水",
|
"jackpotDesc": "奖池参数与进账流水",
|
||||||
"riskCapTitle": "限额版本",
|
"riskCapTitle": "限额版本",
|
||||||
@@ -360,6 +360,20 @@
|
|||||||
"readOnlyDraftHint": "当前版本为只读,请先创建草稿。",
|
"readOnlyDraftHint": "当前版本为只读,请先创建草稿。",
|
||||||
"batchEnabledCount": "{{enabledCount}}/{{total}} 已开启",
|
"batchEnabledCount": "{{enabledCount}}/{{total}} 已开启",
|
||||||
"noPlayTypes": "暂无玩法",
|
"noPlayTypes": "暂无玩法",
|
||||||
|
"filters": {
|
||||||
|
"sectionTitle": "筛选玩法",
|
||||||
|
"sectionDescription": "先缩小范围,再进行批量开关或逐项修改。",
|
||||||
|
"keyword": "搜索玩法",
|
||||||
|
"keywordPlaceholder": "按玩法编码、显示名或分类筛选",
|
||||||
|
"category": "分类",
|
||||||
|
"status": "状态",
|
||||||
|
"allCategories": "全部分类",
|
||||||
|
"allStatuses": "全部状态",
|
||||||
|
"uncategorized": "未分类",
|
||||||
|
"reset": "清空筛选",
|
||||||
|
"empty": "没有匹配的玩法",
|
||||||
|
"groupCount": "{{count}} 个玩法"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"enable": "开启",
|
"enable": "开启",
|
||||||
"disable": "关闭",
|
"disable": "关闭",
|
||||||
@@ -371,6 +385,13 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"ne": "नेपाली"
|
"ne": "नेपाली"
|
||||||
},
|
},
|
||||||
|
"categories": {
|
||||||
|
"standard": "标准类",
|
||||||
|
"attribute": "属性类",
|
||||||
|
"position": "位置类",
|
||||||
|
"box": "包号类",
|
||||||
|
"jackpot": "奖池类"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"playCode": "玩法编码",
|
"playCode": "玩法编码",
|
||||||
"category": "分类",
|
"category": "分类",
|
||||||
@@ -422,7 +443,7 @@
|
|||||||
},
|
},
|
||||||
"currentSelection": "当前选择:{{category}} / {{play}}",
|
"currentSelection": "当前选择:{{category}} / {{play}}",
|
||||||
"playSelectPlaceholder": "选择玩法",
|
"playSelectPlaceholder": "选择玩法",
|
||||||
"readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与回水。",
|
"readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与基础回水。",
|
||||||
"table": {
|
"table": {
|
||||||
"prizeScope": "奖级范围",
|
"prizeScope": "奖级范围",
|
||||||
"multiplier": "赔率倍数"
|
"multiplier": "赔率倍数"
|
||||||
@@ -461,11 +482,11 @@
|
|||||||
"loadingDetails": "正在加载详情…",
|
"loadingDetails": "正在加载详情…",
|
||||||
"multiplier": "倍数 x{{value}} · {{currency}}",
|
"multiplier": "倍数 x{{value}} · {{currency}}",
|
||||||
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
|
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
|
||||||
"rebateRate": "回水比例 (%)",
|
"rebateRate": "基础回水比例 (%)",
|
||||||
"rebateRateHint": "会把 rebate_rate 写入该玩法下所有奖级范围。",
|
"rebateRateHint": "这里维护的是平台基础回水,会把 rebate_rate 写入该玩法下所有奖级范围;代理/玩家回水需在此基础上叠加。",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"multiplier": "请输入赔率倍数",
|
"multiplier": "请输入赔率倍数",
|
||||||
"rebateRate": "请输入返点比例"
|
"rebateRate": "请输入基础回水比例"
|
||||||
},
|
},
|
||||||
"publishFailed": "发布失败",
|
"publishFailed": "发布失败",
|
||||||
"createDraftSuccess": "已创建草稿 v{{version}}",
|
"createDraftSuccess": "已创建草稿 v{{version}}",
|
||||||
@@ -490,33 +511,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rebate": {
|
"rebate": {
|
||||||
"sectionHint": "回水比例写入赔率版本;请先在上方选择或创建赔率草稿。",
|
"sectionHint": "这里配置的是基础回水,写入赔率版本;请先在上方选择或创建赔率草稿。",
|
||||||
"lazyLoadHint": "向下滚动至回水区域后加载",
|
"lazyLoadHint": "向下滚动至回水区域后加载",
|
||||||
"embeddedVersionHint": "回水与上方赔率共用版本线,请在「赔率」区块切换版本。",
|
"embeddedVersionHint": "基础回水与上方赔率共用版本线,请在「赔率」区块切换版本。",
|
||||||
"sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。",
|
"sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。",
|
||||||
"publishLabel": "发布",
|
"publishLabel": "发布",
|
||||||
"publishSuccess": "已发布带回水的赔率版本",
|
"publishSuccess": "已发布带回水的赔率版本",
|
||||||
"publishFailed": "发布失败",
|
"publishFailed": "发布失败",
|
||||||
"publishDialog": {
|
"publishDialog": {
|
||||||
"title": "确认发布回水/赔率版本?",
|
"title": "确认发布基础回水/赔率版本?",
|
||||||
"description": "发布后将影响后续新注单的回水计算。",
|
"description": "发布后将影响后续新注单的基础回水计算;代理/玩家额外回水仍在此基础上叠加。",
|
||||||
"confirm": "确认发布"
|
"confirm": "确认发布"
|
||||||
},
|
},
|
||||||
"createDraftSuccess": "已创建草稿 v{{version}}",
|
"createDraftSuccess": "已创建草稿 v{{version}}",
|
||||||
"createDraftFailed": "创建草稿失败",
|
"createDraftFailed": "创建草稿失败",
|
||||||
"deleteFailed": "删除失败",
|
"deleteFailed": "删除失败",
|
||||||
"editingVersion": "当前编辑版本 v{{version}} · {{status}}",
|
"editingVersion": "当前编辑版本 v{{version}} · {{status}}",
|
||||||
"readOnlyHint": "修改回水前请先创建草稿。",
|
"readOnlyHint": "修改基础回水前请先创建草稿。",
|
||||||
"dimensionRatesMixedHint": "检测到同一维度(2D/3D/4D)内各玩法的首奖级回水比例不完全相同:上方三个百分比输入仅展示按玩法编码排序后的第一个有值示例,实际回水请以下方表格各行数据为准;使用批量输入会先按维度覆盖为同一比例。",
|
"dimensionRatesMixedHint": "检测到同一维度(2D/3D/4D)内各玩法的首奖级基础回水不完全相同:上方三个百分比输入仅展示按玩法编码排序后的第一个有值示例,实际基础回水请以下方表格各行数据为准;使用批量输入会先按维度覆盖为同一比例。",
|
||||||
"fields": {
|
"fields": {
|
||||||
"d2": "2D 回水比例 (%)",
|
"d2": "2D 基础回水比例 (%)",
|
||||||
"d3": "3D 回水比例 (%)",
|
"d3": "3D 基础回水比例 (%)",
|
||||||
"d4": "4D 回水比例 (%)"
|
"d4": "4D 基础回水比例 (%)"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"d2": "请输入 2D 返点",
|
"d2": "请输入 2D 基础回水",
|
||||||
"d3": "请输入 3D 返点",
|
"d3": "请输入 3D 基础回水",
|
||||||
"d4": "请输入 4D 返点"
|
"d4": "请输入 4D 基础回水"
|
||||||
},
|
},
|
||||||
"winEnjoy": {
|
"winEnjoy": {
|
||||||
"label": "中奖注单结算时再扣回水",
|
"label": "中奖注单结算时再扣回水",
|
||||||
@@ -536,6 +557,8 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"requireAtLeastOne": "至少需要一条封顶配置",
|
"requireAtLeastOne": "至少需要一条封顶配置",
|
||||||
"defaultGreaterThanZero": "默认封顶金额必须大于 0",
|
"defaultGreaterThanZero": "默认封顶金额必须大于 0",
|
||||||
|
"defaultCannotBindDraw": "默认封顶不能绑定具体期号",
|
||||||
|
"specialGreaterThanZero": "特殊封顶金额必须大于 0:{{number}}",
|
||||||
"numberMustBe4Digits": "号码必须为 4 位数字:{{number}}",
|
"numberMustBe4Digits": "号码必须为 4 位数字:{{number}}",
|
||||||
"enterValidCapAmount": "请输入有效的封顶金额"
|
"enterValidCapAmount": "请输入有效的封顶金额"
|
||||||
},
|
},
|
||||||
@@ -556,14 +579,37 @@
|
|||||||
"defaultCap": {
|
"defaultCap": {
|
||||||
"title": "默认封顶",
|
"title": "默认封顶",
|
||||||
"description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。",
|
"description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。",
|
||||||
"fieldLabel": "封顶金额(最小单位)"
|
"fieldLabel": "封顶金额(主币单位)"
|
||||||
},
|
},
|
||||||
"specialCaps": {
|
"specialCaps": {
|
||||||
"title": "特殊封顶"
|
"title": "特殊封顶",
|
||||||
|
"description": "不选期号时表示全局号码限额;选择期号后表示该期单独限额。"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"global": "全局号码",
|
||||||
|
"drawId": "期号 ID:{{id}}"
|
||||||
|
},
|
||||||
|
"groups": {
|
||||||
|
"globalTitle": "全局号码限额",
|
||||||
|
"globalDescription": "长期生效,不绑定具体期号。适合配置某个号码的常规售卖上限。",
|
||||||
|
"globalEmpty": "暂无全局号码限额。",
|
||||||
|
"drawTitle": "期号单独限额",
|
||||||
|
"drawDescription": "仅对所选期号生效。适合某一期临时收紧或放宽某个号码的限额。",
|
||||||
|
"drawEmpty": "暂无期号单独限额。",
|
||||||
|
"count": "{{count}} 条"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"defaultCap": "默认封顶",
|
||||||
|
"defaultHint": "未命中特殊配置的号码,统一回落到这里。",
|
||||||
|
"globalCaps": "全局号码限额",
|
||||||
|
"globalHint": "长期规则,不跟随单一期号变化。",
|
||||||
|
"drawCaps": "期号单独限额",
|
||||||
|
"drawHint": "仅在指定期号内临时生效。"
|
||||||
},
|
},
|
||||||
"loadingDetails": "正在加载详情…",
|
"loadingDetails": "正在加载详情…",
|
||||||
"noDetailRows": "暂无明细行。",
|
"noDetailRows": "暂无明细行。",
|
||||||
"table": {
|
"table": {
|
||||||
|
"scope": "作用范围",
|
||||||
"number": "号码",
|
"number": "号码",
|
||||||
"capAmount": "封顶金额",
|
"capAmount": "封顶金额",
|
||||||
"used": "已占用",
|
"used": "已占用",
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
"queryDraw": "查询期号",
|
"queryDraw": "查询期号",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"fuzzyDrawNo": "模糊匹配期号",
|
"fuzzyDrawNo": "模糊匹配期号",
|
||||||
"viewDetails": "查看详情",
|
"viewDetails": "查看期号详情",
|
||||||
"editDraw": {
|
"editDraw": {
|
||||||
"action": "编辑",
|
"action": "编辑期号",
|
||||||
"title": "编辑期号",
|
"title": "编辑期号",
|
||||||
"description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑",
|
"description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑",
|
||||||
"drawNoPlaceholder": "请输入期号,如 20260526-008",
|
"drawNoPlaceholder": "请输入期号,如 20260526-008",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"failed": "更新失败"
|
"failed": "更新失败"
|
||||||
},
|
},
|
||||||
"deleteDraw": {
|
"deleteDraw": {
|
||||||
"action": "删除",
|
"action": "删除期号",
|
||||||
"title": "删除期号",
|
"title": "删除期号",
|
||||||
"description": "确定删除期号 {{drawNo}}?仅适用于未开始且无注单的记录,删除后不可恢复。",
|
"description": "确定删除期号 {{drawNo}}?仅适用于未开始且无注单的记录,删除后不可恢复。",
|
||||||
"success": "期号已删除",
|
"success": "期号已删除",
|
||||||
@@ -56,14 +56,20 @@
|
|||||||
"listActionsHint": "未开始且无注单:可编辑、删除;可下注/封盘/待开奖且无注单:可取消(见详情页更多操作)。",
|
"listActionsHint": "未开始且无注单:可编辑、删除;可下注/封盘/待开奖且无注单:可取消(见详情页更多操作)。",
|
||||||
"invalidDrawId": "无效的期号 ID",
|
"invalidDrawId": "无效的期号 ID",
|
||||||
"loadFailed": "加载失败,请检查登录与 API 配置",
|
"loadFailed": "加载失败,请检查登录与 API 配置",
|
||||||
"drawDetail": "开奖详情",
|
"drawDetail": "期号详情",
|
||||||
|
"backToList": "返回期号列表",
|
||||||
"detailSubtitle": "{{date}} · 第 {{seq}} 期",
|
"detailSubtitle": "{{date}} · 第 {{seq}} 期",
|
||||||
|
"overviewTitle": "期号概览",
|
||||||
|
"overviewBetTotal": "下注总额",
|
||||||
|
"overviewPayoutTotal": "派彩总额",
|
||||||
|
"overviewProfitLoss": "盈亏",
|
||||||
"scheduleTitle": "时间安排",
|
"scheduleTitle": "时间安排",
|
||||||
"resultBatchesTitle": "开奖批次",
|
"resultBatchesTitle": "开奖批次",
|
||||||
"batchSummaryTotal": "共 {{count}} 批",
|
"batchSummaryTotal": "共 {{count}} 批",
|
||||||
"batchSummaryPending": "待审 {{count}}",
|
"batchSummaryPending": "待审 {{count}}",
|
||||||
"batchSummaryPublished": "已发 {{count}}",
|
"batchSummaryPublished": "已发 {{count}}",
|
||||||
"noResultBatchesYet": "尚无开奖批次。",
|
"noResultBatchesYet": "尚无开奖批次。",
|
||||||
|
"reviewQueueHint": "结果生成后,可前往审核与发布处理。",
|
||||||
"goToReviewTab": "去审核与发布",
|
"goToReviewTab": "去审核与发布",
|
||||||
"businessDate": "业务日",
|
"businessDate": "业务日",
|
||||||
"sequenceNo": "流水序号",
|
"sequenceNo": "流水序号",
|
||||||
|
|||||||
@@ -3,17 +3,21 @@
|
|||||||
"configTitle": "奖池配置",
|
"configTitle": "奖池配置",
|
||||||
"pageDescription": "维护各币种奖池参数,下方可查询蓄水与派彩流水。",
|
"pageDescription": "维护各币种奖池参数,下方可查询蓄水与派彩流水。",
|
||||||
"poolsSectionDescription": "蓄水比例、爆池阈值、开关与手动爆池。",
|
"poolsSectionDescription": "蓄水比例、爆池阈值、开关与手动爆池。",
|
||||||
|
"rulesTitle": "规则说明",
|
||||||
|
"rulesJoin": "只有提交成功且满足最低参与下注额的注项,才会按蓄水比例进入奖池。",
|
||||||
|
"rulesBurst": "奖池会在达到爆池阈值、达到强制爆池间隔,或命中指定组合触发玩法时释放。",
|
||||||
|
"rulesManual": "手动爆池仅限超管兜底使用,可填写后台期号数字 ID 或期号编码。",
|
||||||
"recordsSectionTitle": "蓄水与派彩流水",
|
"recordsSectionTitle": "蓄水与派彩流水",
|
||||||
"recordsSectionDescription": "按条件筛选派彩记录与蓄水明细,只读查询。",
|
"recordsSectionDescription": "按条件筛选派彩记录与蓄水明细,只读查询。",
|
||||||
"loadFailed": "加载失败",
|
"loadFailed": "加载失败",
|
||||||
"saveSuccess": "已保存",
|
"saveSuccess": "已保存",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
"invalidDrawId": "请填写有效的期号 ID",
|
"invalidDrawId": "请填写有效的期号 ID 或期号编码",
|
||||||
"manualBurstSuccess": "已手动触发爆池",
|
"manualBurstSuccess": "已手动触发爆池",
|
||||||
"manualBurstFailed": "手动爆池失败",
|
"manualBurstFailed": "手动爆池失败",
|
||||||
"noPoolData": "暂无奖池数据",
|
"noPoolData": "暂无奖池数据",
|
||||||
"displayBalance": "展示余额 {{amount}}",
|
"displayBalance": "展示余额 {{amount}}",
|
||||||
"currentAmount": "当前池余额(最小单位)",
|
"currentAmount": "当前池余额(主币单位)",
|
||||||
"balanceAdjustmentTitle": "余额调整",
|
"balanceAdjustmentTitle": "余额调整",
|
||||||
"balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。",
|
"balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。",
|
||||||
"adjustmentDirection": "方向",
|
"adjustmentDirection": "方向",
|
||||||
@@ -33,13 +37,13 @@
|
|||||||
"recentAdjustments": "最近调整记录",
|
"recentAdjustments": "最近调整记录",
|
||||||
"contributionRate": "蓄水比例 (%)",
|
"contributionRate": "蓄水比例 (%)",
|
||||||
"contributionRatePlaceholder": "如 2 表示 2%",
|
"contributionRatePlaceholder": "如 2 表示 2%",
|
||||||
"triggerThreshold": "爆池阈值(最小单位)",
|
"triggerThreshold": "爆池阈值(主币单位)",
|
||||||
"triggerThresholdPlaceholder": "请输入触发阈值",
|
"triggerThresholdPlaceholder": "请输入触发阈值",
|
||||||
"payoutRate": "爆池派彩比例 (%)",
|
"payoutRate": "爆池派彩比例 (%)",
|
||||||
"payoutRatePlaceholder": "如 5 表示 5%",
|
"payoutRatePlaceholder": "如 5 表示 5%",
|
||||||
"forceTriggerGap": "强制爆池间隔(已结算期数)",
|
"forceTriggerGap": "强制爆池间隔(已结算期数)",
|
||||||
"forceTriggerGapPlaceholder": "请输入强制触发间隔期数",
|
"forceTriggerGapPlaceholder": "请输入强制触发间隔期数",
|
||||||
"minBetAmount": "最低下注额(最小单位)",
|
"minBetAmount": "最低参与下注额(主币单位)",
|
||||||
"minBetAmountPlaceholder": "请输入最低下注金额",
|
"minBetAmountPlaceholder": "请输入最低下注金额",
|
||||||
"comboTriggerPlays": "组合触发玩法(逗号分隔)",
|
"comboTriggerPlays": "组合触发玩法(逗号分隔)",
|
||||||
"comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox",
|
"comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox",
|
||||||
@@ -50,8 +54,8 @@
|
|||||||
"save": "保存",
|
"save": "保存",
|
||||||
"confirmSavePoolTitle": "确认保存奖池配置?",
|
"confirmSavePoolTitle": "确认保存奖池配置?",
|
||||||
"confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含池余额);余额请使用「余额调整」。",
|
"confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含池余额);余额请使用「余额调整」。",
|
||||||
"manualBurstDrawId": "手动爆池期号 ID",
|
"manualBurstDrawId": "手动爆池期号(ID 或编码)",
|
||||||
"manualBurstHint": "仅超级管理员可在紧急情况下触发;须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。",
|
"manualBurstHint": "仅超级管理员可在紧急情况下触发;可填写后台期号数字 ID 或期号编码。须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。",
|
||||||
"manualBurstConfirmTitle": "确认手动爆池?",
|
"manualBurstConfirmTitle": "确认手动爆池?",
|
||||||
"manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot,并扣减奖池余额。此操作不可自动撤销。",
|
"manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot,并扣减奖池余额。此操作不可自动撤销。",
|
||||||
"processing": "处理中…",
|
"processing": "处理中…",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
|
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
|
||||||
"tabOverview": "概览",
|
"tabOverview": "概览",
|
||||||
"tabTickets": "注单",
|
"tabTickets": "注单",
|
||||||
|
"ticketTableHint": "这里展示该玩家最近注单;如需查看完整上下文,可通过行内操作跳到总注单列表继续排查。",
|
||||||
"tabWalletTxns": "钱包流水",
|
"tabWalletTxns": "钱包流水",
|
||||||
"tabCreditLedger": "信用流水",
|
"tabCreditLedger": "信用流水",
|
||||||
"tabTransferOrders": "转账单",
|
"tabTransferOrders": "转账单",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
|
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
|
||||||
"createSummaryPending": "请选择完整的对账日期范围后,再创建任务。",
|
"createSummaryPending": "请选择完整的对账日期范围后,再创建任务。",
|
||||||
"jobsTitle": "对账任务",
|
"jobsTitle": "对账任务",
|
||||||
"jobsDesc": "在右侧操作中查看差异明细与分页。",
|
"jobsDesc": "在右侧操作中查看差异明细与分页结果。",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"jobNo": "任务号",
|
"jobNo": "任务号",
|
||||||
"type": "类型",
|
"type": "类型",
|
||||||
@@ -42,10 +42,18 @@
|
|||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"operate": "操作",
|
"operate": "操作",
|
||||||
"view": "查看",
|
"view": "查看",
|
||||||
"detailsTitle": "任务明细",
|
"viewDetails": "查看差异明细",
|
||||||
|
"detailsTitle": "差异明细",
|
||||||
"sideARef": "彩票侧引用",
|
"sideARef": "彩票侧引用",
|
||||||
"sideBRef": "主站侧引用",
|
"sideBRef": "主站侧引用",
|
||||||
"differenceAmount": "差额(分)",
|
"differenceAmount": "差额(分)",
|
||||||
|
"itemResult": "检查结果",
|
||||||
|
"diagnosis": "异常说明",
|
||||||
|
"suggestedAction": "建议处理方向",
|
||||||
|
"processingStatus": "处理状态",
|
||||||
|
"quickAccess": "快捷处理",
|
||||||
|
"openTransferOrder": "查看转账单",
|
||||||
|
"openWalletTxn": "查看钱包流水",
|
||||||
"detectedAt": "发现时间",
|
"detectedAt": "发现时间",
|
||||||
"noDetails": "无明细",
|
"noDetails": "无明细",
|
||||||
"playerSearch": "指定玩家(可选)",
|
"playerSearch": "指定玩家(可选)",
|
||||||
@@ -63,5 +71,33 @@
|
|||||||
"statusFailed": "失败",
|
"statusFailed": "失败",
|
||||||
"itemMismatch": "不一致",
|
"itemMismatch": "不一致",
|
||||||
"itemMatched": "一致",
|
"itemMatched": "一致",
|
||||||
"itemPendingCheck": "待核对"
|
"itemPendingCheck": "待核对",
|
||||||
|
"itemStaleProcessing": "长时间处理中",
|
||||||
|
"itemPendingReconcile": "待人工对账",
|
||||||
|
"itemMissingWalletTxn": "缺少钱包流水",
|
||||||
|
"itemUnexpectedWalletTxn": "出现多余钱包流水",
|
||||||
|
"itemMissingRefund": "缺少退款流水",
|
||||||
|
"itemMissingReversal": "缺少冲正流水",
|
||||||
|
"itemResolved": "已处理",
|
||||||
|
"itemUnresolved": "未处理",
|
||||||
|
"diagnosisStaleProcessing": "转账单长时间停留在处理中,系统未拿到明确成功或失败结果。",
|
||||||
|
"diagnosisPendingReconcile": "转账单已被标记为待人工对账,需要人工确认主站与彩票侧最终结果。",
|
||||||
|
"diagnosisMissingWalletTxn": "转账单状态已推进,但彩票侧缺少对应钱包流水。",
|
||||||
|
"diagnosisUnexpectedWalletTxn": "彩票侧出现了与当前转账状态不匹配的额外钱包流水。",
|
||||||
|
"diagnosisMissingRefund": "转出失败后,应有退款流水回补,但当前未找到。",
|
||||||
|
"diagnosisMissingReversal": "转账单已冲正,但彩票侧缺少冲正流水。",
|
||||||
|
"diagnosisMatched": "该记录已对平,无需进一步处理。",
|
||||||
|
"diagnosisPendingCheck": "该记录需要继续人工确认。",
|
||||||
|
"actionStaleProcessing": "先核对主站是否已成功扣款,再查看转账单和钱包流水是否需要冲正或补记。",
|
||||||
|
"actionPendingReconcile": "优先打开转账单核对主站回执,再决定是补记入账、冲正,还是结案。",
|
||||||
|
"actionMissingWalletTxn": "打开转账单与钱包流水交叉核对,确认是否需要补记一笔钱包流水。",
|
||||||
|
"actionUnexpectedWalletTxn": "检查是否发生重复记账或错误回补,必要时按实际情况冲正。",
|
||||||
|
"actionMissingRefund": "确认主站侧失败后是否已退款;若已退款,补记彩票侧退款流水或冲正。",
|
||||||
|
"actionMissingReversal": "确认冲正是否在外部成功,再补记彩票侧冲正流水。",
|
||||||
|
"actionMatched": "无需处理。",
|
||||||
|
"actionPendingCheck": "请结合转账单与钱包流水继续核对。",
|
||||||
|
"actionResolved": "该异常已处理,当前转账单状态为:{{status}}。如需复核,请打开转账单查看处理结果。",
|
||||||
|
"transferStatusSuccess": "已成功",
|
||||||
|
"transferStatusReversed": "已冲正",
|
||||||
|
"transferStatusManual": "已结案"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"loadFailed": "任务列表加载失败",
|
"loadFailed": "任务列表加载失败",
|
||||||
"downloadSuccess": "已下载 {{jobNo}}",
|
"downloadSuccess": "已下载 {{jobNo}}",
|
||||||
"downloadFailed": "下载失败",
|
"downloadFailed": "下载失败",
|
||||||
|
"currentReportHint": "这里只显示当前所选报表的导出任务,避免和其他报表任务混在一起。",
|
||||||
"columns": {
|
"columns": {
|
||||||
"jobNo": "任务编号",
|
"jobNo": "任务编号",
|
||||||
"report": "报表",
|
"report": "报表",
|
||||||
@@ -191,6 +192,8 @@
|
|||||||
"stats": {
|
"stats": {
|
||||||
"records": "记录数",
|
"records": "记录数",
|
||||||
"currentPage": "当前页",
|
"currentPage": "当前页",
|
||||||
|
"notQueried": "未查询",
|
||||||
|
"notSet": "未设置",
|
||||||
"drawNo": "期号",
|
"drawNo": "期号",
|
||||||
"currency": "币种",
|
"currency": "币种",
|
||||||
"exportRows": "导出行数",
|
"exportRows": "导出行数",
|
||||||
@@ -225,12 +228,10 @@
|
|||||||
"status": "状态",
|
"status": "状态",
|
||||||
"createdAt": "创建时间"
|
"createdAt": "创建时间"
|
||||||
},
|
},
|
||||||
"legacyTitle": "旧版钱包报表",
|
|
||||||
"categories": {
|
"categories": {
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
"profit": "盈亏",
|
"profit": "盈亏",
|
||||||
"wallet": "资金",
|
"wallet": "资金",
|
||||||
"legacy": "旧版口径",
|
|
||||||
"risk": "风控",
|
"risk": "风控",
|
||||||
"audit": "审计"
|
"audit": "审计"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"actualDeduct": "实扣",
|
"actualDeduct": "实扣",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
|
"viewTicketInList": "查看该注单",
|
||||||
"failReason": "失败原因",
|
"failReason": "失败原因",
|
||||||
"winAmount": "中奖",
|
"winAmount": "中奖",
|
||||||
"placedAt": "下单时间",
|
"placedAt": "下单时间",
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const PRD_WALLET_PLAYER_ACCESS_ANY = [
|
|||||||
...PRD_WALLET_TX_ACCESS_ANY,
|
...PRD_WALLET_TX_ACCESS_ANY,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 赔率与回水配置页 */
|
/** 赔率与基础回水配置页 */
|
||||||
export const PRD_RULES_ODDS_ACCESS_ANY = [
|
export const PRD_RULES_ODDS_ACCESS_ANY = [
|
||||||
PRD_ODDS_MANAGE,
|
PRD_ODDS_MANAGE,
|
||||||
PRD_ODDS_VIEW,
|
PRD_ODDS_VIEW,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export type AgentLineDetailPanelProps = {
|
|||||||
canViewPlayersTab: boolean;
|
canViewPlayersTab: boolean;
|
||||||
canManageNode: boolean;
|
canManageNode: boolean;
|
||||||
canCreateChild: boolean;
|
canCreateChild: boolean;
|
||||||
|
canCreateChildAgent: boolean;
|
||||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||||
onEditChild: (node: AgentNodeRow) => void;
|
onEditChild: (node: AgentNodeRow) => void;
|
||||||
onDeleteChild: (node: AgentNodeRow) => void;
|
onDeleteChild: (node: AgentNodeRow) => void;
|
||||||
@@ -88,6 +89,7 @@ export function AgentLineDetailPanel({
|
|||||||
canViewPlayersTab,
|
canViewPlayersTab,
|
||||||
canManageNode,
|
canManageNode,
|
||||||
canCreateChild,
|
canCreateChild,
|
||||||
|
canCreateChildAgent,
|
||||||
canDeleteChild,
|
canDeleteChild,
|
||||||
onEditChild,
|
onEditChild,
|
||||||
onDeleteChild,
|
onDeleteChild,
|
||||||
@@ -155,6 +157,17 @@ export function AgentLineDetailPanel({
|
|||||||
siteLabel && siteCode.trim() !== ""
|
siteLabel && siteCode.trim() !== ""
|
||||||
? `${siteLabel} (${siteCode})`
|
? `${siteLabel} (${siteCode})`
|
||||||
: siteLabel ?? siteCode;
|
: siteLabel ?? siteCode;
|
||||||
|
const codeText = typeof node.code === "string" ? node.code.trim() : "";
|
||||||
|
const usernameText = typeof node.username === "string" ? node.username.trim() : "";
|
||||||
|
const childActionHint = canCreateChild
|
||||||
|
? null
|
||||||
|
: canCreateChildAgent
|
||||||
|
? t("lineUi.addChildUnavailableHint", {
|
||||||
|
defaultValue: "当前代理未开启“允许创建下级代理”,如需新增请先调整该代理配置。",
|
||||||
|
})
|
||||||
|
: t("lineUi.addChildNoPermissionHint", {
|
||||||
|
defaultValue: "当前账号没有为该节点创建下级代理的权限。",
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background">
|
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background">
|
||||||
@@ -171,21 +184,25 @@ export function AgentLineDetailPanel({
|
|||||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
{(codeText !== "" || usernameText !== "" || parentName) ? (
|
||||||
<span className="font-mono text-xs text-foreground/80">{node.code}</span>
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
{node.username ? (
|
{codeText !== "" ? (
|
||||||
<>
|
<span className="rounded-md bg-muted/50 px-2 py-1 font-mono text-xs text-foreground/80">
|
||||||
<span className="mx-1.5 text-border">·</span>
|
{t("lineUi.agentCode", { defaultValue: "编码" })} {codeText}
|
||||||
{node.username}
|
</span>
|
||||||
</>
|
) : null}
|
||||||
) : null}
|
{usernameText !== "" ? (
|
||||||
{parentName ? (
|
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
|
||||||
<>
|
{t("lineUi.agentUsername", { defaultValue: "账号" })} {usernameText}
|
||||||
<span className="mx-1.5 text-border">·</span>
|
</span>
|
||||||
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
|
) : null}
|
||||||
</>
|
{parentName ? (
|
||||||
) : null}
|
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
|
||||||
</p>
|
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
|
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||||
@@ -202,16 +219,23 @@ export function AgentLineDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{canManageNode ? (
|
{canManageNode ? (
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex max-w-[28rem] flex-col items-end gap-2">
|
||||||
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Pencil className="mr-1.5 size-3.5" />
|
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
||||||
{t("lineUi.editAccount", { defaultValue: "账号与状态" })}
|
<Pencil className="mr-1.5 size-3.5" />
|
||||||
</Button>
|
{t("lineUi.editAgent", { defaultValue: "编辑代理" })}
|
||||||
{canCreateChild ? (
|
|
||||||
<Button type="button" size="sm" onClick={onAddChild}>
|
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
|
||||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{canCreateChild ? (
|
||||||
|
<Button type="button" size="sm" onClick={onAddChild}>
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
{t("createChild", { defaultValue: "添加下级代理" })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{childActionHint ? (
|
||||||
|
<p className="text-right text-xs leading-5 text-muted-foreground">
|
||||||
|
{childActionHint}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -267,7 +291,7 @@ export function AgentLineDetailPanel({
|
|||||||
})
|
})
|
||||||
: t("lineUi.profileTabHint", {
|
: t("lineUi.profileTabHint", {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
|
"占成、授信、回水与风控标签在此维护;登录名、密码与启停状态请用「编辑代理」。",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -426,7 +450,7 @@ function OverviewTab({
|
|||||||
defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。",
|
defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。",
|
||||||
count: childCount,
|
count: childCount,
|
||||||
})}
|
})}
|
||||||
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
|
actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
|
||||||
onAction={onGoToDownline}
|
onAction={onGoToDownline}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -437,7 +461,7 @@ function OverviewTab({
|
|||||||
description={t("lineUi.overviewPlayersHint", {
|
description={t("lineUi.overviewPlayersHint", {
|
||||||
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
|
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
|
||||||
})}
|
})}
|
||||||
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
|
actionLabel={t("lineUi.viewPlayers", { defaultValue: "查看直属玩家" })}
|
||||||
onAction={onGoToPlayers}
|
onAction={onGoToPlayers}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -509,6 +533,9 @@ function DownlineTable({
|
|||||||
onAddChild: () => void;
|
onAddChild: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
|
const createChildLabel = t("lineUi.createDownline", { defaultValue: "创建下级代理" });
|
||||||
|
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" });
|
||||||
|
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" });
|
||||||
|
|
||||||
if (childAgents.length === 0) {
|
if (childAgents.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -517,7 +544,7 @@ function DownlineTable({
|
|||||||
{canManageNode && canCreateChild ? (
|
{canManageNode && canCreateChild ? (
|
||||||
<Button type="button" className="mt-2" onClick={onAddChild}>
|
<Button type="button" className="mt-2" onClick={onAddChild}>
|
||||||
<Plus className="mr-1.5 size-4" />
|
<Plus className="mr-1.5 size-4" />
|
||||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
{createChildLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</AdminNoResourceState>
|
</AdminNoResourceState>
|
||||||
@@ -604,13 +631,13 @@ function DownlineTable({
|
|||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
key: "edit",
|
key: "edit",
|
||||||
label: t("editNode", { defaultValue: "编辑代理" }),
|
label: editChildLabel,
|
||||||
icon: Pencil,
|
icon: Pencil,
|
||||||
onClick: () => onEditChild(child),
|
onClick: () => onEditChild(child),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
label: t("deleteNode", { defaultValue: "删除代理" }),
|
label: deleteChildLabel,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
destructive: true,
|
destructive: true,
|
||||||
disabled: !canDeleteChild(child),
|
disabled: !canDeleteChild(child),
|
||||||
|
|||||||
@@ -295,24 +295,6 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
return counts;
|
return counts;
|
||||||
}, [flatNodes]);
|
}, [flatNodes]);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
|
||||||
const normalized = keyword.trim().toLowerCase();
|
|
||||||
|
|
||||||
return businessRows.filter((node) => {
|
|
||||||
if (normalized === "") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentName =
|
|
||||||
node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : "";
|
|
||||||
|
|
||||||
return [node.name, node.code, node.username ?? "", parentName]
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(normalized);
|
|
||||||
});
|
|
||||||
}, [businessRows, keyword, parentNameMap]);
|
|
||||||
|
|
||||||
const loadTree = useCallback(async (siteId?: number | null) => {
|
const loadTree = useCallback(async (siteId?: number | null) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
@@ -421,15 +403,15 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (filteredRows.length === 0) {
|
if (businessRows.length === 0) {
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedNodeId === null || !filteredRows.some((row) => row.id === selectedNodeId)) {
|
if (selectedNodeId === null || !businessRows.some((row) => row.id === selectedNodeId)) {
|
||||||
setSelectedNodeId(filteredRows[0]?.id ?? null);
|
setSelectedNodeId(businessRows[0]?.id ?? null);
|
||||||
}
|
}
|
||||||
}, [filteredRows, selectedNodeId]);
|
}, [businessRows, selectedNodeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetailTab("overview");
|
setDetailTab("overview");
|
||||||
@@ -798,10 +780,10 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<AgentLineDetailPanel
|
<AgentLineDetailPanel
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
profile={selectedProfile}
|
profile={selectedProfile}
|
||||||
profileLoading={selectedProfileLoading}
|
profileLoading={selectedProfileLoading}
|
||||||
childAgents={selectedChildAgents}
|
childAgents={selectedChildAgents}
|
||||||
childCountById={childCountById}
|
childCountById={childCountById}
|
||||||
siteCode={activeSiteCode}
|
siteCode={activeSiteCode}
|
||||||
@@ -820,6 +802,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
canViewPlayersTab={canShowPlayersTab}
|
canViewPlayersTab={canShowPlayersTab}
|
||||||
canManageNode={canManageNode}
|
canManageNode={canManageNode}
|
||||||
canCreateChild={canCreateChildOnSelected}
|
canCreateChild={canCreateChildOnSelected}
|
||||||
|
canCreateChildAgent={canCreateChildAgent}
|
||||||
canDeleteChild={canDeleteNode}
|
canDeleteChild={canDeleteNode}
|
||||||
onEditChild={(node) => openEditForNode(node)}
|
onEditChild={(node) => openEditForNode(node)}
|
||||||
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
||||||
@@ -843,7 +826,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{nodeDialogMode === "create"
|
{nodeDialogMode === "create"
|
||||||
? t("createChild", { defaultValue: "添加下级代理" })
|
? t("createChild", { defaultValue: "添加下级代理" })
|
||||||
: t("editNode", { defaultValue: "编辑代理" })}
|
: t("editNode", { defaultValue: "编辑代理账号与配置" })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye, Pencil, Plus, Trash2 } from "lucide-react";
|
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getAgentNodeProfile } from "@/api/admin-agents";
|
import { getAgentNodeProfile } from "@/api/admin-agents";
|
||||||
|
import {
|
||||||
|
getSettlementBills,
|
||||||
|
postSettlementBillBadDebtWriteOff,
|
||||||
|
postSettlementBillConfirm,
|
||||||
|
postSettlementBillPayment,
|
||||||
|
type SettlementBillRow,
|
||||||
|
} from "@/api/admin-agent-settlement";
|
||||||
import {
|
import {
|
||||||
deleteAdminPlayer,
|
deleteAdminPlayer,
|
||||||
getAdminPlayer,
|
getAdminPlayer,
|
||||||
@@ -15,7 +22,7 @@ import {
|
|||||||
} from "@/api/admin-player";
|
} from "@/api/admin-player";
|
||||||
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
@@ -48,7 +55,7 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
|
|||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
|
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||||
@@ -79,6 +86,15 @@ function playerStatusLabel(
|
|||||||
return String(status);
|
return String(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function creditAdjustModeLabel(
|
||||||
|
mode: "increase" | "decrease",
|
||||||
|
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||||
|
): string {
|
||||||
|
return mode === "increase"
|
||||||
|
? t("playersPanel.creditIncrease", { defaultValue: "增加授信" })
|
||||||
|
: t("playersPanel.creditDecrease", { defaultValue: "减少授信" });
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePlayerRebateRate(row: AdminPlayerRow): number | null {
|
function resolvePlayerRebateRate(row: AdminPlayerRow): number | null {
|
||||||
if (row.rebate_rate != null) {
|
if (row.rebate_rate != null) {
|
||||||
return row.rebate_rate;
|
return row.rebate_rate;
|
||||||
@@ -108,7 +124,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
|
|||||||
nickname: string;
|
nickname: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
status: number;
|
status: number;
|
||||||
creditLimit: string;
|
creditLimit: number;
|
||||||
rebateRate: string;
|
rebateRate: string;
|
||||||
riskTags: string;
|
riskTags: string;
|
||||||
} {
|
} {
|
||||||
@@ -119,7 +135,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
|
|||||||
nickname: row.nickname ?? "",
|
nickname: row.nickname ?? "",
|
||||||
currency: row.default_currency ?? "",
|
currency: row.default_currency ?? "",
|
||||||
status: row.status,
|
status: row.status,
|
||||||
creditLimit: row.credit_limit != null ? String(row.credit_limit) : "",
|
creditLimit: row.credit_limit ?? 0,
|
||||||
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
|
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
|
||||||
riskTags: (row.risk_tags ?? []).join(", "),
|
riskTags: (row.risk_tags ?? []).join(", "),
|
||||||
};
|
};
|
||||||
@@ -143,6 +159,15 @@ export function AgentsPlayersPanel({
|
|||||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "players", "common"]);
|
const { t } = useTranslation(["agents", "players", "common"]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
|
const createPlayerLabel = embedded
|
||||||
|
? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" })
|
||||||
|
: t("playersPanel.create", { defaultValue: "创建玩家" });
|
||||||
|
const viewPlayerLabel = t("players:viewDetail", { defaultValue: "查看玩家详情" });
|
||||||
|
const editPlayerLabel = t("players:editPlayer", { defaultValue: "编辑玩家" });
|
||||||
|
const deletePlayerLabel = t("players:deletePlayer", { defaultValue: "删除玩家" });
|
||||||
|
const settlementCenterLabel = t("playersPanel.gotoSettlementCenter", {
|
||||||
|
defaultValue: "去结算中心",
|
||||||
|
});
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const boundAgent = profile?.agent ?? null;
|
const boundAgent = profile?.agent ?? null;
|
||||||
const isSuperAdmin = profile?.is_super_admin === true;
|
const isSuperAdmin = profile?.is_super_admin === true;
|
||||||
@@ -175,7 +200,6 @@ export function AgentsPlayersPanel({
|
|||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [sitePlayerId, setSitePlayerId] = useState("");
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [nickname, setNickname] = useState("");
|
const [nickname, setNickname] = useState("");
|
||||||
@@ -189,10 +213,22 @@ export function AgentsPlayersPanel({
|
|||||||
const [editNickname, setEditNickname] = useState("");
|
const [editNickname, setEditNickname] = useState("");
|
||||||
const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
|
const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
|
||||||
const [editStatus, setEditStatus] = useState(0);
|
const [editStatus, setEditStatus] = useState(0);
|
||||||
const [editCreditLimit, setEditCreditLimit] = useState("");
|
const [editCreditBase, setEditCreditBase] = useState(0);
|
||||||
|
const [editCreditAdjustMode, setEditCreditAdjustMode] = useState<"increase" | "decrease">("increase");
|
||||||
|
const [editCreditDelta, setEditCreditDelta] = useState("");
|
||||||
const [editRebateRate, setEditRebateRate] = useState("");
|
const [editRebateRate, setEditRebateRate] = useState("");
|
||||||
const [editRiskTags, setEditRiskTags] = useState("");
|
const [editRiskTags, setEditRiskTags] = useState("");
|
||||||
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
||||||
|
const [billingDialogOpen, setBillingDialogOpen] = useState(false);
|
||||||
|
const [billingPlayer, setBillingPlayer] = useState<AdminPlayerRow | null>(null);
|
||||||
|
const [billingBills, setBillingBills] = useState<SettlementBillRow[]>([]);
|
||||||
|
const [billingLoading, setBillingLoading] = useState(false);
|
||||||
|
const [billingBusy, setBillingBusy] = useState(false);
|
||||||
|
const [selectedBillId, setSelectedBillId] = useState<number | null>(null);
|
||||||
|
const [payAmount, setPayAmount] = useState("");
|
||||||
|
const [payMethod, setPayMethod] = useState("");
|
||||||
|
const [payProof, setPayProof] = useState("");
|
||||||
|
const [badDebtReason, setBadDebtReason] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (siteCode.trim() === "") {
|
if (siteCode.trim() === "") {
|
||||||
@@ -241,7 +277,6 @@ export function AgentsPlayersPanel({
|
|||||||
try {
|
try {
|
||||||
await postAdminPlayer({
|
await postAdminPlayer({
|
||||||
site_code: siteCode.trim(),
|
site_code: siteCode.trim(),
|
||||||
...(sitePlayerId.trim() !== "" ? { site_player_id: sitePlayerId.trim() } : {}),
|
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password: password,
|
password: password,
|
||||||
nickname: nickname.trim() || null,
|
nickname: nickname.trim() || null,
|
||||||
@@ -259,7 +294,6 @@ export function AgentsPlayersPanel({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setSitePlayerId("");
|
|
||||||
setUsername("");
|
setUsername("");
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setNickname("");
|
setNickname("");
|
||||||
@@ -290,7 +324,9 @@ export function AgentsPlayersPanel({
|
|||||||
setEditNickname(form.nickname);
|
setEditNickname(form.nickname);
|
||||||
setEditDefaultCurrency(form.currency);
|
setEditDefaultCurrency(form.currency);
|
||||||
setEditStatus(form.status);
|
setEditStatus(form.status);
|
||||||
setEditCreditLimit(form.creditLimit);
|
setEditCreditBase(form.creditLimit);
|
||||||
|
setEditCreditAdjustMode("increase");
|
||||||
|
setEditCreditDelta("");
|
||||||
setEditRebateRate(form.rebateRate);
|
setEditRebateRate(form.rebateRate);
|
||||||
setEditRiskTags(form.riskTags);
|
setEditRiskTags(form.riskTags);
|
||||||
};
|
};
|
||||||
@@ -339,10 +375,13 @@ export function AgentsPlayersPanel({
|
|||||||
if (editStatus !== editingPlayer.status) {
|
if (editStatus !== editingPlayer.status) {
|
||||||
body.status = editStatus;
|
body.status = editStatus;
|
||||||
}
|
}
|
||||||
const nextCredit =
|
const creditDelta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
|
||||||
editCreditLimit.trim() === "" ? 0 : Number.parseInt(editCreditLimit, 10);
|
if (!Number.isNaN(creditDelta) && creditDelta > 0) {
|
||||||
if (!Number.isNaN(nextCredit) && nextCredit !== (editingPlayer.credit_limit ?? 0)) {
|
const signedDelta = editCreditAdjustMode === "increase" ? creditDelta : -creditDelta;
|
||||||
body.credit_limit = Math.max(0, nextCredit);
|
const nextCredit = Math.max(0, (editingPlayer.credit_limit ?? 0) + signedDelta);
|
||||||
|
if (nextCredit !== (editingPlayer.credit_limit ?? 0)) {
|
||||||
|
body.credit_limit = nextCredit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const prevRebate = resolvePlayerRebateRate(editingPlayer);
|
const prevRebate = resolvePlayerRebateRate(editingPlayer);
|
||||||
const nextPercent = parsePercentUi(editRebateRate);
|
const nextPercent = parsePercentUi(editRebateRate);
|
||||||
@@ -390,7 +429,141 @@ export function AgentsPlayersPanel({
|
|||||||
setTotal((current) => Math.max(0, current - 1));
|
setTotal((current) => Math.max(0, current - 1));
|
||||||
toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id }));
|
toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("deleteFailed"));
|
if (e instanceof LotteryApiBizError) {
|
||||||
|
const needsSettlement = e.message.includes("已占用信用额度");
|
||||||
|
toast.error(
|
||||||
|
needsSettlement
|
||||||
|
? t("playersPanel.deleteBlockedByCreditHint", {
|
||||||
|
defaultValue: "该玩家仍有已占用信用额度,请先到结算中心结清或核销相关账单后再删除。",
|
||||||
|
})
|
||||||
|
: e.message,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(t("deleteFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedBill = useMemo(
|
||||||
|
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null,
|
||||||
|
[billingBills, selectedBillId],
|
||||||
|
);
|
||||||
|
const projectedCreditLimit = useMemo(() => {
|
||||||
|
const delta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
|
||||||
|
if (Number.isNaN(delta) || delta <= 0) {
|
||||||
|
return editCreditBase;
|
||||||
|
}
|
||||||
|
return Math.max(0, editCreditBase + (editCreditAdjustMode === "increase" ? delta : -delta));
|
||||||
|
}, [editCreditAdjustMode, editCreditBase, editCreditDelta]);
|
||||||
|
|
||||||
|
function resetBillingForm(): void {
|
||||||
|
setPayAmount("");
|
||||||
|
setPayMethod("");
|
||||||
|
setPayProof("");
|
||||||
|
setBadDebtReason("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBillingDialog(row: AdminPlayerRow): Promise<void> {
|
||||||
|
setBillingDialogOpen(true);
|
||||||
|
setBillingPlayer(row);
|
||||||
|
setBillingBills([]);
|
||||||
|
setSelectedBillId(null);
|
||||||
|
resetBillingForm();
|
||||||
|
setBillingLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getSettlementBills({
|
||||||
|
bill_type: "player",
|
||||||
|
keyword: row.site_player_id,
|
||||||
|
per_page: 20,
|
||||||
|
});
|
||||||
|
const items = (data.items ?? []).filter(
|
||||||
|
(bill) =>
|
||||||
|
bill.bill_type === "player" &&
|
||||||
|
bill.owner_id === row.id &&
|
||||||
|
(bill.status === "pending_confirm" || Number(bill.unpaid_amount ?? 0) > 0),
|
||||||
|
);
|
||||||
|
setBillingBills(items);
|
||||||
|
const first = items[0] ?? null;
|
||||||
|
setSelectedBillId(first?.id ?? null);
|
||||||
|
setPayAmount(first ? String(first.unpaid_amount ?? 0) : "");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError
|
||||||
|
? e.message
|
||||||
|
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBillingLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmBill(): Promise<void> {
|
||||||
|
if (selectedBill === null) return;
|
||||||
|
setBillingBusy(true);
|
||||||
|
try {
|
||||||
|
await postSettlementBillConfirm(selectedBill.id);
|
||||||
|
toast.success(
|
||||||
|
t("playersPanel.billConfirmed", { defaultValue: "账单已确认,请继续登记收付或核销" }),
|
||||||
|
);
|
||||||
|
if (billingPlayer) {
|
||||||
|
await openBillingDialog(billingPlayer);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError
|
||||||
|
? e.message
|
||||||
|
: t("playersPanel.billConfirmFailed", { defaultValue: "确认账单失败" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBillingBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePayBill(): Promise<void> {
|
||||||
|
if (selectedBill === null) return;
|
||||||
|
setBillingBusy(true);
|
||||||
|
try {
|
||||||
|
await postSettlementBillPayment(selectedBill.id, {
|
||||||
|
amount: Number(payAmount || selectedBill.unpaid_amount || 0),
|
||||||
|
method: payMethod.trim() || undefined,
|
||||||
|
proof: payProof.trim() || undefined,
|
||||||
|
});
|
||||||
|
toast.success(t("playersPanel.billPaid", { defaultValue: "已登记收付" }));
|
||||||
|
await load();
|
||||||
|
if (billingPlayer) {
|
||||||
|
await openBillingDialog(billingPlayer);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError
|
||||||
|
? e.message
|
||||||
|
: t("playersPanel.billPayFailed", { defaultValue: "登记收付失败" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBillingBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWriteOffBill(): Promise<void> {
|
||||||
|
if (selectedBill === null) return;
|
||||||
|
setBillingBusy(true);
|
||||||
|
try {
|
||||||
|
await postSettlementBillBadDebtWriteOff(selectedBill.id, {
|
||||||
|
reason: badDebtReason.trim() || undefined,
|
||||||
|
});
|
||||||
|
toast.success(t("playersPanel.billWrittenOff", { defaultValue: "已核销坏账" }));
|
||||||
|
await load();
|
||||||
|
if (billingPlayer) {
|
||||||
|
await openBillingDialog(billingPlayer);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError
|
||||||
|
? e.message
|
||||||
|
: t("playersPanel.billWriteOffFailed", { defaultValue: "核销坏账失败" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBillingBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +588,7 @@ export function AgentsPlayersPanel({
|
|||||||
{canCreatePlayer ? (
|
{canCreatePlayer ? (
|
||||||
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
{t("playersPanel.create", { defaultValue: "创建玩家" })}
|
{createPlayerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -434,6 +607,9 @@ export function AgentsPlayersPanel({
|
|||||||
<TableHead className="whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
|
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">
|
||||||
|
{t("players:riskTags", { defaultValue: "风控标签" })}
|
||||||
|
</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("players:fundingMode", { defaultValue: "资金模式" })}
|
{t("players:fundingMode", { defaultValue: "资金模式" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -455,11 +631,12 @@ export function AgentsPlayersPanel({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<AdminTableNoResourceRow colSpan={embedded ? 8 : 9} cellClassName="py-12 text-center" />
|
<AdminTableNoResourceRow colSpan={embedded ? 9 : 10} cellClassName="py-12 text-center" />
|
||||||
) : (
|
) : (
|
||||||
items.map((row) => {
|
items.map((row) => {
|
||||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||||
const rebate = resolvePlayerRebateRate(row);
|
const rebate = resolvePlayerRebateRate(row);
|
||||||
|
const riskTags = row.risk_tags ?? [];
|
||||||
return (
|
return (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell className="tabular-nums text-xs font-medium">#{row.id}</TableCell>
|
<TableCell className="tabular-nums text-xs font-medium">#{row.id}</TableCell>
|
||||||
@@ -471,6 +648,22 @@ export function AgentsPlayersPanel({
|
|||||||
<span className="text-muted-foreground"> / </span>
|
<span className="text-muted-foreground"> / </span>
|
||||||
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
|
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[14rem]">
|
||||||
|
{riskTags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
|
||||||
|
{riskTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={`${row.id}-${tag}`}
|
||||||
|
className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium leading-4 text-amber-900"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<PlayerFundingModeBadge row={row} />
|
<PlayerFundingModeBadge row={row} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -509,21 +702,33 @@ export function AgentsPlayersPanel({
|
|||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
key: "detail",
|
key: "detail",
|
||||||
label: t("players:viewDetail", { defaultValue: "查看详情" }),
|
label: viewPlayerLabel,
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
href: adminPlayerDetailPath(row.id),
|
href: adminPlayerDetailPath(row.id),
|
||||||
},
|
},
|
||||||
|
...(row.funding_mode === "credit" || row.uses_credit === true
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "settlement",
|
||||||
|
label: t("playersPanel.manageSettlement", {
|
||||||
|
defaultValue: "处理账单",
|
||||||
|
}),
|
||||||
|
icon: ReceiptText,
|
||||||
|
onClick: () => void openBillingDialog(row),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(canManagePlayerRows
|
...(canManagePlayerRows
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: "edit",
|
key: "edit",
|
||||||
label: t("players:edit", { defaultValue: "编辑" }),
|
label: editPlayerLabel,
|
||||||
icon: Pencil,
|
icon: Pencil,
|
||||||
onClick: () => openEditPlayer(row),
|
onClick: () => openEditPlayer(row),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
label: t("players:delete", { defaultValue: "删除" }),
|
label: deletePlayerLabel,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
destructive: true,
|
destructive: true,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
@@ -572,24 +777,12 @@ export function AgentsPlayersPanel({
|
|||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle>
|
<DialogTitle>{createPlayerLabel}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
|
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
|
||||||
<Input value={siteCode} readOnly disabled />
|
<Input value={siteCode} readOnly disabled />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="agent-player-site-id">
|
|
||||||
{t("playersPanel.externalIdOptional", { defaultValue: "外部 ID(可选)" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="agent-player-site-id"
|
|
||||||
value={sitePlayerId}
|
|
||||||
onChange={(e) => setSitePlayerId(e.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder={t("playersPanel.externalIdHint", { defaultValue: "留空则系统自动生成" })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-username">
|
<Label htmlFor="agent-player-username">
|
||||||
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
|
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
|
||||||
@@ -669,6 +862,138 @@ export function AgentsPlayersPanel({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={billingDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setBillingDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setBillingPlayer(null);
|
||||||
|
setBillingBills([]);
|
||||||
|
setSelectedBillId(null);
|
||||||
|
resetBillingForm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("playersPanel.manageSettlement", { defaultValue: "处理账单" })}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{billingLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("playersPanel.billingLoading", { defaultValue: "正在加载账单…" })}
|
||||||
|
</p>
|
||||||
|
) : billingBills.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("playersPanel.noPendingBills", { defaultValue: "当前没有可处理的未结账单。" })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("playersPanel.selectBill", { defaultValue: "选择账单" })}</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedBillId ? String(selectedBillId) : ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const next = billingBills.find((bill) => bill.id === Number(value)) ?? null;
|
||||||
|
setSelectedBillId(next?.id ?? null);
|
||||||
|
setPayAmount(next ? String(next.unpaid_amount ?? 0) : "");
|
||||||
|
setPayMethod("");
|
||||||
|
setPayProof("");
|
||||||
|
setBadDebtReason("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("playersPanel.selectBill", { defaultValue: "选择账单" })} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{billingBills.map((bill) => (
|
||||||
|
<SelectItem key={bill.id} value={String(bill.id)}>
|
||||||
|
{`#${bill.id} · ${bill.status} · ${bill.player_site_player_id ?? bill.owner_id} · ${bill.unpaid_amount ?? 0}`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBill ? (
|
||||||
|
<div className="space-y-4 rounded-xl border border-border/70 p-4">
|
||||||
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("playersPanel.billStatus", { defaultValue: "状态" })}:
|
||||||
|
</span>{" "}
|
||||||
|
{selectedBill.status}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("playersPanel.billUnpaid", { defaultValue: "未结" })}:
|
||||||
|
</span>{" "}
|
||||||
|
{selectedBill.unpaid_amount ?? 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBill.status === "pending_confirm" ? (
|
||||||
|
<Button type="button" className="w-full" disabled={billingBusy} onClick={() => void handleConfirmBill()}>
|
||||||
|
{t("agents:settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedBill.status !== "pending_confirm" && Number(selectedBill.unpaid_amount ?? 0) > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||||
|
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
||||||
|
<Input
|
||||||
|
value={payMethod}
|
||||||
|
onChange={(e) => setPayMethod(e.target.value)}
|
||||||
|
placeholder={t("agents:settlementBills.paymentMethodPlaceholder", {
|
||||||
|
defaultValue: "例如:现金 / 银行转账",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t("agents:settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||||
|
<Input
|
||||||
|
value={payProof}
|
||||||
|
onChange={(e) => setPayProof(e.target.value)}
|
||||||
|
placeholder={t("agents:settlementBills.paymentProofPlaceholder", {
|
||||||
|
defaultValue: "可填写流水号、截图说明或备注",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" className="w-full" disabled={billingBusy} onClick={() => void handlePayBill()}>
|
||||||
|
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||||
|
<Input
|
||||||
|
value={badDebtReason}
|
||||||
|
onChange={(e) => setBadDebtReason(e.target.value)}
|
||||||
|
placeholder={t("agents:settlementBills.badDebtReasonPlaceholder", {
|
||||||
|
defaultValue: "例如:客户失联、确认坏账",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy} onClick={() => void handleWriteOffBill()}>
|
||||||
|
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -713,13 +1038,60 @@ export function AgentsPlayersPanel({
|
|||||||
<Label htmlFor="agent-player-edit-credit">
|
<Label htmlFor="agent-player-edit-credit">
|
||||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="rounded-xl border border-border/70 bg-muted/20 p-3">
|
||||||
id="agent-player-edit-credit"
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
type="number"
|
<span className="text-muted-foreground">
|
||||||
min={0}
|
{t("playersPanel.currentCredit", { defaultValue: "当前授信" })}
|
||||||
value={editCreditLimit}
|
</span>
|
||||||
onChange={(e) => setEditCreditLimit(e.target.value)}
|
<span className="font-semibold">
|
||||||
/>
|
{formatPlayerCreditAmount(editCreditBase, editDefaultCurrency || "NPR")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="agent-player-edit-credit-mode">
|
||||||
|
{t("playersPanel.creditAdjustType", { defaultValue: "调整方式" })}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={editCreditAdjustMode}
|
||||||
|
onValueChange={(value) => setEditCreditAdjustMode(value as "increase" | "decrease")}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="agent-player-edit-credit-mode">
|
||||||
|
<SelectValue>
|
||||||
|
{creditAdjustModeLabel(editCreditAdjustMode, t)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="increase">
|
||||||
|
{creditAdjustModeLabel("increase", t)}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="decrease">
|
||||||
|
{creditAdjustModeLabel("decrease", t)}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="agent-player-edit-credit-delta">
|
||||||
|
{t("playersPanel.creditAdjustAmount", { defaultValue: "调整额度" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-player-edit-credit-delta"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={editCreditDelta}
|
||||||
|
onChange={(e) => setEditCreditDelta(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
|
{t("playersPanel.creditProjected", {
|
||||||
|
defaultValue: "调整后授信:{{amount}}",
|
||||||
|
amount: formatPlayerCreditAmount(projectedCreditLimit, editDefaultCurrency || "NPR"),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-edit-rebate">
|
<Label htmlFor="agent-player-edit-rebate">
|
||||||
|
|||||||
@@ -227,11 +227,10 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell>{row.id}</TableCell>
|
<TableCell>{row.id}</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
{t(`operatorTypes.${row.operator_type}`, {
|
<div className="font-medium text-foreground">{row.operator_label}</div>
|
||||||
ns: "audit",
|
{row.operator_subtitle ? (
|
||||||
defaultValue: row.operator_type,
|
<div className="text-muted-foreground">{row.operator_subtitle}</div>
|
||||||
})}
|
) : null}
|
||||||
:{row.operator_id}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{row.module_label}</TableCell>
|
<TableCell className="text-sm">{row.module_label}</TableCell>
|
||||||
<TableCell className="text-sm">{row.action_label}</TableCell>
|
<TableCell className="text-sm">{row.action_label}</TableCell>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -32,6 +32,13 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -157,6 +164,9 @@ export function PlayConfigDocScreen() {
|
|||||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||||
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||||
const detailRequestSeq = useRef(0);
|
const detailRequestSeq = useRef(0);
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
const refreshList = useCallback(async () => {
|
||||||
@@ -268,6 +278,61 @@ export function PlayConfigDocScreen() {
|
|||||||
[draftRows],
|
[draftRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return orderedRows
|
||||||
|
.map((row) => row.category?.trim() || "")
|
||||||
|
.filter((value) => {
|
||||||
|
if (!value || seen.has(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [orderedRows]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||||
|
|
||||||
|
return orderedRows.filter((row) => {
|
||||||
|
const normalizedCategory = row.category?.trim() || "uncategorized";
|
||||||
|
const matchesKeyword =
|
||||||
|
normalizedKeyword === "" ||
|
||||||
|
row.play_code.toLowerCase().includes(normalizedKeyword) ||
|
||||||
|
(row.display_name ?? "").toLowerCase().includes(normalizedKeyword) ||
|
||||||
|
(row.category ?? "").toLowerCase().includes(normalizedKeyword);
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === "all" ||
|
||||||
|
(statusFilter === "enabled" && row.is_enabled) ||
|
||||||
|
(statusFilter === "disabled" && !row.is_enabled);
|
||||||
|
const matchesCategory = categoryFilter === "all" || normalizedCategory === categoryFilter;
|
||||||
|
|
||||||
|
return matchesKeyword && matchesStatus && matchesCategory;
|
||||||
|
});
|
||||||
|
}, [categoryFilter, keyword, orderedRows, statusFilter]);
|
||||||
|
|
||||||
|
const groupedRows = useMemo(() => {
|
||||||
|
const groups = new Map<string, PlayConfigItemRow[]>();
|
||||||
|
for (const row of filteredRows) {
|
||||||
|
const groupKey = row.category?.trim() || "uncategorized";
|
||||||
|
const current = groups.get(groupKey);
|
||||||
|
if (current) {
|
||||||
|
current.push(row);
|
||||||
|
} else {
|
||||||
|
groups.set(groupKey, [row]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}, [filteredRows]);
|
||||||
|
|
||||||
|
function categoryLabel(categoryKey: string): string {
|
||||||
|
if (categoryKey === "uncategorized") {
|
||||||
|
return t("play.filters.uncategorized", { ns: "config" });
|
||||||
|
}
|
||||||
|
const mapped = t(`play.categories.${categoryKey}`, { ns: "config" });
|
||||||
|
return mapped === `play.categories.${categoryKey}` ? categoryKey : mapped;
|
||||||
|
}
|
||||||
|
|
||||||
function updateConfigRow(playCode: string, patch: Partial<PlayConfigItemRow>) {
|
function updateConfigRow(playCode: string, patch: Partial<PlayConfigItemRow>) {
|
||||||
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
||||||
}
|
}
|
||||||
@@ -474,69 +539,153 @@ export function PlayConfigDocScreen() {
|
|||||||
>
|
>
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<ConfigSection
|
<ConfigSection
|
||||||
title={t("play.batchSwitchesTitle", { ns: "config" })}
|
title={t("play.filters.sectionTitle", { ns: "config" })}
|
||||||
description={!isDraft ? t("play.readOnlyDraftHint", { ns: "config" }) : undefined}
|
description={isDraft ? t("play.filters.sectionDescription", { ns: "config" }) : undefined}
|
||||||
>
|
>
|
||||||
<ConfigChipGroup>
|
{!isDraft ? (
|
||||||
{batchSwitchStates.map((group) => {
|
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
|
||||||
const groupOn = group.allEnabled;
|
{t("play.readOnlyDraftHint", { ns: "config" })}
|
||||||
const isPartial =
|
</div>
|
||||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
) : null}
|
||||||
return (
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||||
<div
|
<div className="flex flex-1 flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
|
||||||
key={group.key}
|
<div className="flex min-w-0 flex-col gap-1.5 md:w-[320px]">
|
||||||
className="flex items-center justify-between gap-4 rounded-xl border border-border/60 bg-card px-4 py-3"
|
<span className="text-sm font-medium">{t("play.filters.keyword", { ns: "config" })}</span>
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 md:w-[140px]">
|
||||||
|
<span className="text-sm font-medium">{t("play.filters.category", { ns: "config" })}</span>
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue>
|
||||||
|
{categoryFilter === "all"
|
||||||
|
? t("play.filters.allCategories", { ns: "config" })
|
||||||
|
: categoryLabel(categoryFilter)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
|
||||||
|
{categoryOptions.map((category) => (
|
||||||
|
<SelectItem key={category} value={category}>
|
||||||
|
{categoryLabel(category)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="uncategorized">
|
||||||
|
{t("play.filters.uncategorized", { ns: "config" })}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 md:w-[140px]">
|
||||||
|
<span className="text-sm font-medium">{t("play.filters.status", { ns: "config" })}</span>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<SelectTrigger className="h-8">
|
||||||
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
<SelectValue>
|
||||||
<p className="text-sm text-muted-foreground">
|
{statusFilter === "all"
|
||||||
{group.total > 0
|
? t("play.filters.allStatuses", { ns: "config" })
|
||||||
? isPartial
|
: statusFilter === "enabled"
|
||||||
? t("play.batchPartialEnabled", {
|
? t("play.states.enabled", { ns: "config" })
|
||||||
ns: "config",
|
: t("play.states.disabled", { ns: "config" })}
|
||||||
enabledCount: group.enabledCount,
|
</SelectValue>
|
||||||
total: group.total,
|
</SelectTrigger>
|
||||||
})
|
<SelectContent>
|
||||||
: t("play.batchEnabledCount", {
|
<SelectItem value="all">{t("play.filters.allStatuses", { ns: "config" })}</SelectItem>
|
||||||
ns: "config",
|
<SelectItem value="enabled">{t("play.states.enabled", { ns: "config" })}</SelectItem>
|
||||||
enabledCount: group.enabledCount,
|
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
|
||||||
total: group.total,
|
</SelectContent>
|
||||||
})
|
</Select>
|
||||||
: t("play.noPlayTypes", { ns: "config" })}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<div className="flex items-end justify-start lg:flex-none">
|
||||||
<div className="flex shrink-0 items-center justify-center">
|
<Button
|
||||||
<Checkbox
|
type="button"
|
||||||
checked={groupOn}
|
size="sm"
|
||||||
indeterminate={isPartial}
|
variant="outline"
|
||||||
disabled={!isDraft || saving || group.total === 0 || confirmBusy}
|
onClick={() => {
|
||||||
aria-label={t("play.aria.batchGroupSwitch", {
|
setKeyword("");
|
||||||
ns: "config",
|
setCategoryFilter("all");
|
||||||
group: group.label,
|
setStatusFilter("all");
|
||||||
})}
|
}}
|
||||||
onCheckedChange={(checked) => {
|
>
|
||||||
const enable = checked === true;
|
{t("play.filters.reset", { ns: "config" })}
|
||||||
const action = enable
|
</Button>
|
||||||
? t("play.batchSwitchEnable", { ns: "config" })
|
</div>
|
||||||
: t("play.batchSwitchDisable", { ns: "config" });
|
</div>
|
||||||
requestConfirm({
|
{isDraft ? (
|
||||||
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||||
description: t("play.batchSwitchConfirmDescription", {
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("play.batchSwitchesTitle", { ns: "config" })}
|
||||||
|
</div>
|
||||||
|
<ConfigChipGroup>
|
||||||
|
{batchSwitchStates.map((group) => {
|
||||||
|
const groupOn = group.allEnabled;
|
||||||
|
const isPartial =
|
||||||
|
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.key}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-card px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{group.total > 0
|
||||||
|
? isPartial
|
||||||
|
? t("play.batchPartialEnabled", {
|
||||||
|
ns: "config",
|
||||||
|
enabledCount: group.enabledCount,
|
||||||
|
total: group.total,
|
||||||
|
})
|
||||||
|
: t("play.batchEnabledCount", {
|
||||||
|
ns: "config",
|
||||||
|
enabledCount: group.enabledCount,
|
||||||
|
total: group.total,
|
||||||
|
})
|
||||||
|
: t("play.noPlayTypes", { ns: "config" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={groupOn}
|
||||||
|
indeterminate={isPartial}
|
||||||
|
disabled={saving || group.total === 0 || confirmBusy}
|
||||||
|
aria-label={t("play.aria.batchGroupSwitch", {
|
||||||
ns: "config",
|
ns: "config",
|
||||||
action,
|
|
||||||
group: group.label,
|
group: group.label,
|
||||||
count: group.total,
|
})}
|
||||||
}),
|
onCheckedChange={(checked) => {
|
||||||
confirmVariant: enable ? "default" : "destructive",
|
const enable = checked === true;
|
||||||
onConfirm: () => applyBatchSwitch(group, enable),
|
const action = enable
|
||||||
});
|
? t("play.batchSwitchEnable", { ns: "config" })
|
||||||
}}
|
: t("play.batchSwitchDisable", { ns: "config" });
|
||||||
/>
|
requestConfirm({
|
||||||
</div>
|
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||||
</div>
|
description: t("play.batchSwitchConfirmDescription", {
|
||||||
);
|
ns: "config",
|
||||||
})}
|
action,
|
||||||
</ConfigChipGroup>
|
group: group.label,
|
||||||
|
count: group.total,
|
||||||
|
}),
|
||||||
|
confirmVariant: enable ? "default" : "destructive",
|
||||||
|
onConfirm: () => applyBatchSwitch(group, enable),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ConfigChipGroup>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -546,22 +695,41 @@ export function PlayConfigDocScreen() {
|
|||||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupedRows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t("play.filters.empty", { ns: "config" })}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{groupedRows.map(([groupKey, rows]) => (
|
||||||
|
<Fragment key={groupKey}>
|
||||||
|
<TableRow className="bg-muted/30">
|
||||||
|
<TableCell colSpan={7} className="py-2 text-sm font-medium text-foreground">
|
||||||
|
{categoryLabel(groupKey)}
|
||||||
|
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
|
{t("play.filters.groupCount", { ns: "config", count: rows.length })}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
{rows.map((row) => (
|
||||||
<TableBody>
|
|
||||||
{orderedRows.map((row) => (
|
|
||||||
<TableRow key={row.play_code}>
|
<TableRow key={row.play_code}>
|
||||||
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
|
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
|
||||||
<TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
|
<TableCell className="text-center text-muted-foreground text-sm">
|
||||||
|
{row.category ? categoryLabel(row.category) : "—"}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@@ -691,7 +859,9 @@ export function PlayConfigDocScreen() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
publishRiskCapVersion,
|
publishRiskCapVersion,
|
||||||
putRiskCapItems,
|
putRiskCapItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
|
import { getAdminDraws } from "@/api/admin-draws";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
@@ -32,7 +33,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,13 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
@@ -51,10 +59,13 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||||
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
|
import { PRD_RISK_CAP_MANAGE } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
||||||
|
import type {
|
||||||
|
AdminDrawListItem,
|
||||||
|
} from "@/types/api/admin-draws";
|
||||||
import type {
|
import type {
|
||||||
ConfigVersionSummary,
|
ConfigVersionSummary,
|
||||||
RiskCapItemRow,
|
RiskCapItemRow,
|
||||||
@@ -102,6 +113,7 @@ export function RiskCapDocScreen() {
|
|||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [drawOptions, setDrawOptions] = useState<AdminDrawListItem[]>([]);
|
||||||
|
|
||||||
const [defaultCapStr, setDefaultCapStr] = useState("");
|
const [defaultCapStr, setDefaultCapStr] = useState("");
|
||||||
const [syncOpen, setSyncOpen] = useState(false);
|
const [syncOpen, setSyncOpen] = useState(false);
|
||||||
@@ -124,10 +136,23 @@ export function RiskCapDocScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingList(false);
|
setLoadingList(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [tRef]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void refreshList();
|
void refreshList();
|
||||||
|
}, [tRef]);
|
||||||
|
|
||||||
|
const loadDrawOptions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getAdminDraws({ page: 1, per_page: 100 });
|
||||||
|
setDrawOptions(data.items);
|
||||||
|
} catch {
|
||||||
|
setDrawOptions([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useAsyncEffect(() => {
|
||||||
|
void loadDrawOptions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
||||||
@@ -232,12 +257,20 @@ export function RiskCapDocScreen() {
|
|||||||
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
|
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (r.draw_id !== null) {
|
||||||
|
toast.error(t("riskCap.validation.defaultCannotBindDraw", { ns: "config" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
|
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
|
||||||
toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number }));
|
toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (r.cap_amount <= 0) {
|
||||||
|
toast.error(t("riskCap.validation.specialGreaterThanZero", { ns: "config", number: r.normalized_number }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -340,6 +373,15 @@ export function RiskCapDocScreen() {
|
|||||||
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
|
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
|
||||||
[draftRows],
|
[draftRows],
|
||||||
);
|
);
|
||||||
|
const globalRows = useMemo(
|
||||||
|
() => specialRows.filter(({ row }) => row.draw_id == null),
|
||||||
|
[specialRows],
|
||||||
|
);
|
||||||
|
const drawRows = useMemo(
|
||||||
|
() => specialRows.filter(({ row }) => row.draw_id != null),
|
||||||
|
[specialRows],
|
||||||
|
);
|
||||||
|
const defaultCapDisplay = defaultCapStr || formatAdminMinorDecimal(0, amountCurrencyCode);
|
||||||
|
|
||||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||||
try {
|
try {
|
||||||
@@ -459,6 +501,35 @@ export function RiskCapDocScreen() {
|
|||||||
>
|
>
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
key: "default",
|
||||||
|
label: t("riskCap.summary.defaultCap", { ns: "config" }),
|
||||||
|
value: defaultCapDisplay,
|
||||||
|
hint: t("riskCap.summary.defaultHint", { ns: "config" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "global",
|
||||||
|
label: t("riskCap.summary.globalCaps", { ns: "config" }),
|
||||||
|
value: t("riskCap.groups.count", { ns: "config", count: globalRows.length }),
|
||||||
|
hint: t("riskCap.summary.globalHint", { ns: "config" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "draw",
|
||||||
|
label: t("riskCap.summary.drawCaps", { ns: "config" }),
|
||||||
|
value: t("riskCap.groups.count", { ns: "config", count: drawRows.length }),
|
||||||
|
hint: t("riskCap.summary.drawHint", { ns: "config" }),
|
||||||
|
},
|
||||||
|
].map((card) => (
|
||||||
|
<div key={card.key} className="rounded-xl border border-border/60 bg-background p-4 shadow-sm">
|
||||||
|
<p className="text-xs text-muted-foreground">{card.label}</p>
|
||||||
|
<p className="mt-1 font-mono text-lg font-semibold tabular-nums">{card.value}</p>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-muted-foreground">{card.hint}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfigSection title={t("riskCap.defaultCap.title", { ns: "config" })}>
|
<ConfigSection title={t("riskCap.defaultCap.title", { ns: "config" })}>
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
@@ -466,8 +537,8 @@ export function RiskCapDocScreen() {
|
|||||||
{canEditDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
id="default-cap"
|
id="default-cap"
|
||||||
type="number"
|
type="text"
|
||||||
min={0}
|
inputMode="decimal"
|
||||||
className="w-[220px] font-mono tabular-nums"
|
className="w-[220px] font-mono tabular-nums"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={defaultCapStr}
|
value={defaultCapStr}
|
||||||
@@ -490,6 +561,7 @@ export function RiskCapDocScreen() {
|
|||||||
|
|
||||||
<ConfigSection
|
<ConfigSection
|
||||||
title={t("riskCap.specialCaps.title", { ns: "config" })}
|
title={t("riskCap.specialCaps.title", { ns: "config" })}
|
||||||
|
description={t("riskCap.specialCaps.description", { ns: "config" })}
|
||||||
actions={
|
actions={
|
||||||
canEditDraft ? (
|
canEditDraft ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -508,79 +580,150 @@ export function RiskCapDocScreen() {
|
|||||||
) : specialRows.length === 0 ? (
|
) : specialRows.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<div className="space-y-4">
|
||||||
<TableHeader>
|
{[
|
||||||
<TableRow>
|
{
|
||||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
key: "global",
|
||||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
title: t("riskCap.groups.globalTitle", { ns: "config" }),
|
||||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
description: t("riskCap.groups.globalDescription", { ns: "config" }),
|
||||||
</TableRow>
|
rows: globalRows,
|
||||||
</TableHeader>
|
emptyText: t("riskCap.groups.globalEmpty", { ns: "config" }),
|
||||||
<TableBody>
|
},
|
||||||
{specialRows.map(({ row: r, index: idx }) => (
|
{
|
||||||
<TableRow key={r.clientKey}>
|
key: "draw",
|
||||||
<TableCell>
|
title: t("riskCap.groups.drawTitle", { ns: "config" }),
|
||||||
{canEditDraft ? (
|
description: t("riskCap.groups.drawDescription", { ns: "config" }),
|
||||||
<Input
|
rows: drawRows,
|
||||||
className="h-8 font-mono tabular-nums"
|
emptyText: t("riskCap.groups.drawEmpty", { ns: "config" }),
|
||||||
maxLength={4}
|
},
|
||||||
disabled={saving}
|
].map((group) => (
|
||||||
value={r.normalized_number}
|
<div key={group.key} className="rounded-xl border border-border/60 bg-muted/10 p-3">
|
||||||
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||||
onChange={(e) =>
|
<div className="space-y-1">
|
||||||
updateRow(idx, {
|
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
|
||||||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
<p className="text-xs leading-5 text-muted-foreground">{group.description}</p>
|
||||||
})
|
</div>
|
||||||
}
|
<span className="rounded-full bg-background px-2 py-1 text-xs text-muted-foreground">
|
||||||
/>
|
{t("riskCap.groups.count", { ns: "config", count: group.rows.length })}
|
||||||
) : (
|
</span>
|
||||||
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
|
</div>
|
||||||
)}
|
|
||||||
</TableCell>
|
{group.rows.length === 0 ? (
|
||||||
<TableCell>
|
<p className="text-sm text-muted-foreground">{group.emptyText}</p>
|
||||||
{canEditDraft ? (
|
) : (
|
||||||
<Input
|
<Table>
|
||||||
type="text"
|
<TableHeader>
|
||||||
inputMode="decimal"
|
<TableRow>
|
||||||
className="h-8 font-mono tabular-nums"
|
<TableHead className="w-[180px]">{t("riskCap.table.scope", { ns: "config" })}</TableHead>
|
||||||
disabled={saving}
|
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||||
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||||
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
onChange={(e) =>
|
{t("riskCap.table.actions", { ns: "config" })}
|
||||||
updateRow(idx, {
|
</TableHead>
|
||||||
cap_amount:
|
</TableRow>
|
||||||
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
|
</TableHeader>
|
||||||
})
|
<TableBody>
|
||||||
}
|
{group.rows.map(({ row: r, index: idx }) => (
|
||||||
/>
|
<TableRow key={r.clientKey}>
|
||||||
) : (
|
<TableCell>
|
||||||
<ConfigReadonlyValue mono>
|
{canEditDraft ? (
|
||||||
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
<Select
|
||||||
</ConfigReadonlyValue>
|
value={r.draw_id == null ? "__global__" : String(r.draw_id)}
|
||||||
)}
|
onValueChange={(value) =>
|
||||||
</TableCell>
|
updateRow(idx, { draw_id: value === "__global__" ? null : Number(value) })
|
||||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
}
|
||||||
{canEditDraft ? (
|
>
|
||||||
<AdminRowActionsMenu
|
<SelectTrigger className="h-8 min-w-0">
|
||||||
busy={saving}
|
<SelectValue />
|
||||||
actions={[
|
</SelectTrigger>
|
||||||
{
|
<SelectContent>
|
||||||
key: "delete",
|
<SelectItem value="__global__">
|
||||||
label: t("actions.delete", { ns: "adminUsers" }),
|
{t("riskCap.scope.global", { ns: "config" })}
|
||||||
icon: Trash2,
|
</SelectItem>
|
||||||
destructive: true,
|
{drawOptions.map((draw) => (
|
||||||
onClick: () => removeRow(idx),
|
<SelectItem key={draw.id} value={String(draw.id)}>
|
||||||
},
|
{draw.draw_no}
|
||||||
]}
|
</SelectItem>
|
||||||
/>
|
))}
|
||||||
) : (
|
</SelectContent>
|
||||||
<span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span>
|
</Select>
|
||||||
)}
|
) : (
|
||||||
</TableCell>
|
<ConfigReadonlyValue>
|
||||||
</TableRow>
|
{r.draw_id == null
|
||||||
))}
|
? t("riskCap.scope.global", { ns: "config" })
|
||||||
</TableBody>
|
: drawOptions.find((draw) => draw.id === r.draw_id)?.draw_no ??
|
||||||
</Table>
|
t("riskCap.scope.drawId", { ns: "config", id: r.draw_id })}
|
||||||
|
</ConfigReadonlyValue>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canEditDraft ? (
|
||||||
|
<Input
|
||||||
|
className="h-8 font-mono tabular-nums"
|
||||||
|
maxLength={4}
|
||||||
|
disabled={saving}
|
||||||
|
value={r.normalized_number}
|
||||||
|
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(idx, {
|
||||||
|
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canEditDraft ? (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-8 font-mono tabular-nums"
|
||||||
|
disabled={saving}
|
||||||
|
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||||
|
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(idx, {
|
||||||
|
cap_amount:
|
||||||
|
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ConfigReadonlyValue mono>
|
||||||
|
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||||
|
</ConfigReadonlyValue>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
|
{canEditDraft ? (
|
||||||
|
<AdminRowActionsMenu
|
||||||
|
busy={saving}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: t("actions.delete", { ns: "adminUsers" }),
|
||||||
|
icon: Trash2,
|
||||||
|
destructive: true,
|
||||||
|
onClick: () => removeRow(idx),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("riskCap.readOnly", { ns: "config" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,9 @@ export function DashboardAgentRankingCard({
|
|||||||
const v = metricValue(row);
|
const v = metricValue(row);
|
||||||
const pct = (Math.abs(v) / maxAbs) * 100;
|
const pct = (Math.abs(v) / maxAbs) * 100;
|
||||||
const color = barColor(row);
|
const color = barColor(row);
|
||||||
|
const agentName = row.agent_name?.trim() || "-";
|
||||||
|
const agentCode = row.agent_code?.trim() || "";
|
||||||
|
const showCode = agentCode !== "" && agentCode !== agentName;
|
||||||
return (
|
return (
|
||||||
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
|
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -429,8 +432,10 @@ export function DashboardAgentRankingCard({
|
|||||||
#{idx + 1}
|
#{idx + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p>
|
<p className="truncate text-xs font-medium">{agentName}</p>
|
||||||
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p>
|
{showCode ? (
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground">{agentCode}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
|
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
|
||||||
|
|||||||
@@ -13,6 +13,38 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||||
|
|
||||||
|
function formatDashboardDrawStatus(
|
||||||
|
status: string,
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
|
): string {
|
||||||
|
const lower = status.trim().toLowerCase();
|
||||||
|
|
||||||
|
switch (lower) {
|
||||||
|
case "pending":
|
||||||
|
return t("statusOptions.pending", { ns: "draws" });
|
||||||
|
case "open":
|
||||||
|
return t("statusOptions.open", { ns: "draws" });
|
||||||
|
case "closing":
|
||||||
|
return t("statusOptions.closing", { ns: "draws" });
|
||||||
|
case "closed":
|
||||||
|
return t("statusOptions.closed", { ns: "draws" });
|
||||||
|
case "drawing":
|
||||||
|
return t("statusOptions.drawing", { ns: "draws" });
|
||||||
|
case "review":
|
||||||
|
return t("statusOptions.review", { ns: "draws" });
|
||||||
|
case "cooldown":
|
||||||
|
return t("statusOptions.cooldown", { ns: "draws" });
|
||||||
|
case "settling":
|
||||||
|
return t("statusOptions.settling", { ns: "draws" });
|
||||||
|
case "settled":
|
||||||
|
return t("statusOptions.settled", { ns: "draws" });
|
||||||
|
case "cancelled":
|
||||||
|
return t("statusOptions.cancelled", { ns: "draws" });
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isOpenLikeStatus(status: string): boolean {
|
function isOpenLikeStatus(status: string): boolean {
|
||||||
const lower = status.toLowerCase();
|
const lower = status.toLowerCase();
|
||||||
return lower.includes("open") || lower.includes("sale");
|
return lower.includes("open") || lower.includes("sale");
|
||||||
@@ -29,7 +61,7 @@ export function DashboardCurrentDrawCard({
|
|||||||
drawId,
|
drawId,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: DashboardCurrentDrawCardProps): ReactElement {
|
}: DashboardCurrentDrawCardProps): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation(["dashboard", "draws"]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -54,6 +86,7 @@ export function DashboardCurrentDrawCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openLike = isOpenLikeStatus(hall.status);
|
const openLike = isOpenLikeStatus(hall.status);
|
||||||
|
const statusLabel = formatDashboardDrawStatus(hall.status, t);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="admin-list-card overflow-hidden border-primary/15 py-0 shadow-sm">
|
<Card className="admin-list-card overflow-hidden border-primary/15 py-0 shadow-sm">
|
||||||
@@ -95,7 +128,7 @@ export function DashboardCurrentDrawCard({
|
|||||||
openLike ? "bg-emerald-500" : "bg-muted-foreground/70",
|
openLike ? "bg-emerald-500" : "bg-muted-foreground/70",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{hall.status}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -817,6 +817,11 @@ export function HotUsageBars({
|
|||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
|
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
|
||||||
|
const shortenPoolLabel = (value: string): string => {
|
||||||
|
const normalized = value.trim();
|
||||||
|
const trimmedMeta = normalized.replace(/\s*\(.+\)$/, "");
|
||||||
|
return trimmedMeta.length > 10 ? `${trimmedMeta.slice(0, 10)}…` : trimmedMeta;
|
||||||
|
};
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -824,6 +829,7 @@ export function HotUsageBars({
|
|||||||
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
|
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
|
||||||
return {
|
return {
|
||||||
number: row.normalized_number.trim(),
|
number: row.normalized_number.trim(),
|
||||||
|
displayNumber: shortenPoolLabel(row.normalized_number),
|
||||||
usage: pct,
|
usage: pct,
|
||||||
fill: usageBarFill(pct),
|
fill: usageBarFill(pct),
|
||||||
};
|
};
|
||||||
@@ -855,15 +861,23 @@ export function HotUsageBars({
|
|||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="number"
|
dataKey="number"
|
||||||
width={72}
|
width={compact ? 92 : 112}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const row = chartData.find((item) => item.number === value);
|
||||||
|
return row?.displayNumber ?? value;
|
||||||
|
}}
|
||||||
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
|
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
formatter={(value) => `${Number(value).toFixed(1)}%`}
|
formatter={(value, _name, item) => {
|
||||||
|
const payload = item?.payload as { number?: string } | undefined;
|
||||||
|
const number = payload?.number ? `${payload.number} · ` : "";
|
||||||
|
return `${number}${Number(value).toFixed(1)}%`;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getAdminDraw,
|
getAdminDraw,
|
||||||
|
getAdminDrawFinanceSummary,
|
||||||
postAdminCancelDraw,
|
postAdminCancelDraw,
|
||||||
postAdminManualCloseDraw,
|
postAdminManualCloseDraw,
|
||||||
postAdminReopenDraw,
|
postAdminReopenDraw,
|
||||||
@@ -22,11 +23,13 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
|||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||||
import { canManageDrawResults } from "@/lib/draw-access";
|
import { canManageDrawResults } from "@/lib/draw-access";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
|
|
||||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
|
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
|
||||||
import { DrawStatusBadge } from "./draw-status-badge";
|
import { DrawStatusBadge } from "./draw-status-badge";
|
||||||
@@ -80,6 +83,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [acting, setActing] = useState<string | null>(null);
|
const [acting, setActing] = useState<string | null>(null);
|
||||||
|
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -91,9 +95,20 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setData(await getAdminDraw(idNum));
|
const draw = await getAdminDraw(idNum);
|
||||||
|
setData(draw);
|
||||||
|
if (draw.capabilities?.can_view_draw_finance !== false) {
|
||||||
|
try {
|
||||||
|
setFinanceSummary(await getAdminDrawFinanceSummary(idNum));
|
||||||
|
} catch {
|
||||||
|
setFinanceSummary(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFinanceSummary(null);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setData(null);
|
setData(null);
|
||||||
|
setFinanceSummary(null);
|
||||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -225,6 +240,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
const batch = data.result_batch_counts;
|
const batch = data.result_batch_counts;
|
||||||
const pendingReview = batch.pending_review ?? 0;
|
const pendingReview = batch.pending_review ?? 0;
|
||||||
const totalBatches = batch.total ?? batch.published;
|
const totalBatches = batch.total ?? batch.published;
|
||||||
|
const financeCurrency = financeSummary?.currency_code ?? "NPR";
|
||||||
const hasResultActivity =
|
const hasResultActivity =
|
||||||
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
|
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
|
||||||
const showActions =
|
const showActions =
|
||||||
@@ -236,6 +252,14 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
<div className="mb-3">
|
||||||
|
<Link
|
||||||
|
href="/admin/draws"
|
||||||
|
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{t("backToList")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<CardTitle className="font-mono text-xl tracking-tight">{data.draw_no}</CardTitle>
|
<CardTitle className="font-mono text-xl tracking-tight">{data.draw_no}</CardTitle>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{t("detailSubtitle", {
|
{t("detailSubtitle", {
|
||||||
@@ -263,6 +287,39 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6 border-t pt-6">
|
<CardContent className="space-y-6 border-t pt-6">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium">{t("overviewTitle")}</h3>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{t("overviewBetTotal")}</p>
|
||||||
|
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||||
|
{formatAdminMinorUnits(
|
||||||
|
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
|
||||||
|
financeCurrency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{t("overviewPayoutTotal")}</p>
|
||||||
|
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||||
|
{formatAdminMinorUnits(
|
||||||
|
financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0,
|
||||||
|
financeCurrency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p>
|
||||||
|
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||||
|
{formatAdminMinorUnits(
|
||||||
|
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
|
||||||
|
financeCurrency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
|
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
|
||||||
<ScheduleTimeline steps={scheduleSteps} />
|
<ScheduleTimeline steps={scheduleSteps} />
|
||||||
@@ -307,6 +364,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("noResultBatchesYet")}
|
{t("noResultBatchesYet")}
|
||||||
|
<span className="ml-1">{t("reviewQueueHint")}</span>
|
||||||
{canManageDraw ? (
|
{canManageDraw ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
|
||||||
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
|
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
|
||||||
export function JackpotConfigScreen() {
|
export function JackpotConfigScreen() {
|
||||||
@@ -26,6 +28,15 @@ export function JackpotConfigScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<div className="flex w-full max-w-none flex-col gap-6">
|
||||||
<AdminPageCard title={t("poolsSectionTitle")}>
|
<AdminPageCard title={t("poolsSectionTitle")}>
|
||||||
|
<Alert className="mb-4 border-primary/20 bg-primary/5 text-foreground">
|
||||||
|
<Info className="size-4" aria-hidden />
|
||||||
|
<AlertTitle>{t("rulesTitle")}</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-1 text-xs leading-5">
|
||||||
|
<p>{t("rulesJoin")}</p>
|
||||||
|
<p>{t("rulesBurst")}</p>
|
||||||
|
<p>{t("rulesManual")}</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
<JackpotPoolsConsole embedded />
|
<JackpotPoolsConsole embedded />
|
||||||
</AdminPageCard>
|
</AdminPageCard>
|
||||||
|
|
||||||
|
|||||||
@@ -214,15 +214,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
const manualBurst = async (p: AdminJackpotPoolRow) => {
|
const manualBurst = async (p: AdminJackpotPoolRow) => {
|
||||||
const d = drafts[p.id];
|
const d = drafts[p.id];
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
const drawId = Number.parseInt(d.manual_burst_draw_id, 10);
|
const drawRef = d.manual_burst_draw_id.trim();
|
||||||
if (!Number.isFinite(drawId) || drawId <= 0) {
|
if (drawRef.length === 0) {
|
||||||
toast.error(t("invalidDrawId"));
|
toast.error(t("invalidDrawId"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBurstingId(p.id);
|
setBurstingId(p.id);
|
||||||
try {
|
try {
|
||||||
const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawId });
|
const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawRef });
|
||||||
toast.success(
|
toast.success(
|
||||||
`${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`,
|
`${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
|||||||
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
|
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
@@ -42,6 +43,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
|||||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||||
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
|
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
|
||||||
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
|
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
|
||||||
|
import { Eye } from "lucide-react";
|
||||||
|
|
||||||
function playerStatusLabel(status: number, t: (key: string) => string): string {
|
function playerStatusLabel(status: number, t: (key: string) => string): string {
|
||||||
if (status === 0) return t("statusNormal");
|
if (status === 0) return t("statusNormal");
|
||||||
@@ -309,6 +311,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
|||||||
<ProfileField label={t("authSource")}>
|
<ProfileField label={t("authSource")}>
|
||||||
{playerAuthSourceLabel(player, t)}
|
{playerAuthSourceLabel(player, t)}
|
||||||
</ProfileField>
|
</ProfileField>
|
||||||
|
<ProfileField label={t("riskTags", { defaultValue: "风控标签" })}>
|
||||||
|
{player.risk_tags && player.risk_tags.length > 0 ? player.risk_tags.join(", ") : "—"}
|
||||||
|
</ProfileField>
|
||||||
<ProfileField label={t("status")}>
|
<ProfileField label={t("status")}>
|
||||||
<PlayerStatusBadge status={player.status} t={t} />
|
<PlayerStatusBadge status={player.status} t={t} />
|
||||||
</ProfileField>
|
</ProfileField>
|
||||||
@@ -408,7 +413,10 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
|||||||
<TabsContent value="tickets" className="mt-0">
|
<TabsContent value="tickets" className="mt-0">
|
||||||
<Card className="admin-list-card">
|
<Card className="admin-list-card">
|
||||||
<CardHeader className="admin-list-header">
|
<CardHeader className="admin-list-header">
|
||||||
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("ticketTableHint")}</p>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<CardContent className="admin-list-content">
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
@@ -416,21 +424,29 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
|
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
|
||||||
|
<TableHead>{t("orderNo", { ns: "tickets" })}</TableHead>
|
||||||
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
|
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
|
||||||
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
|
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
|
||||||
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
|
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
|
||||||
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
|
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
|
||||||
<TableHead>{t("status", { ns: "tickets" })}</TableHead>
|
<TableHead>{t("status", { ns: "tickets" })}</TableHead>
|
||||||
|
<TableHead>{t("failReason", { ns: "tickets" })}</TableHead>
|
||||||
|
<TableHead className="text-center">{t("winAmount", { ns: "tickets" })}</TableHead>
|
||||||
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
|
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
|
||||||
|
<TableHead>{t("updatedAt", { ns: "tickets" })}</TableHead>
|
||||||
|
<TableHead className="sticky right-0 z-20 w-12 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
|
{t("table.actions", { ns: "common" })}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{ticketsLoading && tickets.length === 0 ? (
|
{ticketsLoading && tickets.length === 0 ? (
|
||||||
<AdminTableLoadingRow colSpan={7} />
|
<AdminTableLoadingRow colSpan={11} />
|
||||||
) : null}
|
) : null}
|
||||||
{tickets.map((row) => (
|
{tickets.map((row) => (
|
||||||
<TableRow key={row.ticket_no}>
|
<TableRow key={row.ticket_no}>
|
||||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||||||
@@ -442,13 +458,36 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
|||||||
{ticketStatusText(row.status, t)}
|
{ticketStatusText(row.status, t)}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||||||
|
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-xs tabular-nums">
|
||||||
|
{row.jackpot_win_amount_minor > 0
|
||||||
|
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
|
||||||
|
: row.win_amount_formatted}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{row.placed_at ? formatDt(row.placed_at) : "—"}
|
{row.placed_at ? formatDt(row.placed_at) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{row.updated_at ? formatDt(row.updated_at) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
|
<AdminRowActionsMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "view-ticket-in-list",
|
||||||
|
label: t("viewTicketInList", { ns: "tickets" }),
|
||||||
|
icon: Eye,
|
||||||
|
href: `/admin/tickets?player_id=${player.id}&number=${encodeURIComponent(row.ticket_no)}${row.draw_no ? `&draw_no=${encodeURIComponent(row.draw_no)}` : ""}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!ticketsLoading && tickets.length === 0 ? (
|
{!ticketsLoading && tickets.length === 0 ? (
|
||||||
<AdminTableNoResourceRow colSpan={7} className="text-muted-foreground" />
|
<AdminTableNoResourceRow colSpan={11} className="text-muted-foreground" />
|
||||||
) : null}
|
) : null}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -82,6 +82,17 @@ const PLAYER_STATUS_OPTIONS = [
|
|||||||
{ value: 2, label: "statusBanned" },
|
{ value: 2, label: "statusBanned" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function parseRiskTagsInput(text: string): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
text
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter((tag) => tag.length > 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PlayersConsole(): React.ReactElement {
|
export function PlayersConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["players", "common"]);
|
const { t } = useTranslation(["players", "common"]);
|
||||||
const tRef = useTranslationRef(["players", "common"]);
|
const tRef = useTranslationRef(["players", "common"]);
|
||||||
@@ -121,6 +132,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
const [formNickname, setFormNickname] = useState("");
|
const [formNickname, setFormNickname] = useState("");
|
||||||
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
|
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
|
||||||
const [formStatus, setFormStatus] = useState(0);
|
const [formStatus, setFormStatus] = useState(0);
|
||||||
|
const [formRiskTags, setFormRiskTags] = useState("");
|
||||||
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
|
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
|
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
|
||||||
const [createAgentLoading, setCreateAgentLoading] = useState(false);
|
const [createAgentLoading, setCreateAgentLoading] = useState(false);
|
||||||
@@ -211,6 +223,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
setFormNickname("");
|
setFormNickname("");
|
||||||
setFormDefaultCurrency("NPR");
|
setFormDefaultCurrency("NPR");
|
||||||
setFormStatus(0);
|
setFormStatus(0);
|
||||||
|
setFormRiskTags("");
|
||||||
setAccountOpen(true);
|
setAccountOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +282,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
setFormNickname(row.nickname ?? "");
|
setFormNickname(row.nickname ?? "");
|
||||||
setFormDefaultCurrency(row.default_currency);
|
setFormDefaultCurrency(row.default_currency);
|
||||||
setFormStatus(row.status);
|
setFormStatus(row.status);
|
||||||
|
setFormRiskTags((row.risk_tags ?? []).join(", "));
|
||||||
setAccountOpen(true);
|
setAccountOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +351,11 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
if (formStatus !== editingPlayer?.status) {
|
if (formStatus !== editingPlayer?.status) {
|
||||||
body.status = formStatus;
|
body.status = formStatus;
|
||||||
}
|
}
|
||||||
|
const nextRiskTags = parseRiskTagsInput(formRiskTags);
|
||||||
|
const prevRiskTags = editingPlayer?.risk_tags ?? [];
|
||||||
|
if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) {
|
||||||
|
body.risk_tags = nextRiskTags;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(body).length === 0) {
|
if (Object.keys(body).length === 0) {
|
||||||
toast.success(t("noChanges"));
|
toast.success(t("noChanges"));
|
||||||
@@ -517,6 +536,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
<TableHead>{t("sitePlayerId")}</TableHead>
|
<TableHead>{t("sitePlayerId")}</TableHead>
|
||||||
<TableHead>{t("username")}</TableHead>
|
<TableHead>{t("username")}</TableHead>
|
||||||
<TableHead>{t("nickname")}</TableHead>
|
<TableHead>{t("nickname")}</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">{t("riskTags", { defaultValue: "风控标签" })}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
|
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
|
||||||
@@ -528,12 +548,13 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<AdminTableLoadingRow colSpan={13} />
|
<AdminTableLoadingRow colSpan={14} />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<AdminTableNoResourceRow colSpan={13} className="text-muted-foreground" />
|
<AdminTableNoResourceRow colSpan={14} className="text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
items.map((row) => {
|
items.map((row) => {
|
||||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||||
|
const riskTags = row.risk_tags ?? [];
|
||||||
return (
|
return (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell className="tabular-nums">#{row.id}</TableCell>
|
<TableCell className="tabular-nums">#{row.id}</TableCell>
|
||||||
@@ -546,6 +567,22 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.username ?? "—"}</TableCell>
|
<TableCell>{row.username ?? "—"}</TableCell>
|
||||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||||
|
<TableCell className="max-w-[16rem]">
|
||||||
|
{riskTags.length > 0 ? (
|
||||||
|
<div className="flex flex-nowrap items-center gap-1 overflow-x-auto whitespace-nowrap" title={riskTags.join(", ")}>
|
||||||
|
{riskTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={`${row.id}-${tag}`}
|
||||||
|
className="inline-flex shrink-0 items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium leading-4 text-amber-900"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{row.default_currency}</TableCell>
|
<TableCell>{row.default_currency}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<PlayerFundingModeBadge row={row} />
|
<PlayerFundingModeBadge row={row} />
|
||||||
@@ -791,24 +828,61 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{accountMode === "edit" && (
|
{accountMode === "edit" && (
|
||||||
<div className="space-y-1.5">
|
<>
|
||||||
<Label htmlFor="player-edit-status">{t("status")}</Label>
|
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 text-sm">
|
||||||
<Select
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
value={String(formStatus)}
|
<div>
|
||||||
onValueChange={(v) => setFormStatus(Number(v))}
|
<p className="text-xs text-muted-foreground">
|
||||||
>
|
{t("fundingMode", { defaultValue: "资金模式" })}
|
||||||
<SelectTrigger id="player-edit-status">
|
</p>
|
||||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
<p className="mt-1 font-medium">
|
||||||
</SelectTrigger>
|
{editingPlayer ? <PlayerFundingModeBadge row={editingPlayer} /> : "—"}
|
||||||
<SelectContent>
|
</p>
|
||||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
</div>
|
||||||
<SelectItem key={o.value} value={String(o.value)}>
|
<div>
|
||||||
{t(o.label)}
|
<p className="text-xs text-muted-foreground">
|
||||||
</SelectItem>
|
{t("authSource", { defaultValue: "登录来源" })}
|
||||||
))}
|
</p>
|
||||||
</SelectContent>
|
<p className="mt-1 font-medium">
|
||||||
</Select>
|
{editingPlayer?.auth_source === "main_site_sso"
|
||||||
</div>
|
? t("authMainSite", { defaultValue: "主站 SSO" })
|
||||||
|
: editingPlayer?.auth_source === "lottery_native"
|
||||||
|
? t("authNative", { defaultValue: "彩票端" })
|
||||||
|
: editingPlayer?.auth_source ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-edit-risk-tags">
|
||||||
|
{t("riskTags", { defaultValue: "风控标签" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="player-edit-risk-tags"
|
||||||
|
value={formRiskTags}
|
||||||
|
placeholder={t("riskTagsPlaceholder", { defaultValue: "如:高频、大额、需复核;多个标签用逗号分隔" })}
|
||||||
|
onChange={(e) => setFormRiskTags(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="player-edit-status">{t("status")}</Label>
|
||||||
|
<Select
|
||||||
|
value={String(formStatus)}
|
||||||
|
onValueChange={(v) => setFormStatus(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="player-edit-status">
|
||||||
|
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={String(o.value)}>
|
||||||
|
{t(o.label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
|
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -18,7 +19,7 @@ import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admi
|
|||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -71,6 +72,18 @@ function itemStatusLabel(status: string, t: (key: string) => string): string {
|
|||||||
return t("itemMatched");
|
return t("itemMatched");
|
||||||
case "pending_check":
|
case "pending_check":
|
||||||
return t("itemPendingCheck");
|
return t("itemPendingCheck");
|
||||||
|
case "stale_processing":
|
||||||
|
return t("itemStaleProcessing");
|
||||||
|
case "pending_reconcile":
|
||||||
|
return t("itemPendingReconcile");
|
||||||
|
case "missing_wallet_txn":
|
||||||
|
return t("itemMissingWalletTxn");
|
||||||
|
case "unexpected_wallet_txn":
|
||||||
|
return t("itemUnexpectedWalletTxn");
|
||||||
|
case "missing_refund":
|
||||||
|
return t("itemMissingRefund");
|
||||||
|
case "missing_reversal":
|
||||||
|
return t("itemMissingReversal");
|
||||||
default:
|
default:
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -85,6 +98,82 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function itemResolutionLabel(
|
||||||
|
row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): string {
|
||||||
|
return row.is_resolved === true || row.resolved_at ? t("itemResolved") : t("itemUnresolved");
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemResolutionTone(row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">): "success" | "warning" {
|
||||||
|
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemDiagnosisLabel(status: string, t: (key: string) => string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "stale_processing":
|
||||||
|
return t("diagnosisStaleProcessing");
|
||||||
|
case "pending_reconcile":
|
||||||
|
return t("diagnosisPendingReconcile");
|
||||||
|
case "missing_wallet_txn":
|
||||||
|
return t("diagnosisMissingWalletTxn");
|
||||||
|
case "unexpected_wallet_txn":
|
||||||
|
return t("diagnosisUnexpectedWalletTxn");
|
||||||
|
case "missing_refund":
|
||||||
|
return t("diagnosisMissingRefund");
|
||||||
|
case "missing_reversal":
|
||||||
|
return t("diagnosisMissingReversal");
|
||||||
|
case "matched":
|
||||||
|
return t("diagnosisMatched");
|
||||||
|
default:
|
||||||
|
return t("diagnosisPendingCheck");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemSuggestedAction(
|
||||||
|
row: Pick<AdminReconcileItemsData["items"][number], "status" | "resolved_at" | "is_resolved" | "current_transfer_status">,
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||||
|
): string {
|
||||||
|
if (row.is_resolved === true || row.resolved_at) {
|
||||||
|
return t("actionResolved", {
|
||||||
|
status: row.current_transfer_status ? itemTransferStatusLabel(row.current_transfer_status, t) : t("statusCompleted"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = row.status;
|
||||||
|
switch (status) {
|
||||||
|
case "stale_processing":
|
||||||
|
return t("actionStaleProcessing");
|
||||||
|
case "pending_reconcile":
|
||||||
|
return t("actionPendingReconcile");
|
||||||
|
case "missing_wallet_txn":
|
||||||
|
return t("actionMissingWalletTxn");
|
||||||
|
case "unexpected_wallet_txn":
|
||||||
|
return t("actionUnexpectedWalletTxn");
|
||||||
|
case "missing_refund":
|
||||||
|
return t("actionMissingRefund");
|
||||||
|
case "missing_reversal":
|
||||||
|
return t("actionMissingReversal");
|
||||||
|
case "matched":
|
||||||
|
return t("actionMatched");
|
||||||
|
default:
|
||||||
|
return t("actionPendingCheck");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTransferStatusLabel(status: string, t: (key: string) => string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "success":
|
||||||
|
return t("transferStatusSuccess");
|
||||||
|
case "reversed":
|
||||||
|
return t("transferStatusReversed");
|
||||||
|
case "manually_processed":
|
||||||
|
return t("transferStatusManual");
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
||||||
const raw = summary?.[key];
|
const raw = summary?.[key];
|
||||||
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
||||||
@@ -507,8 +596,8 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
<AdminRowActionsMenu
|
<AdminRowActionsMenu
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view-details",
|
||||||
label: t("view"),
|
label: t("viewDetails"),
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedId(row.id);
|
setSelectedId(row.id);
|
||||||
@@ -609,16 +698,20 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||||
<TableHead>{t("sideARef")}</TableHead>
|
<TableHead className="min-w-[10rem]">{t("sideARef")}</TableHead>
|
||||||
<TableHead>{t("sideBRef")}</TableHead>
|
<TableHead className="min-w-[10rem]">{t("sideBRef")}</TableHead>
|
||||||
<TableHead className="text-right">{t("differenceAmount")}</TableHead>
|
<TableHead className="w-28 text-right">{t("differenceAmount")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead className="w-32">{t("itemResult")}</TableHead>
|
||||||
<TableHead>{t("detectedAt")}</TableHead>
|
<TableHead className="min-w-[16rem] whitespace-normal leading-snug">{t("diagnosis")}</TableHead>
|
||||||
|
<TableHead className="min-w-[16rem] whitespace-normal leading-snug">{t("suggestedAction")}</TableHead>
|
||||||
|
<TableHead className="w-28">{t("processingStatus")}</TableHead>
|
||||||
|
<TableHead className="w-32">{t("quickAccess")}</TableHead>
|
||||||
|
<TableHead className="w-36">{t("detectedAt")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.items.length === 0 ? (
|
{items.items.length === 0 ? (
|
||||||
<AdminTableNoResourceRow colSpan={6} />
|
<AdminTableNoResourceRow colSpan={10} />
|
||||||
) : (
|
) : (
|
||||||
items.items.map((r) => (
|
items.items.map((r) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -628,10 +721,10 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
r.status === "matched" && "bg-emerald-500/5",
|
r.status === "matched" && "bg-emerald-500/5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TableCell>{r.id}</TableCell>
|
<TableCell className="align-top">{r.id}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
|
<TableCell className="align-top font-mono text-xs break-all">{r.side_a_ref ?? "—"}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
|
<TableCell className="align-top font-mono text-xs break-all">{r.side_b_ref ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">
|
<TableCell className="align-top text-right tabular-nums">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
||||||
@@ -640,12 +733,46 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
{r.difference_amount}
|
{r.difference_amount}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="align-top">
|
||||||
<AdminStatusBadge status={r.status}>
|
<AdminStatusBadge status={r.status}>
|
||||||
{itemStatusLabel(r.status, t)}
|
{itemStatusLabel(r.status, t)}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
<TableCell className="align-top min-w-[16rem] max-w-[18rem] whitespace-normal break-words text-xs leading-6 text-muted-foreground">
|
||||||
|
{itemDiagnosisLabel(r.status, t)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top min-w-[16rem] max-w-[18rem] whitespace-normal break-words text-xs leading-6">
|
||||||
|
{itemSuggestedAction(r, t)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<AdminStatusBadge status={r.resolved_at ? "resolved" : "unresolved"} tone={itemResolutionTone(r)}>
|
||||||
|
{itemResolutionLabel(r, t)}
|
||||||
|
</AdminStatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top min-w-[10rem]">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{r.side_a_ref ? (
|
||||||
|
<Link
|
||||||
|
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(r.side_a_ref)}`}
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||||
|
>
|
||||||
|
{t("openTransferOrder")}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{r.side_b_ref ? (
|
||||||
|
<Link
|
||||||
|
href={`/admin/wallet/transactions?txn_no=${encodeURIComponent(r.side_b_ref)}`}
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||||
|
>
|
||||||
|
{t("openWalletTxn")}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{!r.side_a_ref && !r.side_b_ref ? (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="align-top whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||||
{formatTs(r.created_at)}
|
{formatTs(r.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|||||||
type ReportJobsPanelProps = {
|
type ReportJobsPanelProps = {
|
||||||
canExport: boolean;
|
canExport: boolean;
|
||||||
refreshToken?: number;
|
refreshToken?: number;
|
||||||
|
reportType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) {
|
export function ReportJobsPanel({ canExport, refreshToken = 0, reportType }: ReportJobsPanelProps) {
|
||||||
const { t } = useTranslation(["reports", "common"]);
|
const { t } = useTranslation(["reports", "common"]);
|
||||||
const tRef = useTranslationRef(["reports", "common"]);
|
const tRef = useTranslationRef(["reports", "common"]);
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
@@ -53,7 +54,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
|||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getAdminReportJobs({ page: 1, per_page: 10 });
|
const data = await getAdminReportJobs({ page: 1, per_page: 10, report_type: reportType || undefined });
|
||||||
setJobs(data.items);
|
setJobs(data.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed"));
|
||||||
@@ -61,7 +62,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [reportType, tRef]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void loadJobs();
|
void loadJobs();
|
||||||
@@ -100,7 +101,9 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
|||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-2">
|
<CardContent className="pt-2">
|
||||||
<p className="mb-3 text-xs text-muted-foreground">{t("exportHint")}</p>
|
<p className="mb-3 text-xs text-muted-foreground">
|
||||||
|
{reportType ? t("tasks.currentReportHint") : t("exportHint")}
|
||||||
|
</p>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ import type {
|
|||||||
AdminReportRebateCommissionRow,
|
AdminReportRebateCommissionRow,
|
||||||
} from "@/types/api/admin-reports";
|
} from "@/types/api/admin-reports";
|
||||||
|
|
||||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit" | "legacy";
|
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
||||||
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
|
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
|
||||||
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
||||||
type ExportFormat = "csv" | "excel";
|
type ExportFormat = "csv" | "excel";
|
||||||
@@ -192,7 +192,7 @@ const REPORTS: ReportDefinition[] = [
|
|||||||
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
|
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
|
||||||
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||||
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
|
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
|
||||||
{ key: "rebate_commission", category: "legacy", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
{ key: "rebate_commission", category: "profit", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||||
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
|
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -226,8 +226,6 @@ function categoryTone(category: ReportCategory): string {
|
|||||||
return "border-red-200 bg-red-50 text-red-700";
|
return "border-red-200 bg-red-50 text-red-700";
|
||||||
case "audit":
|
case "audit":
|
||||||
return "border-slate-200 bg-slate-50 text-slate-700";
|
return "border-slate-200 bg-slate-50 text-slate-700";
|
||||||
case "legacy":
|
|
||||||
return "border-amber-200 bg-amber-50 text-amber-800";
|
|
||||||
default:
|
default:
|
||||||
return "border-blue-200 bg-blue-50 text-blue-700";
|
return "border-blue-200 bg-blue-50 text-blue-700";
|
||||||
}
|
}
|
||||||
@@ -405,6 +403,90 @@ function resultRowCount(result: ReportResult | null): number {
|
|||||||
return result?.rows.length ?? 0;
|
return result?.rows.length ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultSummaryCards(
|
||||||
|
reportKey: ReportKey,
|
||||||
|
filters: ReportFilters,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): StatCard[] {
|
||||||
|
const periodLabel =
|
||||||
|
filters.dateFrom && filters.dateTo
|
||||||
|
? `${filters.dateFrom} ~ ${filters.dateTo}`
|
||||||
|
: filters.dateFrom || filters.dateTo || t("preview.stats.notQueried");
|
||||||
|
|
||||||
|
switch (reportKey) {
|
||||||
|
case "draw_profit":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||||
|
];
|
||||||
|
case "daily_profit":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("fields.period"), value: periodLabel },
|
||||||
|
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||||
|
];
|
||||||
|
case "player_win_loss":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
|
||||||
|
{ label: t("preview.stats.players"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||||
|
];
|
||||||
|
case "player_transfer":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
|
||||||
|
{ label: t("preview.stats.transferIn"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.transferOut"), value: t("preview.stats.notQueried") },
|
||||||
|
];
|
||||||
|
case "hot_number_risk":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||||
|
{ label: t("fields.number"), value: filters.number || t("preview.stats.notSet") },
|
||||||
|
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.logs"), value: t("preview.stats.notQueried") },
|
||||||
|
];
|
||||||
|
case "play_dimension":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("fields.play"), value: filters.play || t("filterAll") },
|
||||||
|
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
|
||||||
|
];
|
||||||
|
case "sold_out_number":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||||
|
{ label: t("preview.stats.currency"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
|
||||||
|
];
|
||||||
|
case "rebate_commission":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("fields.play"), value: filters.play || t("filterAll") },
|
||||||
|
{ label: t("preview.stats.rebate"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.orders"), value: t("preview.stats.notQueried") },
|
||||||
|
];
|
||||||
|
case "admin_audit":
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") },
|
||||||
|
{ label: t("preview.stats.modules"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.operators"), value: t("preview.stats.notQueried") },
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") },
|
||||||
|
{ label: t("preview.stats.exportRows"), value: "0" },
|
||||||
|
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
|
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
|
||||||
const { t, i18n } = useTranslation(["reports", "common"]);
|
const { t, i18n } = useTranslation(["reports", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
@@ -1426,189 +1508,179 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||||
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
<Card className="admin-list-card">
|
||||||
<Card className="admin-list-card self-start">
|
<CardHeader className="admin-list-header pb-3">
|
||||||
<CardHeader className="admin-list-header pb-4">
|
<div className="flex flex-col gap-3">
|
||||||
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle>
|
<div className="flex flex-wrap gap-2">
|
||||||
</CardHeader>
|
{filteredReports.map((report) => {
|
||||||
<CardContent className="space-y-1.5 pt-3">
|
const Icon = report.icon;
|
||||||
{filteredReports.map((report) => {
|
const active = report.key === selectedReport.key;
|
||||||
const Icon = report.icon;
|
return (
|
||||||
const active = report.key === selectedReport.key;
|
<button
|
||||||
return (
|
key={report.key}
|
||||||
<button
|
|
||||||
key={report.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedKey(report.key)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full min-w-0 items-center gap-3 rounded-md border px-3 py-2.5 text-left transition",
|
|
||||||
active
|
|
||||||
? "border-primary bg-primary/[0.05] shadow-sm ring-1 ring-primary/15"
|
|
||||||
: "border-border/80 bg-card hover:border-primary/35 hover:bg-muted/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
|
|
||||||
<Icon className="size-4" aria-hidden />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate text-sm font-medium text-foreground">{t(`items.${report.key}.title`)}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="min-w-0 space-y-5">
|
|
||||||
<Card className="admin-list-card">
|
|
||||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle className="admin-list-title">{t("filterPanel")}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 pt-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{selectedReport.fields.map(renderField)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
|
|
||||||
<div className="flex shrink-0 gap-2">
|
|
||||||
<Button type="button" variant="outline" onClick={resetFilters}>
|
|
||||||
{t("reset")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canViewReports || !selectedReport.connected || loading}
|
onClick={() => setSelectedKey(report.key)}
|
||||||
onClick={() => {
|
className={cn(
|
||||||
setPage(1);
|
"inline-flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition",
|
||||||
void queryReport();
|
active
|
||||||
}}
|
? "border-primary bg-primary/[0.06] text-primary shadow-sm"
|
||||||
|
: "border-border/80 bg-card text-muted-foreground hover:border-primary/35 hover:text-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Database data-icon="inline-start" />
|
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
|
||||||
{loading ? t("querying") : t("query")}
|
<Icon className="size-3.5" aria-hidden />
|
||||||
</Button>
|
</span>
|
||||||
</div>
|
<span className="truncate">{t(`items.${report.key}.title`)}</span>
|
||||||
</div>
|
</button>
|
||||||
</CardContent>
|
);
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
|
||||||
{(result?.summary ?? [
|
|
||||||
{ label: t("preview.stats.records"), value: "-" },
|
|
||||||
{ label: t("preview.stats.currentPage"), value: "-" },
|
|
||||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || "-" },
|
|
||||||
{ label: t("preview.stats.exportRows"), value: String(resultRowCount(result)) },
|
|
||||||
]).map((item) => (
|
|
||||||
<div key={item.label} className={cn("rounded-md border px-4 py-3", statTone(item.tone))}>
|
|
||||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
|
||||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums">{item.value}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedReport.key === "rebate_commission" ? (
|
|
||||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
|
||||||
{t("items.rebate_commission.disclaimer", {
|
|
||||||
defaultValue:
|
|
||||||
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 pt-0">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{selectedReport.fields.map(renderField)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("filterPanel")}</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
|
||||||
|
{t("reset")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canViewReports || !selectedReport.connected || loading}
|
||||||
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
|
void queryReport();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Database data-icon="inline-start" />
|
||||||
|
{loading ? t("querying") : t("query")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="admin-list-card">
|
<div className="grid gap-2 md:grid-cols-4">
|
||||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
|
||||||
<div>
|
<div key={item.label} className={cn("rounded-md border px-3 py-2.5", statTone(item.tone))}>
|
||||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||||
</div>
|
<div className="mt-0.5 truncate text-base font-semibold tabular-nums">{item.value}</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t("exportServerHint")}</p>
|
))}
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
{selectedReport.key === "rebate_commission" ? (
|
||||||
variant="outline"
|
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||||
disabled={!canExportReports || exporting !== null}
|
{t("items.rebate_commission.disclaimer", {
|
||||||
onClick={() => exportReport("csv")}
|
defaultValue:
|
||||||
>
|
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
|
||||||
<FileDown data-icon="inline-start" />
|
})}
|
||||||
{t("formats.csvServer")}
|
</div>
|
||||||
</Button>
|
) : null}
|
||||||
<Button
|
|
||||||
type="button"
|
<Card className="admin-list-card">
|
||||||
disabled={!canExportReports || exporting !== null}
|
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
onClick={() => exportReport("excel")}
|
<div>
|
||||||
>
|
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||||
<FileSpreadsheet data-icon="inline-start" />
|
</div>
|
||||||
{t("formats.excelServer")}
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
</Button>
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
</div>
|
<Button
|
||||||
{result && result.rows.length > 0 ? (
|
type="button"
|
||||||
<>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canExportReports || exporting !== null}
|
||||||
|
onClick={() => exportReport("csv")}
|
||||||
|
>
|
||||||
|
<FileDown data-icon="inline-start" />
|
||||||
|
{t("formats.csvServer")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canExportReports || exporting !== null}
|
||||||
|
onClick={() => exportReport("excel")}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet data-icon="inline-start" />
|
||||||
|
{t("formats.excelServer")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{result && result.rows.length > 0 ? (
|
||||||
|
<>
|
||||||
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
|
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canExportReports || exporting !== null}
|
disabled={!canExportReports || exporting !== null}
|
||||||
onClick={() => exportPreview("csv")}
|
onClick={() => exportPreview("csv")}
|
||||||
>
|
>
|
||||||
{t("formats.csvPreview")}
|
{t("formats.csvPreview")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canExportReports || exporting !== null}
|
disabled={!canExportReports || exporting !== null}
|
||||||
onClick={() => exportPreview("excel")}
|
onClick={() => exportPreview("excel")}
|
||||||
>
|
>
|
||||||
{t("formats.excelPreview")}
|
{t("formats.excelPreview")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pt-4">
|
<CardContent className="space-y-3 pt-3">
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950">
|
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
|
||||||
{t("preview.summaryScopeHint")}
|
{t("preview.summaryScopeHint")}
|
||||||
</div>
|
</div>
|
||||||
<Table id="reports-preview-table">
|
<Table id="reports-preview-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{previewColumns.primary}</TableHead>
|
<TableHead>{previewColumns.primary}</TableHead>
|
||||||
<TableHead>{previewColumns.secondary}</TableHead>
|
<TableHead>{previewColumns.secondary}</TableHead>
|
||||||
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
||||||
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
||||||
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
||||||
<TableHead>{previewColumns.status}</TableHead>
|
<TableHead>{previewColumns.status}</TableHead>
|
||||||
<TableHead>{previewColumns.extra}</TableHead>
|
<TableHead>{previewColumns.extra}</TableHead>
|
||||||
<TableHead>{previewColumns.time}</TableHead>
|
<TableHead>{previewColumns.time}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>{renderTable()}</TableBody>
|
<TableBody>{renderTable()}</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{result?.meta ? (
|
{result?.meta ? (
|
||||||
<AdminListPaginationFooter
|
<AdminListPaginationFooter
|
||||||
selectId="reports-preview-per-page"
|
selectId="reports-preview-per-page"
|
||||||
total={result.meta.total}
|
total={result.meta.total}
|
||||||
page={result.meta.page}
|
page={result.meta.page}
|
||||||
lastPage={result.meta.lastPage}
|
lastPage={result.meta.lastPage}
|
||||||
perPage={result.meta.perPage}
|
perPage={result.meta.perPage}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onPerPageChange={(next) => {
|
onPerPageChange={(next) => {
|
||||||
setPerPage(next);
|
setPerPage(next);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReportJobsPanel canExport={canExportReports} refreshToken={jobRefreshToken} />
|
<ReportJobsPanel
|
||||||
|
canExport={canExportReports}
|
||||||
|
refreshToken={jobRefreshToken}
|
||||||
|
reportType={REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ category: "profit", href: "/admin/reports/profit" },
|
|
||||||
{ category: "wallet", href: "/admin/reports/wallet" },
|
|
||||||
{ category: "legacy", href: "/admin/reports/legacy" },
|
|
||||||
{ category: "risk", href: "/admin/reports/risk" },
|
|
||||||
{ category: "audit", href: "/admin/reports/audit" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function ReportsSubnav(): React.ReactElement {
|
|
||||||
const { t } = useTranslation("reports");
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminSubnav aria-label={t("title")}>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
|
||||||
return (
|
|
||||||
<AdminSubnavLink key={tab.href} href={tab.href} active={active}>
|
|
||||||
{t(`categories.${tab.category}`)}
|
|
||||||
</AdminSubnavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AdminSubnav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|
||||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
|
||||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
|
||||||
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
|
||||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
|
|
||||||
interface CurrencyFormatDraft {
|
|
||||||
currencyDisplayDecimals: string;
|
|
||||||
currencyDecimalSeparator: string;
|
|
||||||
currencyThousandsSeparator: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const INITIAL: CurrencyFormatDraft = {
|
|
||||||
currencyDisplayDecimals: "2",
|
|
||||||
currencyDecimalSeparator: ".",
|
|
||||||
currencyThousandsSeparator: ",",
|
|
||||||
};
|
|
||||||
|
|
||||||
function fromKv(kv: Record<string, unknown>): CurrencyFormatDraft {
|
|
||||||
return {
|
|
||||||
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
|
||||||
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
|
||||||
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDirtyItems(draft: CurrencyFormatDraft, saved: CurrencyFormatDraft): AdminSettingBatchItem[] {
|
|
||||||
const items: AdminSettingBatchItem[] = [];
|
|
||||||
if (draft.currencyDisplayDecimals !== saved.currencyDisplayDecimals) {
|
|
||||||
items.push({
|
|
||||||
key: DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
|
||||||
value: Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (draft.currencyDecimalSeparator !== saved.currencyDecimalSeparator) {
|
|
||||||
items.push({
|
|
||||||
key: DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
|
||||||
value: (draft.currencyDecimalSeparator || ".").slice(0, 1),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (draft.currencyThousandsSeparator !== saved.currencyThousandsSeparator) {
|
|
||||||
items.push({
|
|
||||||
key: DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
|
||||||
value: (draft.currencyThousandsSeparator || ",").slice(0, 1),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CurrencyFormatSettingsPanel() {
|
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
|
||||||
const buildItems = useCallback(buildDirtyItems, []);
|
|
||||||
const section = useSettingsSection({
|
|
||||||
initialDraft: INITIAL,
|
|
||||||
fromKv,
|
|
||||||
buildDirtyItems: buildItems,
|
|
||||||
saveSuccessKey: "system.saveCurrencyFormatSuccess",
|
|
||||||
saveFailedKey: "system.saveFailed",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AdminPageCard
|
|
||||||
title={t("system.sections.currencyFormat", { ns: "config" })}
|
|
||||||
description={t("system.sections.currencyFormatDescription", { ns: "config" })}
|
|
||||||
>
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
|
||||||
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="currency-display-decimals"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="12"
|
|
||||||
step="1"
|
|
||||||
value={draft.currencyDisplayDecimals}
|
|
||||||
placeholder={t("system.placeholders.currencyDisplayDecimals", { ns: "config" })}
|
|
||||||
onChange={(e) => updateField("currencyDisplayDecimals", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
|
||||||
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="currency-decimal-separator"
|
|
||||||
value={draft.currencyDecimalSeparator}
|
|
||||||
placeholder={t("system.placeholders.currencyDecimalSeparator", { ns: "config" })}
|
|
||||||
onChange={(e) => updateField("currencyDecimalSeparator", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
maxLength={1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
|
||||||
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="currency-thousands-separator"
|
|
||||||
value={draft.currencyThousandsSeparator}
|
|
||||||
placeholder={t("system.placeholders.currencyThousandsSeparator", { ns: "config" })}
|
|
||||||
onChange={(e) => updateField("currencyThousandsSeparator", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
maxLength={1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SettingsSectionActions
|
|
||||||
dirty={dirty}
|
|
||||||
loading={loading}
|
|
||||||
saving={saving}
|
|
||||||
onSave={() =>
|
|
||||||
requestConfirm({
|
|
||||||
title: t("system.confirmSaveCurrencyFormatTitle", { ns: "config" }),
|
|
||||||
description: t("system.confirmSaveCurrencyFormatDescription", { ns: "config" }),
|
|
||||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
|
||||||
onConfirm: () => {
|
|
||||||
void save();
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onDiscard={discard}
|
|
||||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
|
||||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
|
||||||
discardLabel={t("system.discard", { ns: "config" })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AdminPageCard>
|
|
||||||
<ConfirmDialog />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,9 +11,6 @@ export const DRAW_KEYS = {
|
|||||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
||||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||||
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
|
||||||
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
|
||||||
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const SETTLEMENT_KEYS = {
|
export const SETTLEMENT_KEYS = {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
|
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
|
||||||
import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel";
|
|
||||||
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
|
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
|
||||||
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
|
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
|
||||||
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
|
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
|
||||||
@@ -15,7 +14,6 @@ function SystemSettingsContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<div className="flex w-full max-w-none flex-col gap-6">
|
||||||
<DrawSettingsPanel />
|
<DrawSettingsPanel />
|
||||||
<CurrencyFormatSettingsPanel />
|
|
||||||
<SettlementSettingsPanel />
|
<SettlementSettingsPanel />
|
||||||
|
|
||||||
<AdminPageCard
|
<AdminPageCard
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
|
|
||||||
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
|
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(1040px,calc(100vw-2rem))] sm:!max-w-[1040px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(860px,calc(100vw-2rem))] sm:!max-w-[860px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
||||||
>
|
>
|
||||||
<DialogHeader className="border-b px-6 py-4">
|
<DialogHeader className="border-b px-6 py-4">
|
||||||
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
|
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||||
|
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
||||||
@@ -124,9 +125,13 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||||
|
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
||||||
|
const numberKeywordFromUrl = (searchParams.get("number") ?? "").trim();
|
||||||
const initialFilters: TicketFilters = {
|
const initialFilters: TicketFilters = {
|
||||||
...emptyTicketFilters,
|
...emptyTicketFilters,
|
||||||
playerQuery: playerIdFromUrl,
|
playerQuery: playerIdFromUrl,
|
||||||
|
drawNo: drawNoFromUrl,
|
||||||
|
numberKeyword: numberKeywordFromUrl,
|
||||||
};
|
};
|
||||||
const [draft, setDraft] = useState<TicketFilters>(initialFilters);
|
const [draft, setDraft] = useState<TicketFilters>(initialFilters);
|
||||||
const [applied, setApplied] = useState<TicketFilters>(initialFilters);
|
const [applied, setApplied] = useState<TicketFilters>(initialFilters);
|
||||||
@@ -340,6 +345,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
<TableHead>{t("ticketNo")}</TableHead>
|
<TableHead>{t("ticketNo")}</TableHead>
|
||||||
<AdminAgentIdentityHeads />
|
<AdminAgentIdentityHeads />
|
||||||
<AdminPlayerIdentityHeads />
|
<AdminPlayerIdentityHeads />
|
||||||
|
<TableHead>{t("fundingMode", { ns: "players" })}</TableHead>
|
||||||
<TableHead>{t("orderNo")}</TableHead>
|
<TableHead>{t("orderNo")}</TableHead>
|
||||||
<TableHead>{t("drawNo")}</TableHead>
|
<TableHead>{t("drawNo")}</TableHead>
|
||||||
<TableHead>{t("playCode")}</TableHead>
|
<TableHead>{t("playCode")}</TableHead>
|
||||||
@@ -351,14 +357,14 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
<TableHead className="text-center">{t("winAmount")}</TableHead>
|
<TableHead className="text-center">{t("winAmount")}</TableHead>
|
||||||
<TableHead>{t("placedAt")}</TableHead>
|
<TableHead>{t("placedAt")}</TableHead>
|
||||||
<TableHead>{t("updatedAt")}</TableHead>
|
<TableHead>{t("updatedAt")}</TableHead>
|
||||||
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<AdminTableLoadingRow colSpan={17} />
|
<AdminTableLoadingRow colSpan={18} />
|
||||||
) : !data || data.items.length === 0 ? (
|
) : !data || data.items.length === 0 ? (
|
||||||
<AdminTableNoResourceRow colSpan={17} />
|
<AdminTableNoResourceRow colSpan={18} />
|
||||||
) : (
|
) : (
|
||||||
data.items.map((row) => {
|
data.items.map((row) => {
|
||||||
const winLabel = row.jackpot_win_amount > 0
|
const winLabel = row.jackpot_win_amount > 0
|
||||||
@@ -369,6 +375,9 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||||
<AdminAgentIdentityCells row={row} />
|
<AdminAgentIdentityCells row={row} />
|
||||||
<AdminPlayerIdentityCells row={row} />
|
<AdminPlayerIdentityCells row={row} />
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
<PlayerFundingModeBadge row={row} />
|
||||||
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components
|
|||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -55,7 +55,6 @@ import { useExportLabels } from "@/hooks/use-export-labels";
|
|||||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
|
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
AdminPlayerWalletsData,
|
AdminPlayerWalletsData,
|
||||||
@@ -323,13 +322,23 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
const exportLabels = useExportLabels("walletTransferOrders");
|
const exportLabels = useExportLabels("walletTransferOrders");
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||||
|
const transferNoFromUrl = (searchParams.get("transfer_no") ?? "").trim();
|
||||||
|
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
|
||||||
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
|
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
|
const initialTransferFilters: TransferFilters = {
|
||||||
const [applied, setApplied] = useState<TransferFilters>(emptyTransferFilters);
|
...emptyTransferFilters,
|
||||||
|
playerId: playerIdFromUrl,
|
||||||
|
transferNo: transferNoFromUrl,
|
||||||
|
externalRefNo: externalRefNoFromUrl,
|
||||||
|
};
|
||||||
|
const [draft, setDraft] = useState<TransferFilters>(initialTransferFilters);
|
||||||
|
const [applied, setApplied] = useState<TransferFilters>(initialTransferFilters);
|
||||||
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
|
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const doAction = async (
|
const doAction = async (
|
||||||
@@ -645,10 +654,18 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
|
|
||||||
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||||
|
const txnNoFromUrl = (searchParams.get("txn_no") ?? "").trim();
|
||||||
|
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
|
||||||
|
const initialTxnFilters: TxnFilters = {
|
||||||
|
...emptyTxnFilters,
|
||||||
|
playerId: playerIdFromUrl,
|
||||||
|
txnNo: txnNoFromUrl,
|
||||||
|
externalRefNo: externalRefNoFromUrl,
|
||||||
|
};
|
||||||
|
const [draft, setDraft] = useState<TxnFilters>(initialTxnFilters);
|
||||||
|
const [applied, setApplied] = useState<TxnFilters>(initialTxnFilters);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -685,15 +702,6 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
void load();
|
void load();
|
||||||
}, [page, perPage, applied]);
|
}, [page, perPage, applied]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
|
||||||
if (!playerIdFromUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraft((d) => ({ ...d, playerId: playerIdFromUrl }));
|
|
||||||
setApplied((d) => ({ ...d, playerId: playerIdFromUrl }));
|
|
||||||
setPage(1);
|
|
||||||
}, [playerIdFromUrl]);
|
|
||||||
|
|
||||||
const runSearch = () => {
|
const runSearch = () => {
|
||||||
setApplied({ ...draft });
|
setApplied({ ...draft });
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export type AdminAuditLogRow = {
|
|||||||
id: number;
|
id: number;
|
||||||
operator_type: string;
|
operator_type: string;
|
||||||
operator_id: number;
|
operator_id: number;
|
||||||
|
operator_label: string;
|
||||||
|
operator_subtitle: string | null;
|
||||||
module_code: string;
|
module_code: string;
|
||||||
action_code: string;
|
action_code: string;
|
||||||
target_type: string | null;
|
target_type: string | null;
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export type AdminDrawShowData = {
|
|||||||
sequence_no: number;
|
sequence_no: number;
|
||||||
status: string;
|
status: string;
|
||||||
hall_preview_status: string;
|
hall_preview_status: string;
|
||||||
|
total_bet_minor?: number;
|
||||||
|
total_payout_minor?: number;
|
||||||
|
profit_loss_minor?: number;
|
||||||
start_time: string | null;
|
start_time: string | null;
|
||||||
close_time: string | null;
|
close_time: string | null;
|
||||||
draw_time: string | null;
|
draw_time: string | null;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export type AdminReconcileItemRow = {
|
|||||||
difference_amount: number;
|
difference_amount: number;
|
||||||
status: string;
|
status: string;
|
||||||
resolved_at: string | null;
|
resolved_at: string | null;
|
||||||
|
is_resolved?: boolean;
|
||||||
|
current_transfer_status?: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type AdminTicketItemRow = {
|
|||||||
site_player_id: string | null;
|
site_player_id: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
|
funding_mode?: string | null;
|
||||||
|
uses_credit?: boolean;
|
||||||
order_no: string | null;
|
order_no: string | null;
|
||||||
draw_no: string | null;
|
draw_no: string | null;
|
||||||
currency_code: string | null;
|
currency_code: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user