refactor(risk, navigation): update risk management redirects and enhance loading states

Changed default redirects in risk management pages to point to the new risk pools section. Removed unused risk lock log components and streamlined the admin reports page with a loading state for better user experience. Added a new DocFigure component for improved documentation visuals and updated localization files to include new figure descriptions.
This commit is contained in:
2026-06-16 13:50:58 +08:00
parent b774e22352
commit a4454a54a4
57 changed files with 981 additions and 1161 deletions

View File

@@ -48,4 +48,4 @@ This version has breaking changes — APIs, conventions, and file structure may
- Docs 侧栏粘性定位用 CSS 变量 `--docs-sticky-top`(含顶栏/header 偏移)。
- Tanumo 联调/生产默认H5 `https://front.tanumo.com`、API `https://lotterylaravel.tanumo.com`、管理端/文档 `https://lotteryadmin.tanumo.com`
- 接入文档 SSO无「登录换票」主站 JWT 直传 H5/iframe`GET /api/v1/player/me` 自动建档iframe 约定 token 在顶层 `data.token`;勿引用 main-site/monorepo 等内部仓库路径。
- 风控页默认:占用流水按注单聚合;风险池仅显示有占用/高风险;组合明细注单详情二级页。
- 风控页默认:风险池仅显示有占用/高风险;单号详情可看占用来源;组合明细注单详情页。

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -2,7 +2,6 @@ import { adminRequest } from "@/lib/admin-http";
import type {
AdminRiskLockLogListData,
AdminRiskPoolListData,
AdminRiskPoolRow,
AdminRiskPoolShowData,
@@ -57,34 +56,6 @@ export async function postAdminRiskPoolRecover(
);
}
export type AdminRiskLockLogQuery = {
page?: number;
per_page?: number;
group_by?: "ticket" | "entry";
action_type?: "lock" | "release";
normalized_number?: string;
ticket_item_id?: number;
};
export async function getAdminRiskPoolLockLogs(
drawId: number,
q: AdminRiskLockLogQuery = {},
): Promise<AdminRiskLockLogListData> {
return adminRequest.get<AdminRiskLockLogListData>(
`${A}/draws/${drawId}/risk-pool-lock-logs`,
{
params: {
page: q.page,
per_page: q.per_page,
group_by: q.group_by ?? "ticket",
action_type: q.action_type,
normalized_number: q.normalized_number,
ticket_item_id: q.ticket_item_id,
},
},
);
}
export type AdminRiskPoolShowQuery = {
page?: number;
per_page?: number;

View File

@@ -1,10 +1,8 @@
import { RiskLockLogsConsole } from "@/modules/risk/risk-lock-logs-console";
import { redirect } from "next/navigation";
export default async function AdminDrawRiskOccupancyPage(props: {
export default async function AdminDrawRiskOccupancyRedirectPage(props: {
params: Promise<{ drawId: string }>;
}) {
const { drawId } = await props.params;
const id = Number(drawId);
return <RiskLockLogsConsole drawId={id} />;
redirect(`/admin/draws/${drawId}/risk/pools`);
}

View File

@@ -5,5 +5,5 @@ export default async function AdminDrawRiskIndexPage(props: {
}) {
const { drawId } = await props.params;
redirect(`/admin/draws/${drawId}/risk/occupancy`);
redirect(`/admin/draws/${drawId}/risk/pools`);
}

View File

@@ -1,11 +1,15 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
import { ReportsConsole } from "@/modules/reports/reports-console";
import { Suspense } from "react";
export default function AdminReportsPage() {
return (
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
<Suspense fallback={<AdminLoadingState minHeight="12rem" />}>
<ReportsConsole />
</Suspense>
</AdminPermissionGate>
);
}

View File

@@ -4,5 +4,5 @@ export default async function AdminRiskOccupancyPage(props: {
params: Promise<{ drawId: string }>;
}) {
const { drawId } = await props.params;
redirect(`/admin/draws/${drawId}/risk/occupancy`);
redirect(`/admin/draws/${drawId}/risk/pools`);
}

View File

@@ -55,6 +55,21 @@ export function DocParagraph({ children }: { children: ReactNode }): React.React
return <p className="text-[15px] leading-7 text-slate-700 sm:leading-8">{children}</p>;
}
export function DocFigure({
src,
alt,
}: {
src: string;
alt: string;
}): React.ReactElement {
return (
<figure className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} className="block w-full" loading="lazy" decoding="async" />
</figure>
);
}
export function DocNote({ children }: { children: ReactNode }): React.ReactElement {
return (
<aside className="rounded-lg border border-amber-200/90 bg-amber-50/90 px-4 py-3.5 text-[15px] leading-7 text-amber-950 shadow-sm">

View File

@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import { useEffect } from "react";
import i18n from "@/i18n";
import i18n, { ensureAdminI18nReady, normalizeAdminLanguage } from "@/i18n";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -13,29 +13,19 @@ type ProvidersProps = {
children: ReactNode;
};
function applyStoredAdminLanguage() {
const locale = hydrateAdminUiLocale();
if (!locale) {
return;
}
const current = i18n.resolvedLanguage ?? i18n.language;
if (locale !== current) {
void i18n.changeLanguage(locale);
}
}
function AdminSessionHydrator() {
useEffect(() => {
if (i18n.isInitialized) {
applyStoredAdminLanguage();
} else {
i18n.on("initialized", applyStoredAdminLanguage);
}
useAdminSessionStore.getState().rehydrate();
void (async () => {
await ensureAdminI18nReady();
return () => {
i18n.off("initialized", applyStoredAdminLanguage);
};
const locale = hydrateAdminUiLocale();
const current = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
if (locale && locale !== current) {
await i18n.changeLanguage(locale);
}
useAdminSessionStore.getState().rehydrate();
})();
}, []);
return null;

View File

@@ -17,7 +17,6 @@ export type ExportLabelKey =
| "settlementBatches"
| "jackpotPayouts"
| "jackpotContributions"
| "riskLockLogs"
| "riskPools"
| "riskIndex"
| "riskPoolDetail"

40
src/i18n/backend.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { BackendModule, i18n as I18nInstance } from "i18next";
import { normalizeAdminLanguage } from "@/i18n/languages";
import type { AdminLanguage } from "@/i18n/languages";
import { loadAdminLanguageBundle } from "@/i18n/load-language-bundle";
const pendingLoads = new Map<AdminLanguage, Promise<void>>();
export function ensureAdminLanguageLoaded(i18n: I18nInstance, language: AdminLanguage): Promise<void> {
if (language === "zh" || i18n.hasResourceBundle(language, "common")) {
return Promise.resolve();
}
const existing = pendingLoads.get(language);
if (existing) {
return existing;
}
const load = loadAdminLanguageBundle(language).then((bundle) => {
for (const [namespace, data] of Object.entries(bundle)) {
i18n.addResourceBundle(language, namespace, data, true, true);
}
});
pendingLoads.set(language, load);
return load;
}
export function createAdminLanguageBackend(i18n: I18nInstance): BackendModule {
return {
type: "backend",
init() {},
read(language, namespace, callback) {
const lang = normalizeAdminLanguage(language);
void ensureAdminLanguageLoaded(i18n, lang)
.then(() => callback(null, i18n.getResourceBundle(lang, namespace) ?? {}))
.catch((error: Error) => callback(error, null));
},
};
}

45
src/i18n/bundles/en.ts Normal file
View File

@@ -0,0 +1,45 @@
import adminDocs from "@/i18n/locales/en/adminDocs.json";
import adminUsers from "@/i18n/locales/en/adminUsers.json";
import agents from "@/i18n/locales/en/agents.json";
import audit from "@/i18n/locales/en/audit.json";
import auth from "@/i18n/locales/en/auth.json";
import common from "@/i18n/locales/en/common.json";
import config from "@/i18n/locales/en/config.json";
import dashboard from "@/i18n/locales/en/dashboard.json";
import draws from "@/i18n/locales/en/draws.json";
import integrationDocs from "@/i18n/locales/en/integrationDocs.json";
import jackpot from "@/i18n/locales/en/jackpot.json";
import players from "@/i18n/locales/en/players.json";
import reconcile from "@/i18n/locales/en/reconcile.json";
import reports from "@/i18n/locales/en/reports.json";
import risk from "@/i18n/locales/en/risk.json";
import settlement from "@/i18n/locales/en/settlement.json";
import settlementCenter from "@/i18n/locales/en/settlementCenter.json";
import tickets from "@/i18n/locales/en/tickets.json";
import wallet from "@/i18n/locales/en/wallet.json";
import type { AdminI18nNamespace } from "@/i18n/namespaces";
const bundle = {
common,
auth,
dashboard,
audit,
draws,
settlement,
settlementCenter,
risk,
jackpot,
players,
tickets,
reconcile,
reports,
wallet,
adminUsers,
agents,
config,
integrationDocs,
adminDocs,
} satisfies Record<AdminI18nNamespace, Record<string, unknown>>;
export default bundle;

45
src/i18n/bundles/ne.ts Normal file
View File

@@ -0,0 +1,45 @@
import adminDocs from "@/i18n/locales/ne/adminDocs.json";
import adminUsers from "@/i18n/locales/ne/adminUsers.json";
import agents from "@/i18n/locales/ne/agents.json";
import audit from "@/i18n/locales/ne/audit.json";
import auth from "@/i18n/locales/ne/auth.json";
import common from "@/i18n/locales/ne/common.json";
import config from "@/i18n/locales/ne/config.json";
import dashboard from "@/i18n/locales/ne/dashboard.json";
import draws from "@/i18n/locales/ne/draws.json";
import integrationDocs from "@/i18n/locales/ne/integrationDocs.json";
import jackpot from "@/i18n/locales/ne/jackpot.json";
import players from "@/i18n/locales/ne/players.json";
import reconcile from "@/i18n/locales/ne/reconcile.json";
import reports from "@/i18n/locales/ne/reports.json";
import risk from "@/i18n/locales/ne/risk.json";
import settlement from "@/i18n/locales/ne/settlement.json";
import settlementCenter from "@/i18n/locales/ne/settlementCenter.json";
import tickets from "@/i18n/locales/ne/tickets.json";
import wallet from "@/i18n/locales/ne/wallet.json";
import type { AdminI18nNamespace } from "@/i18n/namespaces";
const bundle = {
common,
auth,
dashboard,
audit,
draws,
settlement,
settlementCenter,
risk,
jackpot,
players,
tickets,
reconcile,
reports,
wallet,
adminUsers,
agents,
config,
integrationDocs,
adminDocs,
} satisfies Record<AdminI18nNamespace, Record<string, unknown>>;
export default bundle;

45
src/i18n/bundles/zh.ts Normal file
View File

@@ -0,0 +1,45 @@
import adminDocs from "@/i18n/locales/zh/adminDocs.json";
import adminUsers from "@/i18n/locales/zh/adminUsers.json";
import agents from "@/i18n/locales/zh/agents.json";
import audit from "@/i18n/locales/zh/audit.json";
import auth from "@/i18n/locales/zh/auth.json";
import common from "@/i18n/locales/zh/common.json";
import config from "@/i18n/locales/zh/config.json";
import dashboard from "@/i18n/locales/zh/dashboard.json";
import draws from "@/i18n/locales/zh/draws.json";
import integrationDocs from "@/i18n/locales/zh/integrationDocs.json";
import jackpot from "@/i18n/locales/zh/jackpot.json";
import players from "@/i18n/locales/zh/players.json";
import reconcile from "@/i18n/locales/zh/reconcile.json";
import reports from "@/i18n/locales/zh/reports.json";
import risk from "@/i18n/locales/zh/risk.json";
import settlement from "@/i18n/locales/zh/settlement.json";
import settlementCenter from "@/i18n/locales/zh/settlementCenter.json";
import tickets from "@/i18n/locales/zh/tickets.json";
import wallet from "@/i18n/locales/zh/wallet.json";
import type { AdminI18nNamespace } from "@/i18n/namespaces";
const bundle = {
common,
auth,
dashboard,
audit,
draws,
settlement,
settlementCenter,
risk,
jackpot,
players,
tickets,
reconcile,
reports,
wallet,
adminUsers,
agents,
config,
integrationDocs,
adminDocs,
} satisfies Record<AdminI18nNamespace, Record<string, unknown>>;
export default bundle;

View File

@@ -9,142 +9,22 @@ import {
hydrateAdminUiLocale,
type AdminApiLocale,
} from "@/lib/admin-locale";
import enAudit from "@/i18n/locales/en/audit.json";
import enAdminUsers from "@/i18n/locales/en/adminUsers.json";
import enAuth from "@/i18n/locales/en/auth.json";
import enCommon from "@/i18n/locales/en/common.json";
import enConfig from "@/i18n/locales/en/config.json";
import enDashboard from "@/i18n/locales/en/dashboard.json";
import enDraws from "@/i18n/locales/en/draws.json";
import enJackpot from "@/i18n/locales/en/jackpot.json";
import enRisk from "@/i18n/locales/en/risk.json";
import enSettlement from "@/i18n/locales/en/settlement.json";
import enPlayers from "@/i18n/locales/en/players.json";
import enTickets from "@/i18n/locales/en/tickets.json";
import enReconcile from "@/i18n/locales/en/reconcile.json";
import enReports from "@/i18n/locales/en/reports.json";
import enWallet from "@/i18n/locales/en/wallet.json";
import enAgents from "@/i18n/locales/en/agents.json";
import enSettlementCenter from "@/i18n/locales/en/settlementCenter.json";
import enAdminDocs from "@/i18n/locales/en/adminDocs.json";
import enIntegrationDocs from "@/i18n/locales/en/integrationDocs.json";
import neAudit from "@/i18n/locales/ne/audit.json";
import neAdminUsers from "@/i18n/locales/ne/adminUsers.json";
import neAuth from "@/i18n/locales/ne/auth.json";
import neCommon from "@/i18n/locales/ne/common.json";
import neConfig from "@/i18n/locales/ne/config.json";
import neDashboard from "@/i18n/locales/ne/dashboard.json";
import neDraws from "@/i18n/locales/ne/draws.json";
import neJackpot from "@/i18n/locales/ne/jackpot.json";
import neRisk from "@/i18n/locales/ne/risk.json";
import neSettlement from "@/i18n/locales/ne/settlement.json";
import nePlayers from "@/i18n/locales/ne/players.json";
import neTickets from "@/i18n/locales/ne/tickets.json";
import neReconcile from "@/i18n/locales/ne/reconcile.json";
import neReports from "@/i18n/locales/ne/reports.json";
import neWallet from "@/i18n/locales/ne/wallet.json";
import neAgents from "@/i18n/locales/ne/agents.json";
import neSettlementCenter from "@/i18n/locales/ne/settlementCenter.json";
import neAdminDocs from "@/i18n/locales/ne/adminDocs.json";
import neIntegrationDocs from "@/i18n/locales/ne/integrationDocs.json";
import zhAudit from "@/i18n/locales/zh/audit.json";
import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json";
import zhAuth from "@/i18n/locales/zh/auth.json";
import zhCommon from "@/i18n/locales/zh/common.json";
import zhConfig from "@/i18n/locales/zh/config.json";
import zhDashboard from "@/i18n/locales/zh/dashboard.json";
import zhDraws from "@/i18n/locales/zh/draws.json";
import zhJackpot from "@/i18n/locales/zh/jackpot.json";
import zhRisk from "@/i18n/locales/zh/risk.json";
import zhSettlement from "@/i18n/locales/zh/settlement.json";
import zhPlayers from "@/i18n/locales/zh/players.json";
import zhTickets from "@/i18n/locales/zh/tickets.json";
import zhReconcile from "@/i18n/locales/zh/reconcile.json";
import zhReports from "@/i18n/locales/zh/reports.json";
import zhWallet from "@/i18n/locales/zh/wallet.json";
import zhAgents from "@/i18n/locales/zh/agents.json";
import zhSettlementCenter from "@/i18n/locales/zh/settlementCenter.json";
import zhAdminDocs from "@/i18n/locales/zh/adminDocs.json";
import zhIntegrationDocs from "@/i18n/locales/zh/integrationDocs.json";
import { createAdminLanguageBackend } from "@/i18n/backend";
import zhBundle from "@/i18n/bundles/zh";
import {
ADMIN_DEFAULT_LANGUAGE,
ADMIN_SUPPORTED_LANGUAGES,
normalizeAdminLanguage,
type AdminLanguage,
} from "@/i18n/languages";
import { ADMIN_I18N_NAMESPACES } from "@/i18n/namespaces";
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh";
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "settlementCenter", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "agents", "config", "integrationDocs", "adminDocs"] as const;
const resources = {
en: {
common: enCommon,
config: enConfig,
adminUsers: enAdminUsers,
auth: enAuth,
dashboard: enDashboard,
draws: enDraws,
jackpot: enJackpot,
players: enPlayers,
tickets: enTickets,
reconcile: enReconcile,
reports: enReports,
risk: enRisk,
audit: enAudit,
settlement: enSettlement,
wallet: enWallet,
agents: enAgents,
settlementCenter: enSettlementCenter,
integrationDocs: enIntegrationDocs,
adminDocs: enAdminDocs,
},
ne: {
common: neCommon,
config: neConfig,
adminUsers: neAdminUsers,
auth: neAuth,
dashboard: neDashboard,
draws: neDraws,
jackpot: neJackpot,
players: nePlayers,
tickets: neTickets,
reconcile: neReconcile,
reports: neReports,
risk: neRisk,
audit: neAudit,
settlement: neSettlement,
wallet: neWallet,
agents: neAgents,
settlementCenter: neSettlementCenter,
integrationDocs: neIntegrationDocs,
adminDocs: neAdminDocs,
},
zh: {
common: zhCommon,
config: zhConfig,
adminUsers: zhAdminUsers,
auth: zhAuth,
dashboard: zhDashboard,
draws: zhDraws,
jackpot: zhJackpot,
players: zhPlayers,
tickets: zhTickets,
reconcile: zhReconcile,
reports: zhReports,
risk: zhRisk,
audit: zhAudit,
settlement: zhSettlement,
wallet: zhWallet,
agents: zhAgents,
settlementCenter: zhSettlementCenter,
integrationDocs: zhIntegrationDocs,
adminDocs: zhAdminDocs,
},
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;
export function normalizeAdminLanguage(lang: string | undefined): AdminLanguage {
const base = lang?.split("-")[0]?.toLowerCase();
if (base === "ne") return "ne";
if (base === "zh") return "zh";
return "en";
}
export {
ADMIN_DEFAULT_LANGUAGE,
ADMIN_SUPPORTED_LANGUAGES,
normalizeAdminLanguage,
type AdminLanguage,
};
function getInitialAdminLanguage(): AdminLanguage {
// Keep SSR and first client render aligned to avoid hydration mismatch.
@@ -167,15 +47,19 @@ if (!i18n.isInitialized) {
const initialLanguage = getInitialAdminLanguage();
void i18n
.use(createAdminLanguageBackend(i18n))
.use(initReactI18next)
.init({
resources,
resources: {
zh: zhBundle,
},
lng: initialLanguage,
fallbackLng: ADMIN_DEFAULT_LANGUAGE,
supportedLngs: [...ADMIN_SUPPORTED_LANGUAGES],
defaultNS: "common",
ns: [...namespaces],
ns: [...ADMIN_I18N_NAMESPACES],
load: "languageOnly",
partialBundledLanguages: true,
initAsync: false,
interpolation: {
escapeValue: false,
@@ -183,13 +67,6 @@ if (!i18n.isInitialized) {
react: {
useSuspense: false,
},
})
.then(() => {
const lang = getInitialAdminLanguage();
if (normalizeAdminLanguage(i18n.language) !== lang) {
void i18n.changeLanguage(lang);
}
syncAdminLanguage(normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language));
});
syncAdminLanguage(initialLanguage);
@@ -198,4 +75,8 @@ if (!i18n.isInitialized) {
});
}
export function ensureAdminI18nReady(): Promise<void> {
return Promise.resolve();
}
export default i18n;

10
src/i18n/languages.ts Normal file
View File

@@ -0,0 +1,10 @@
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh";
export function normalizeAdminLanguage(lang: string | undefined): AdminLanguage {
const base = lang?.split("-")[0]?.toLowerCase();
if (base === "ne") return "ne";
if (base === "zh") return "zh";
return "en";
}

View File

@@ -0,0 +1,15 @@
import type { AdminLanguage } from "@/i18n/languages";
import type { AdminI18nNamespace } from "@/i18n/namespaces";
export type AdminLanguageBundle = Record<AdminI18nNamespace, Record<string, unknown>>;
const loaders: Record<AdminLanguage, () => Promise<{ default: AdminLanguageBundle }>> = {
zh: () => import("@/i18n/bundles/zh"),
en: () => import("@/i18n/bundles/en"),
ne: () => import("@/i18n/bundles/ne"),
};
export async function loadAdminLanguageBundle(language: AdminLanguage): Promise<AdminLanguageBundle> {
const mod = await loaders[language]();
return mod.default;
}

View File

@@ -46,6 +46,7 @@
"overview": {
"title": "Admin operations overview",
"description": "For your organization's super admins, site operators, and bound agent staff. This guide follows the actual admin menu and includes step-by-step instructions. For wallet-mode technical integration, see the API Integration Docs.",
"figureAlt": "Admin console capabilities overview diagram",
"loginNote": "Admin console: https://lotteryadmin.tanumo.com/admin (use the operations account assigned by your organization or our team)",
"scope": "System capabilities",
"scopeItems": [
@@ -122,6 +123,7 @@
"siteSetup": {
"title": "Integration sites (super admin)",
"description": "Wallet mode requires an integration site first. Save SSO and wallet secrets and have your technical team configure them on your main-site server. Credit-line agent line provisioning also creates a site.",
"figureAlt": "Integration site configuration diagram",
"path": "Create and configure",
"pathItems": [
"Sign in at https://lotteryadmin.tanumo.com/admin → Platform management → Integration sites",
@@ -153,6 +155,7 @@
"draws": {
"title": "Draws & results",
"description": "A draw is the basic betting unit. List times are shown in local timezone; the server stores UTC. Whether the hall accepts bets is determined live; the list shows database status, which may differ slightly from the player-side countdown.",
"figureAlt": "Draw status lifecycle diagram",
"lifecycle": "Draw statuses",
"statusRows": [
["Not started", "Before start time", "Edit, delete (no bets)"],
@@ -200,6 +203,7 @@
"settlementCenter": {
"title": "Settlement center (credit line)",
"description": "Manage agent settlement periods: open → close and generate bills → confirm → record payments. Bound agents can only view and operate bills for their own line. Wallet-mode players are not part of this module.",
"figureAlt": "Dual settlement systems diagram",
"entry": "Entry & permissions",
"entryItems": [
"Agent organization → Settlement center: period list",
@@ -264,6 +268,7 @@
"agents": {
"title": "Agent hierarchy",
"description": "The agent layer controls which data you can see and credit limits for downstream players; menu permissions still come from roles. Credit-line sites must maintain the agent tree before creating players under it.",
"figureAlt": "Agent organization and settlement periods diagram",
"structure": "Organization",
"structureItems": [
"Super admin: Agent organization → Agent lines → Provision line: create independent site + root node for external agents",
@@ -305,6 +310,7 @@
"players": {
"title": "Player management",
"description": "Manage wallet-mode and credit-mode players in one place. List and detail views show different balances and ledger tabs by funding mode.",
"figureAlt": "Wallet mode vs credit mode comparison diagram",
"list": "Player list",
"listItems": [
"Daily operations → Player list",
@@ -461,6 +467,7 @@
"fundOperations": {
"title": "Fund operations guide",
"description": "Explains how funds move during betting, draw settlement, and payment in wallet mode vs credit mode. Confirm the player's funding mode first, then check the matching ledger.",
"figureAlt": "Dual settlement systems diagram",
"twoSystems": "Two parallel systems (do not confuse them)",
"twoSystemsItems": [
"Per-draw settlement (Daily operations → Settlement): calculates win/loss for the draw; wallet mode pays out here, credit mode records release/period ledger here",
@@ -544,6 +551,7 @@
"manualReview": {
"title": "Manual review & payouts",
"description": "Covers draw result review, cooling period, per-draw payout settlement batches, and related system switches. This is separate from Settlement center credit-line periods.",
"figureAlt": "Draw review and payout flow diagram",
"distinction": "Difference from credit-line period settlement",
"distinctionItems": [
"This guide: per-draw payout settlement batches (Daily operations → Settlement) — wallet mode pays out, credit mode posts draw ledger; runs every draw",

View File

@@ -1,35 +1,25 @@
{
"title": "Reconcile",
"createTitle": "Create reconcile job",
"createDesc": "Manually check abnormal transfers by date range and optional player. Scheduled reconciliation still runs automatically.",
"scopeTitle": "Define the reconcile scope",
"scopeDescription": "Choose the business type and date range first, then decide whether to narrow it to one player.",
"createTitle": "Run reconcile scan",
"createHint": "Scans transfer orders in the selected period, compares lottery wallet ledgers, and checks main-site idempotent records when the wallet API is configured.",
"reconcileType": "Reconcile type",
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
"reconcileTypeHint": "Only wallet transfer is currently supported.",
"dateRange": "Reconcile date range",
"dateRangeHint": "Start with a shorter period to spot concentrated issues before widening the search.",
"createTask": "Create reconcile job",
"submitting": "Submitting…",
"createTask": "Start scan",
"submitting": "Scanning…",
"loadFailed": "Failed to load",
"loadItemsFailed": "Failed to load details",
"periodRequired": "Enter both reconcile start and end dates",
"periodInvalid": "Invalid date range",
"periodOrderInvalid": "End time must be later than or equal to start time",
"confirmCreateTitle": "Create reconcile job?",
"confirmCreateDescription": "Start a manual reconcile for the selected date range{{playerHint}}.",
"confirmCreateTitle": "Start reconcile scan?",
"confirmCreateDescription": "Scan transfer orders in the selected date range{{playerHint}} and generate discrepancy items.",
"confirmCreatePlayer": " for the selected player",
"confirmCreateAllPlayers": " (all players)",
"createSuccess": "Reconcile job created",
"createFailed": "Failed to create job",
"noCreatePermission": "Current account cannot create reconcile jobs.",
"playerScopeTitle": "Optionally narrow to one player",
"playerAllPlayersHint": "If no player is selected, the reconcile job will cover all players in the chosen date range.",
"createSummaryAll": "A manual reconcile will run for all players 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.",
"confirmCreateAllPlayers": " for all players",
"createSuccess": "Scan finished: {{count}} issue(s) found",
"createSuccessEmpty": "Scan finished: no issues found",
"createFailed": "Scan failed",
"noCreatePermission": "Current account cannot start reconcile scans.",
"jobsTitle": "Reconcile jobs",
"jobsDesc": "Use the action on the right to open discrepancy details and paginated results.",
"refresh": "Refresh",
"jobNo": "Job no.",
"type": "Type",
@@ -41,29 +31,21 @@
"finishedAt": "Finished at",
"createdAt": "Created at",
"operate": "Action",
"view": "View",
"viewDetails": "View discrepancy details",
"detailsTitle": "Discrepancy details",
"sideARef": "Lottery ref",
"sideBRef": "Main site ref",
"differenceAmount": "Difference (cent)",
"transferNo": "Transfer no.",
"walletTxnNo": "Lottery wallet txn",
"mainSiteRef": "Main-site ref",
"mainSiteCheck": "Main-site check",
"differenceAmount": "Difference (minor)",
"itemResult": "Check result",
"diagnosis": "Issue summary",
"suggestedAction": "Suggested action",
"processingStatus": "Processing status",
"quickAccess": "Quick access",
"actions": "Actions",
"openTransferOrder": "Open transfer order",
"openWalletTxn": "Open wallet ledger",
"detectedAt": "Detected at",
"noDetails": "No details",
"playerSearch": "Player (optional)",
"playerSearchPlaceholder": "Search by player ID / username / nickname",
"playerSearchHint": "After selection, reconciliation is limited to this player in the chosen date range.",
"playerSearchEmpty": "Enter a keyword to search players.",
"playerNoResults": "No matching players",
"playerChoose": "Choose",
"playerSelected": "Selected player",
"playerSelectedShort": "Selected",
"playerClear": "Clear",
"loadingPlayers": "Searching players…",
"statusCompleted": "Completed",
@@ -78,26 +60,13 @@
"itemUnexpectedWalletTxn": "Unexpected wallet ledger",
"itemMissingRefund": "Missing refund ledger",
"itemMissingReversal": "Missing reversal ledger",
"itemMainSiteRecordMissing": "Missing on main site",
"itemMainSiteFailed": "Failed on main site",
"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"
"mainSiteMatched": "Main site OK",
"mainSiteNotFound": "Not on main site",
"mainSiteFailed": "Main site failed",
"mainSiteUnavailable": "Main site unavailable",
"mainSiteSkipped": "Not checked"
}

View File

@@ -68,7 +68,11 @@
"empty": "No matching reports",
"backendPending": "This report is temporarily unavailable",
"filterPanel": "Filters",
"queryHint": "Set filters and run a query to preview and export.",
"queryHint": "Set filters and run a query to preview and export. When no period is selected, the last 30 days are used.",
"timeAxis": {
"businessDate": "Time axis: lottery business date (the draws business day).",
"recordCreatedAt": "Time axis: record creation time."
},
"query": "Query",
"querying": "Querying…",
"reset": "Reset",
@@ -283,9 +287,13 @@
"title": "Player win/loss report",
"summary": "Track player win/loss over a selected period for finance and support review."
},
"profit_reports": {
"disclaimer": "These reports use ticket bet/win amounts (betting results), not credit-line period settlement. For share, rebate, and collections, use Settlement Center → Period reports."
},
"player_transfer": {
"title": "Player transfer report",
"summary": "Review player transfers in, transfers out, reversals, and exception handling."
"summary": "Review player transfers in, transfers out, reversals, and exception handling.",
"disclaimer": "Main-site wallet transfer orders only (wallet-mode players). Credit-line players have no such transfers — use Settlement Center for period ledger."
},
"hot_number_risk": {
"title": "Hot number risk report",

View File

@@ -46,6 +46,7 @@
"overview": {
"title": "प्रशासन सञ्चालन अवलोकन",
"description": "तपाईंको सुपर एडमिन, साइट अपरेटर र बाँधिएका एजेन्ट खाताका लागि। यो मार्गदर्शन वास्तविक मेनु अनुसार; वालेट मोड प्राविधिक एकीकरण API कागजातमा छ।",
"figureAlt": "प्रशासन कन्सोल क्षमता अवलोकन चित्र",
"loginNote": "प्रशासन कन्सोल: https://lotteryadmin.tanumo.com/admin (तपाईंको वा हाम्रो तोकिएको खाता प्रयोग गर्नुहोस्)",
"scope": "प्रणाली क्षमता",
"scopeItems": [
@@ -122,6 +123,7 @@
"siteSetup": {
"title": "एकीकरण साइट (सुपर एडमिन)",
"description": "वालेट मोडमा पहिले साइट सिर्जना, SSO र वालेट कुञ्जी सुरक्षित राखेर तपाईंको टेक टिमले मुख्य साइटमा राख्नुपर्छ। क्रेडिट लाइन खोल्दा साइट पनि बन्छ।",
"figureAlt": "एकीकरण साइट कन्फिगरेसन चित्र",
"path": "सिर्जना र कन्फिग कदम",
"pathItems": [
"https://lotteryadmin.tanumo.com/admin मा लगइन → प्लेटफर्म → एकीकरण साइट",
@@ -153,6 +155,7 @@
"draws": {
"title": "ड्र र नतिजा",
"description": "ड्र भर्ना एकाइ हो। समय स्थानीय देखाइन्छ; सर्वर UTC। हल खुला/बन्द रियल-टाइम; सूची DB status देखाउँछ, काउन्टडाउनसँग अलिक फरक हुन सक्छ।",
"figureAlt": "ड्र स्थिति प्रवाह चित्र",
"lifecycle": "ड्र स्थिति",
"statusRows": [
["सुरु भएको छैन", "सुरु समय अघि", "सम्पादन, मेटाउने (टिकट छैन भने)"],
@@ -200,6 +203,7 @@
"settlementCenter": {
"title": "सेटलमेन्ट केन्द्र (क्रेडिट)",
"description": "एजेन्ट अवधि: खोल्ने → बन्द बिल → पुष्टि → भुक्तानी। बाँधिएका एजेन्टले आफ्नो लाइन बिल मात्र। वालेट खेलाडीमा यो मोड्युल छैन।",
"figureAlt": "दुई सेटलमेन्ट प्रणाली चित्र",
"entry": "प्रवेश र अनुमति",
"entryItems": [
"एजेन्ट संगठन → सेटलमेन्ट केन्द्र: अवधि सूची",
@@ -264,6 +268,7 @@
"agents": {
"title": "एजेन्ट प्रणाली",
"description": "एजेन्टले डाटा दायरा र क्रेडिट सीमा नियन्त्रण; मेनु अनुमति भूमिकाबाट। क्रेडिट साइटमा पहिले ट्री, त्यसपछि खेलाडी।",
"figureAlt": "एजेन्ट संगठन र अवधि चित्र",
"structure": "संरचना",
"structureItems": [
"सुपर एडमिन: एजेन्ट लाइन → लाइन खोल्ने: बाह्य एजेन्टलाई साइट + मूल",
@@ -305,6 +310,7 @@
"players": {
"title": "खेलाडी व्यवस्थापन",
"description": "वालेट र क्रेडिट खेलाडी एकै ठाउँ; सूची र विवरणमा कोष मोड अनुसार ब्यालेन्स र लेजर ट्याब।",
"figureAlt": "वालेट र क्रेडिट मोड तुलना चित्र",
"list": "खेलाडी सूची",
"listItems": [
"दैनिक सञ्चालन → खेलाडी सूची",
@@ -461,6 +467,7 @@
"fundOperations": {
"title": "कोष सञ्चालन विवरण",
"description": "वालेट र क्रेडिट मोडमा भर्ना, ड्र, भुक्तानीमा कोष कसरी बदलिन्छ। पहिले खेलाडी कोष मोड पुष्टि गर्नुहोस्, त्यसपछि सही लेजर हेर्नुहोस्।",
"figureAlt": "दुई सेटलमेन्ट प्रणाली चित्र",
"twoSystems": "दुई समानान्तर प्रणाली (नभुल्नुहोस्)",
"twoSystemsItems": [
"एकल ड्र सेटलमेन्ट (दैनिक → सेटलमेन्ट): ड्र जित/हार; वालेटमा पुरस्कार, क्रेडिटमा रिलिज/अवधि लेजर",
@@ -544,6 +551,7 @@
"manualReview": {
"title": "म्यानुअल समीक्षा र पुरस्कार",
"description": "ड्र नतिजा समीक्षा, शीतल अवधि, एकल ड्र पुरस्कार ब्याच र सेटिङ। सेटलमेन्ट केन्द्र क्रेडिट अवधिबाट अलग।",
"figureAlt": "ड्र समीक्षा र पुरस्कार प्रवाह चित्र",
"distinction": "क्रेडिट अवधि सेटलमेन्टबाट भिन्नता",
"distinctionItems": [
"यहाँ: एकल ड्र पुरस्कार ब्याच (दैनिक → सेटलमेन्ट); हरेक ड्रमा",

View File

@@ -1,31 +1,23 @@
{
"title": "मिलान",
"createTitle": "म्यानुअल मिलान कार्य",
"createDesc": "मिति दायरा र वैकल्पिक खेलाडी चयनबाट असामान्य ट्रान्सफर म्यानुअल रूपमा जाँच गर्नुहोस्। scheduled reconciliation स्वतः चलिरहन्छ।",
"scopeTitle": "पहिले मिलानको दायरा तय गर्नुहोस्",
"scopeDescription": "पहिले व्यवसाय प्रकार र मिति दायरा रोज्नुहोस्, त्यसपछि आवश्यक परे एक खेलाडीमा सीमित गर्नुहोस्।",
"reconcileType": "मिलान प्रकार",
"reconcileTypeFixed": "वालेट ट्रान्सफर (मुख्य साइट ⇄ लटरी)",
"reconcileTypeHint": "हाल वालेट ट्रान्सफर मात्र समर्थित छ।",
"dateRange": "मिलान मिति दायरा",
"dateRangeHint": "पहिले छोटो समयावधि रोजेर समस्या कहाँ केन्द्रित छ हेर्नुहोस्, त्यसपछि आवश्यक परे दायरा बढाउनुहोस्।",
"createTask": "मिलान कार्य सिर्जना",
"submitting": "पेश हुँदैछ…",
"loadFailed": "लोड असफल भयो",
"loadItemsFailed": "विवरण लोड असफल भयो",
"periodRequired": "सुरु र अन्त्य मिति दुवै लेख्नुहोस्",
"periodInvalid": "अवैध मिति दायरा",
"periodOrderInvalid": "अन्त्य समय सुरु समयभन्दा पछाडि वा बराबर हुनुपर्छ",
"confirmCreateTitle": "मिलान कार्य सिर्जना गर्ने?",
"confirmCreateDescription": "छनोट गरिएको मिति दायरामा{{playerHint}} म्यानुअल मिलान सुरु हुनेछ।",
"confirmCreatePlayer": " र छानिएको खेलाडीका लागि",
"confirmCreateAllPlayers": " (सबै खेलाडी)",
"createSuccess": "मिलान कार्य सिर्जना भयो",
"createFailed": "कार्य सिर्जना असफल भयो",
"noCreatePermission": "हालको खातासँग मिलान कार्य सिर्जना गर्ने अनुमति छैन।",
"playerScopeTitle": "आवश्यक परे एक खेलाडीमा सीमित गर्नुहोस्",
"playerAllPlayersHint": "खेलाडी नछानेमा, छनोट गरिएको मिति दायराभित्र सबै खेलाडीका लागि मिलान चलाइनेछ।",
"createSummaryAll": "{{from}} देखि {{to}} सम्म सबै खेलाडीका लागि म्यानुअल मिलान चलाइनेछ।",
"createSummaryPlayer": "खेलाडी {{player}} का लागि {{from}} देखि {{to}} सम्म म्यानुअल मिलान चलाइनेछ।",
"createSummaryPending": "कार्य सिर्जना गर्नु अघि पूरा मिलान मिति दायरा छान्नुहोस्।",
"jobsTitle": "मिलान कार्यहरू",
"jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।",
"refresh": "रिफ्रेस",
"jobNo": "कार्य नं.",
"type": "प्रकार",
@@ -37,21 +29,19 @@
"finishedAt": "समाप्त समय",
"createdAt": "सिर्जना समय",
"operate": "कार्य",
"view": "हेर्नुहोस्",
"detailsTitle": "कार्य विवरण",
"viewDetails": "असंगति विवरण हेर्नुहोस्",
"detailsTitle": "असंगति विवरण",
"sideARef": "लटरी साइड सन्दर्भ",
"sideBRef": "मुख्य साइट सन्दर्भ",
"differenceAmount": "अन्तर (cent)",
"itemResult": "जाँच नतिजा",
"processingStatus": "प्रक्रिया स्थिति",
"quickAccess": "छिटो पहुँच",
"openTransferOrder": "ट्रान्सफर अर्डर खोल्नुहोस्",
"openWalletTxn": "वालेट लेजर खोल्नुहोस्",
"detectedAt": "फेला परेको समय",
"noDetails": "विवरण छैन",
"playerSearch": "खेलाडी (वैकल्पिक)",
"playerSearchPlaceholder": "player ID / username / nickname बाट खोज्नुहोस्",
"playerSearchHint": "चयनपछि छनोट गरिएको मिति दायरामा सो खेलाडी मात्र मिलान हुन्छ।",
"playerSearchEmpty": "खेलाडी खोज्न कुञ्जी शब्द लेख्नुहोस्।",
"playerNoResults": "मिल्ने खेलाडी भेटिएन",
"playerChoose": "छान्नुहोस्",
"playerSelected": "छानिएको खेलाडी",
"playerSelectedShort": "छानियो",
"playerClear": "खाली गर्नुहोस्",
"loadingPlayers": "खेलाडी खोजिँदै…",
"statusCompleted": "सम्पन्न",
@@ -59,5 +49,13 @@
"statusFailed": "असफल",
"itemMismatch": "मेल खाएन",
"itemMatched": "मेल खायो",
"itemPendingCheck": "जाँच बाँकी"
"itemPendingCheck": "जाँच बाँकी",
"itemStaleProcessing": "लामो समय प्रक्रियामा",
"itemPendingReconcile": "म्यानुअल मिलान बाँकी",
"itemMissingWalletTxn": "वालेट लेजर छुट्यो",
"itemUnexpectedWalletTxn": "अनपेक्षित वालेट लेजर",
"itemMissingRefund": "फिर्ता लेजर छुट्यो",
"itemMissingReversal": "रिभर्सल लेजर छुट्यो",
"itemResolved": "समाधान भयो",
"itemUnresolved": "समाधान बाँकी"
}

View File

@@ -67,7 +67,11 @@
"empty": "मिल्ने रिपोर्ट छैन",
"backendPending": "यो रिपोर्ट अस्थायी रूपमा उपलब्ध छैन",
"filterPanel": "फिल्टर",
"queryHint": "फिल्टर सेट गरी क्वेरी चलाउनुहोस्।",
"queryHint": "फिल्टर सेट गरी क्वेरी चलाउनुहोस्। समय चयन नगरेमा पछिल्लो ३० दिन प्रयोग हुन्छ।",
"timeAxis": {
"businessDate": "समय अक्ष: लटरी व्यावसायिक मिति (ड्रको business date)।",
"recordCreatedAt": "समय अक्ष: रेकर्ड सिर्जना समय।"
},
"query": "क्वेरी",
"querying": "क्वेरी हुँदै…",
"reset": "रिसेट",
@@ -280,9 +284,13 @@
"title": "खेलाडी जित/हार रिपोर्ट",
"summary": "चयन गरिएको अवधिमा खेलाडीको जित/हार वित्त र सपोर्टका लागि हेर्नुहोस्।"
},
"profit_reports": {
"disclaimer": "यी रिपोर्ट टिकट बेट/जित रकम (बेटिङ नतिजा) मा आधारित छन्, क्रेडिट-लाइन अवधि सेटलमेन्ट होइन। शेयर, रिबेट र सङ्कलनका लागि सेटलमेन्ट सेन्टर → अवधि रिपोर्ट प्रयोग गर्नुहोस्।"
},
"player_transfer": {
"title": "खेलाडी ट्रान्सफर रिपोर्ट",
"summary": "खेलाडी ट्रान्सफर इन, आउट, रिभर्सल र अपवाद रेकर्ड हेर्नुहोस्।"
"summary": "खेलाडी ट्रान्सफर इन, आउट, रिभर्सल र अपवाद रेकर्ड हेर्नुहोस्।",
"disclaimer": "मुख्य साइट वालेट ट्रान्सफर मात्र (वालेट-मोड खेलाडी)। क्रेडिट-लाइन खेलाडीमा यस्तो ट्रान्सफर हुँदैन — अवधि लेजरका लागि सेटलमेन्ट सेन्टर प्रयोग गर्नुहोस्।"
},
"hot_number_risk": {
"title": "हट नम्बर जोखिम रिपोर्ट",

View File

@@ -46,6 +46,7 @@
"overview": {
"title": "后台运营总览",
"description": "面向贵司超级管理员、站点运营与代理经营人员。本手册按后台实际菜单组织,含逐步操作说明;钱包盘技术对接请参阅 API 对接文档。",
"figureAlt": "彩票后台管理系统总览示意图",
"loginNote": "后台登录https://lotteryadmin.tanumo.com/admin请使用贵司或我方分配的运营账号",
"scope": "系统能力",
"scopeItems": [
@@ -122,6 +123,7 @@
"siteSetup": {
"title": "接入站点(超管)",
"description": "钱包盘须先创建接入站点,保存 SSO 与钱包密钥,并由贵司技术写入主站服务端。信用盘代理线路开通时会同步创建站点。",
"figureAlt": "接入站点配置示意图",
"path": "创建与配置步骤",
"pathItems": [
"登录 https://lotteryadmin.tanumo.com/admin → 平台管理 →「接入站点」",
@@ -153,6 +155,7 @@
"draws": {
"title": "期号与开奖",
"description": "期号是下注的基本单位。列表时间按本地时区展示,服务器按 UTC 存储。大厅是否可下注由系统实时判定;列表展示数据库 status可能与玩家端倒计时略有差异。",
"figureAlt": "期号状态流转示意图",
"lifecycle": "期号状态",
"statusRows": [
["未开始", "尚未到达开始时间", "编辑、删除(无注单时)"],
@@ -200,6 +203,7 @@
"settlementCenter": {
"title": "结算中心(信用盘)",
"description": "管理代理账期:开账 → 关账生成账单 → 确认 → 登记收付。绑定代理仅可查看与操作本线相关账单。钱包盘玩家不涉及此模块。",
"figureAlt": "两套结算体系示意图",
"entry": "入口与权限",
"entryItems": [
"代理组织 →「结算中心」:账期列表",
@@ -264,6 +268,7 @@
"agents": {
"title": "代理体系",
"description": "代理层决定「能看哪些数据、能给下级多少额度」;菜单操作权限仍由角色控制。信用盘站点须先维护代理树,再在其下创建玩家。",
"figureAlt": "代理组织与账期示意图",
"structure": "组织结构",
"structureItems": [
"超管:代理组织 →「代理线路」→「开通线路」:为外部代理开通独立站点 + 根节点",
@@ -305,6 +310,7 @@
"players": {
"title": "玩家管理",
"description": "统一管理钱包盘与信用盘玩家;列表与详情会按资金模式展示不同余额与流水 Tab。",
"figureAlt": "钱包盘与信用盘对比示意图",
"list": "玩家列表",
"listItems": [
"日常运营 →「玩家列表」",
@@ -461,6 +467,7 @@
"fundOperations": {
"title": "资金操作详解",
"description": "说明钱包盘与信用盘在「下注、开奖、收付」各环节资金如何变动。请先确认玩家资金模式,再查对应流水。",
"figureAlt": "两套结算体系示意图",
"twoSystems": "两套并行体系(勿混淆)",
"twoSystemsItems": [
"单期开奖结算(日常运营 → 结算):计算本期注单输赢;钱包盘在此派彩入账,信用盘在此释额/记账期流水",
@@ -544,6 +551,7 @@
"manualReview": {
"title": "人工审核与派彩",
"description": "说明开奖结果审核、冷静期、单期派彩结算批次,以及相关系统开关。与「结算中心」信用账期是不同流程。",
"figureAlt": "开奖审核与派彩流程示意图",
"distinction": "与信用账期结算的区别",
"distinctionItems": [
"本文涵盖:单期开奖后的派彩结算批次(菜单:日常运营 → 结算)—— 钱包盘派彩入账、信用盘记开奖流水,每期开奖都会走",

View File

@@ -1,35 +1,25 @@
{
"title": "对账",
"createTitle": "人工发起对账",
"createDesc": "用于按日期范围并可选指定玩家,人工核对异常转账。系统定时对账仍会自动执行。",
"scopeTitle": "先定义对账范围",
"scopeDescription": "先确定要核对的业务类型和日期区间,再决定是否缩小到单个玩家。",
"createTitle": "发起对账扫描",
"createHint": "将扫描所选日期内的转账单,比对彩票钱包流水,并在已配置主站 API 时核对主站幂等记录。",
"reconcileType": "对账类型",
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
"reconcileTypeHint": "当前仅支持钱包划转。",
"dateRange": "对账日期范围",
"dateRangeHint": "建议优先选较短时间段,先看异常是否集中,再按需扩大范围。",
"createTask": "创建对账任务",
"submitting": "提交中…",
"createTask": "开始扫描",
"submitting": "扫描中…",
"loadFailed": "加载失败",
"loadItemsFailed": "加载明细失败",
"periodRequired": "请填写对账日期范围(开始与结束)",
"periodInvalid": "日期无效,请检查所选日期",
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
"confirmCreateTitle": "确认创建对账任务",
"confirmCreateDescription": "将所选日期范围{{playerHint}}发起人工对账。",
"confirmCreatePlayer": "指定玩家",
"confirmCreateAllPlayers": "(全量玩家",
"createSuccess": "已创建对账任务",
"createFailed": "创建失败",
"noCreatePermission": "当前账号无新建对账任务权限。",
"playerScopeTitle": "再决定是否指定玩家",
"playerAllPlayersHint": "不选择玩家时,会按日期范围对全量玩家做一次人工对账。",
"createSummaryAll": "将对 {{from}} 至 {{to}} 的全量玩家发起人工对账。",
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
"createSummaryPending": "请选择完整的对账日期范围后,再创建任务。",
"confirmCreateTitle": "确认发起对账扫描",
"confirmCreateDescription": "将扫描所选日期范围{{playerHint}}内的转账单,并自动生成差异明细。",
"confirmCreatePlayer": "指定玩家",
"confirmCreateAllPlayers": "内全部玩家",
"createSuccess": "扫描完成,发现 {{count}} 条异常",
"createSuccessEmpty": "扫描完成,未发现异常",
"createFailed": "扫描失败",
"noCreatePermission": "当前账号无发起对账扫描权限。",
"jobsTitle": "对账任务",
"jobsDesc": "在右侧操作中查看差异明细与分页结果。",
"refresh": "刷新",
"jobNo": "任务号",
"type": "类型",
@@ -41,29 +31,21 @@
"finishedAt": "完成时间",
"createdAt": "创建时间",
"operate": "操作",
"view": "查看",
"viewDetails": "查看差异明细",
"detailsTitle": "差异明细",
"sideARef": "彩票侧引用",
"sideBRef": "主站侧引用",
"transferNo": "转账单号",
"walletTxnNo": "彩票钱包流水号",
"mainSiteRef": "主站流水号",
"mainSiteCheck": "主站核对",
"differenceAmount": "差额(分)",
"itemResult": "检查结果",
"diagnosis": "异常说明",
"suggestedAction": "建议处理方向",
"processingStatus": "处理状态",
"quickAccess": "快捷处理",
"actions": "处理",
"openTransferOrder": "查看转账单",
"openWalletTxn": "查看钱包流水",
"detectedAt": "发现时间",
"noDetails": "无明细",
"playerSearch": "指定玩家(可选)",
"playerSearchPlaceholder": "输入玩家 ID / 用户名 / 昵称搜索",
"playerSearchHint": "选择后只按该玩家核对所选日期范围内的异常转账。",
"playerSearchEmpty": "请输入关键词后选择玩家。",
"playerNoResults": "暂无匹配玩家",
"playerChoose": "选择",
"playerSelected": "已选玩家",
"playerSelectedShort": "已选",
"playerClear": "清除",
"loadingPlayers": "玩家搜索中…",
"statusCompleted": "已完成",
@@ -78,26 +60,13 @@
"itemUnexpectedWalletTxn": "出现多余钱包流水",
"itemMissingRefund": "缺少退款流水",
"itemMissingReversal": "缺少冲正流水",
"itemMainSiteRecordMissing": "主站无对应记录",
"itemMainSiteFailed": "主站记录失败",
"itemResolved": "已处理",
"itemUnresolved": "未处理",
"diagnosisStaleProcessing": "转账单长时间停留在处理中,系统未拿到明确成功或失败结果。",
"diagnosisPendingReconcile": "转账单已被标记为待人工对账,需要人工确认主站与彩票侧最终结果。",
"diagnosisMissingWalletTxn": "转账单状态已推进,但彩票侧缺少对应钱包流水。",
"diagnosisUnexpectedWalletTxn": "彩票侧出现了与当前转账状态不匹配的额外钱包流水。",
"diagnosisMissingRefund": "转出失败后,应有退款流水回补,但当前未找到。",
"diagnosisMissingReversal": "转账单已冲正,但彩票侧缺少冲正流水。",
"diagnosisMatched": "该记录已对平,无需进一步处理。",
"diagnosisPendingCheck": "该记录需要继续人工确认。",
"actionStaleProcessing": "先核对主站是否已成功扣款,再查看转账单和钱包流水是否需要冲正或补记。",
"actionPendingReconcile": "优先打开转账单核对主站回执,再决定是补记入账、冲正,还是结案。",
"actionMissingWalletTxn": "打开转账单与钱包流水交叉核对,确认是否需要补记一笔钱包流水。",
"actionUnexpectedWalletTxn": "检查是否发生重复记账或错误回补,必要时按实际情况冲正。",
"actionMissingRefund": "确认主站侧失败后是否已退款;若已退款,补记彩票侧退款流水或冲正。",
"actionMissingReversal": "确认冲正是否在外部成功,再补记彩票侧冲正流水。",
"actionMatched": "无需处理。",
"actionPendingCheck": "请结合转账单与钱包流水继续核对。",
"actionResolved": "该异常已处理,当前转账单状态为:{{status}}。如需复核,请打开转账单查看处理结果。",
"transferStatusSuccess": "已成功",
"transferStatusReversed": "已冲正",
"transferStatusManual": "已结案"
"mainSiteMatched": "主站一致",
"mainSiteNotFound": "主站无记录",
"mainSiteFailed": "主站失败",
"mainSiteUnavailable": "主站不可查",
"mainSiteSkipped": "未核对"
}

View File

@@ -68,7 +68,11 @@
"empty": "没有匹配的报表",
"backendPending": "该报表暂不可用",
"filterPanel": "筛选条件",
"queryHint": "设置筛选条件后点击查询,可预览并导出。",
"queryHint": "设置筛选条件后点击查询,可预览并导出。未选时间段时默认查询近 30 天。",
"timeAxis": {
"businessDate": "时间口径:按彩票业务日(期号所属业务日)汇总。",
"recordCreatedAt": "时间口径:按记录创建时间筛选。"
},
"query": "查询",
"querying": "查询中…",
"reset": "重置",
@@ -283,9 +287,13 @@
"title": "玩家输赢报表",
"summary": "按玩家和时间段追踪输赢表现,适合客服与财务复核。"
},
"profit_reports": {
"disclaimer": "本组报表按注单下注/中奖金额统计投注结果,不等同于信用占成盘账期结算。占成、回水与收付请使用「结算中心 → 账期报表」。"
},
"player_transfer": {
"title": "玩家转入转出报表",
"summary": "集中查看玩家转入、转出、冲正和异常处理记录。"
"summary": "集中查看玩家转入、转出、冲正和异常处理记录。",
"disclaimer": "仅统计主站钱包划转单(钱包盘玩家)。信用盘玩家无此类转账,请使用结算中心查看账期账务。"
},
"hot_number_risk": {
"title": "热门号码风险报表",

23
src/i18n/namespaces.ts Normal file
View File

@@ -0,0 +1,23 @@
export const ADMIN_I18N_NAMESPACES = [
"common",
"auth",
"dashboard",
"audit",
"draws",
"settlement",
"settlementCenter",
"risk",
"jackpot",
"players",
"tickets",
"reconcile",
"reports",
"wallet",
"adminUsers",
"agents",
"config",
"integrationDocs",
"adminDocs",
] as const;
export type AdminI18nNamespace = (typeof ADMIN_I18N_NAMESPACES)[number];

View File

@@ -0,0 +1,10 @@
/** 后台运营手册配图(与 docs/images/admin-manual/manifest.json 对应) */
export const ADMIN_MANUAL_IMAGES = {
cover: "/docs/admin-manual/cover.png",
drawLifecycle: "/docs/admin-manual/01-draw-lifecycle.png",
walletVsCredit: "/docs/admin-manual/02-wallet-vs-credit.png",
settlementDual: "/docs/admin-manual/03-settlement-dual.png",
reviewPayout: "/docs/admin-manual/04-review-payout.png",
agentTree: "/docs/admin-manual/05-agent-tree.png",
integrationSite: "/docs/admin-manual/06-integration-site.png",
} as const;

View File

@@ -85,10 +85,6 @@ const ROUTE_PATTERNS: RoutePattern[] = [
test: (p) => /^\/admin\/draws\/\d+\/publish\/\d+$/.test(p),
resolve: () => ({ ns: "draws", key: "publishTitle" }),
},
{
test: (p) => /^\/admin\/draws\/\d+\/risk\/occupancy$/.test(p) || /^\/admin\/risk\/draws\/\d+\/occupancy$/.test(p),
resolve: () => ({ ns: "draws", key: "subnav.riskLockLogs" }),
},
{
test: (p) =>
/^\/admin\/draws\/\d+\/risk\/pools$/.test(p)

View File

@@ -48,7 +48,7 @@ export const PRD_RISK_MANAGE = "prd.risk.manage" as const;
export const PRD_ODDS_VIEW = "prd.odds.view" as const;
/** 钱包补单/冲正(冲正、补入账、手工结案等会影响资金状态的动作) */
export const PRD_WALLET_WRITE_ANY = [PRD_WALLET_ADJUST_MANAGE] as const;
export const PRD_WALLET_WRITE_ANY = [PRD_WALLET_ADJUST_MANAGE, PRD_WALLET_RECONCILE_MANAGE] as const;
/** 玩家列表页(与侧栏 requiredAny 一致) */
export const PRD_PLAYERS_ACCESS_ANY = [

View File

@@ -1,36 +1,24 @@
import enAdminDocs from "@/i18n/locales/en/adminDocs.json";
import enIntegrationDocs from "@/i18n/locales/en/integrationDocs.json";
import neAdminDocs from "@/i18n/locales/ne/adminDocs.json";
import neIntegrationDocs from "@/i18n/locales/ne/integrationDocs.json";
import zhAdminDocs from "@/i18n/locales/zh/adminDocs.json";
import zhIntegrationDocs from "@/i18n/locales/zh/integrationDocs.json";
import { normalizeAdminLanguage } from "@/i18n/languages";
const ADMIN_DEFAULT_LANGUAGE = "zh";
const NAV_RESOURCES = {
integrationDocs: {
zh: zhIntegrationDocs,
en: enIntegrationDocs,
ne: neIntegrationDocs,
},
adminDocs: {
zh: zhAdminDocs,
en: enAdminDocs,
ne: neAdminDocs,
},
const ZH_NAV_RESOURCES = {
integrationDocs: zhIntegrationDocs,
adminDocs: zhAdminDocs,
} as const;
/** SSR / i18n 未就绪时同步解析 nav 文案,避免侧栏 hydration 显示 nav.xxx */
/** SSR / i18n 未就绪时同步解析 nav 文案(默认中文),避免侧栏 hydration 显示 nav.xxx */
export function resolveDocsNavLabel(
key: string,
language: string = ADMIN_DEFAULT_LANGUAGE,
namespace: keyof typeof NAV_RESOURCES = "integrationDocs",
language: string = "zh",
namespace: keyof typeof ZH_NAV_RESOURCES = "integrationDocs",
): string {
const base = language.split("-")[0]?.toLowerCase();
const bucket =
base === "ne" ? NAV_RESOURCES[namespace].ne : base === "en" ? NAV_RESOURCES[namespace].en : NAV_RESOURCES[namespace].zh;
const lang = normalizeAdminLanguage(language);
if (lang !== "zh") {
return key;
}
let node: unknown = bucket;
let node: unknown = ZH_NAV_RESOURCES[namespace];
for (const part of key.split(".")) {
if (node === null || typeof node !== "object") {
return key;

View File

@@ -0,0 +1,44 @@
/** 转账单对账/补单动作是否可用(与后台 API can_* 字段对齐)。 */
export type TransferOrderActionRow = {
direction?: string;
status: string;
fail_reason?: string | null;
external_ref_no?: string | null;
can_reverse?: boolean;
can_complete_credit?: boolean;
can_manually_process?: boolean;
};
export function canReverseTransferOrder(row: TransferOrderActionRow, canWriteWallet: boolean): boolean {
return canWriteWallet && (row.can_reverse ?? row.status === "pending_reconcile");
}
export function canCompleteTransferInCredit(row: TransferOrderActionRow, canWriteWallet: boolean): boolean {
return (
canWriteWallet &&
(row.can_complete_credit ??
(row.direction === "in" &&
row.status === "pending_reconcile" &&
row.fail_reason === "lottery_credit_failed" &&
Boolean(row.external_ref_no?.trim())))
);
}
export function canManuallyProcessTransferOrder(row: TransferOrderActionRow, canWriteWallet: boolean): boolean {
return (
canWriteWallet &&
(row.can_manually_process ??
(["processing", "failed", "pending_reconcile"].includes(row.status) &&
!(row.direction === "out" && row.status === "pending_reconcile") &&
row.fail_reason !== "lottery_credit_failed"))
);
}
export function transferOrderHasReconcileAction(row: TransferOrderActionRow, canWriteWallet: boolean): boolean {
return (
canReverseTransferOrder(row, canWriteWallet) ||
canCompleteTransferInCredit(row, canWriteWallet) ||
canManuallyProcessTransferOrder(row, canWriteWallet)
);
}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import {
@@ -233,7 +234,7 @@ export function AgentLineDetailPanel({
<AdminSubnav
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
className="overflow-x-auto border-b border-border/60 px-4 sm:px-5"
className="min-h-11 overflow-x-auto border-b border-border/60 px-4 sm:px-5"
>
{tabs
.filter((tab) => tab.visible)
@@ -251,6 +252,10 @@ export function AgentLineDetailPanel({
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
{profileLoading && detailTab !== "overview" ? (
<AdminLoadingInline className="py-16" />
) : null}
{detailTab === "overview" ? (
<OverviewTab
profile={profile}
@@ -259,7 +264,7 @@ export function AgentLineDetailPanel({
/>
) : null}
{detailTab === "profile" && canViewProfileTab && profileFields ? (
{detailTab === "profile" && canViewProfileTab && profileFields && !profileLoading ? (
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm">
<CardHeader className="border-b border-border/60 pb-4">
<CardTitle className="text-base">
@@ -303,7 +308,7 @@ export function AgentLineDetailPanel({
</Card>
) : null}
{detailTab === "downline" && canViewDownlineTab ? (
{detailTab === "downline" && canViewDownlineTab && !profileLoading ? (
<DownlineTable
childAgents={childAgents}
childCountById={childCountById}
@@ -318,7 +323,7 @@ export function AgentLineDetailPanel({
/>
) : null}
{detailTab === "players" && canViewPlayersTab ? (
{detailTab === "players" && canViewPlayersTab && !profileLoading ? (
<AgentsPlayersPanel
siteCode={siteCode}
agentNodeId={node.id}
@@ -393,44 +398,49 @@ function OverviewTab({
/>
</div>
{!profileLoading && profile ? (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
value={`${rebateCap ?? "0"}%`}
value={profileLoading ? "…" : `${rebateCap ?? "0"}%`}
/>
<MetricCard
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
value={`${percentValueToUi(profile.default_player_rebate ?? 0)}%`}
value={
profileLoading ? "…" : `${percentValueToUi(profile?.default_player_rebate ?? 0)}%`
}
/>
<MetricCard
label={t("profile.riskTags", { defaultValue: "风控标签" })}
value={
(profile.risk_tags?.length ?? 0) > 0
? profile.risk_tags!.join(", ")
profileLoading
? "…"
: (profile?.risk_tags?.length ?? 0) > 0
? profile!.risk_tags!.join(", ")
: t("common:states.none", { defaultValue: "无" })
}
/>
<CapabilityMetric
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
enabled={profile.can_grant_extra_rebate === true}
enabled={profile?.can_grant_extra_rebate === true}
loading={profileLoading}
yesLabel={yesLabel}
noLabel={noLabel}
/>
<CapabilityMetric
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
enabled={profile.can_create_player !== false}
enabled={profile?.can_create_player !== false}
loading={profileLoading}
yesLabel={yesLabel}
noLabel={noLabel}
/>
<CapabilityMetric
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
enabled={profile.can_create_child_agent === true}
enabled={profile?.can_create_child_agent === true}
loading={profileLoading}
yesLabel={yesLabel}
noLabel={noLabel}
/>
</div>
) : null}
</div>
);
}
@@ -438,11 +448,13 @@ function OverviewTab({
function CapabilityMetric({
label,
enabled,
loading = false,
yesLabel,
noLabel,
}: {
label: string;
enabled: boolean;
loading?: boolean;
yesLabel: string;
noLabel: string;
}): React.ReactElement {
@@ -452,10 +464,10 @@ function CapabilityMetric({
<p
className={cn(
"mt-1.5 text-2xl font-semibold tracking-tight",
enabled ? "text-foreground" : "text-muted-foreground",
loading ? "text-muted-foreground" : enabled ? "text-foreground" : "text-muted-foreground",
)}
>
{enabled ? yesLabel : noLabel}
{loading ? "…" : enabled ? yesLabel : noLabel}
</p>
</div>
);

View File

@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
@@ -55,15 +56,6 @@ function pruneTreeForSearch(
return out;
}
function collectExpandableIds(nodes: AgentNodeRow[], into: Set<number>): void {
for (const node of nodes) {
if ((node.children?.length ?? 0) > 0) {
into.add(node.id);
collectExpandableIds(node.children ?? [], into);
}
}
}
export type AgentLineSidebarProps = {
siteLabel: string | null;
/** API 返回的嵌套树(含 children */
@@ -72,6 +64,7 @@ export type AgentLineSidebarProps = {
selectedId: number | null;
keyword: string;
agentCount: number;
loading?: boolean;
onKeywordChange: (value: string) => void;
onSelect: (node: AgentNodeRow) => void;
};
@@ -167,6 +160,7 @@ export function AgentLineSidebar({
selectedId,
keyword,
agentCount,
loading = false,
onKeywordChange,
onSelect,
}: AgentLineSidebarProps): React.ReactElement {
@@ -180,9 +174,20 @@ export function AgentLineSidebar({
}, [normalizedKeyword, parentNameMap, tree]);
useEffect(() => {
const next = new Set<number>();
collectExpandableIds(tree, next);
setExpandedIds(next);
if (tree.length === 0) {
setExpandedIds(new Set());
return;
}
setExpandedIds((prev) => {
const next = new Set(prev);
for (const node of tree) {
if ((node.children?.length ?? 0) > 0) {
next.add(node.id);
}
}
return next;
});
}, [tree]);
useEffect(() => {
@@ -258,7 +263,9 @@ export function AgentLineSidebar({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{!hasAnyAgent ? (
{loading ? (
<AdminLoadingInline className="py-10" />
) : !hasAnyAgent ? (
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
) : (
<ul className="space-y-0.5" role="listbox" aria-label={t("listTitle", { defaultValue: "代理列表" })}>

View File

@@ -464,8 +464,8 @@ export function AgentsConsole(): React.ReactElement {
const canShowDownlineTab = useMemo(
() =>
selectedNode !== null &&
!selectedProfileLoading &&
(isSiteAdmin ||
(selectedProfileLoading ||
isSiteAdmin ||
isSuperAdmin ||
selectedProfile?.can_create_child_agent === true),
[isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
@@ -474,11 +474,11 @@ export function AgentsConsole(): React.ReactElement {
const canShowPlayersTab = useMemo(
() =>
selectedNode !== null &&
!selectedProfileLoading &&
hasUsersManagePermission &&
(selectedProfileLoading ||
(hasUsersManagePermission &&
(isSiteAdmin ||
isSuperAdmin ||
selectedProfile?.can_create_player === true),
selectedProfile?.can_create_player === true))),
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
);
@@ -747,7 +747,19 @@ export function AgentsConsole(): React.ReactElement {
selectedProfileLoading,
]);
const showAgentSidebar = visibleAgentRows.length > 0;
const showAgentSidebar = loading || visibleAgentRows.length > 0;
const hasSiteContext =
siteOptions.length > 0 ||
profile?.site != null ||
(profile?.accessible_sites?.length ?? 0) > 0;
const isAgentLineBootLoading =
canViewAgents &&
(sitesLoading ||
profile === null ||
(hasSiteContext && adminSiteId === null) ||
(adminSiteId !== null && loading));
const openAddAgent = (): void => {
const parent = selectedNode ?? rootNode;
@@ -910,17 +922,12 @@ export function AgentsConsole(): React.ReactElement {
);
}
const hasSiteContext =
siteOptions.length > 0 ||
profile?.site != null ||
(profile?.accessible_sites?.length ?? 0) > 0;
if (canViewAgents && profile?.agent == null && !sitesLoading && !hasSiteContext) {
return <AdminNoIntegrationSiteState canCreate={isSuperAdmin} />;
}
if (canViewAgents && loading && tree.length === 0 && adminSiteId !== null) {
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
if (canViewAgents && isAgentLineBootLoading) {
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} minHeight="28rem" />;
}
const showSiteAdminAwaitingRoot =
@@ -980,6 +987,7 @@ export function AgentsConsole(): React.ReactElement {
selectedId={selectedNodeId}
keyword={keyword}
agentCount={visibleAgentRows.length}
loading={loading && visibleAgentRows.length === 0}
onKeywordChange={(value) => {
setKeyword(value);
}}

View File

@@ -170,9 +170,6 @@ export function RiskCapRuntimePanel() {
>
{t("filterSoldOut", { ns: "risk" })}
</Link>
<Link href={`${riskBase}/occupancy`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{t("subnav.riskLockLogs", { ns: "draws" })}
</Link>
</div>
) : null}
</div>

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import {
DocFigure,
DocList,
DocNote,
DocOrderedList,
@@ -12,6 +13,7 @@ import {
DocSection,
DocTable,
} from "@/components/docs/doc-ui";
import { ADMIN_MANUAL_IMAGES } from "@/lib/admin-manual-images";
import { useAdminDoc } from "@/modules/docs/admin/use-admin-doc";
export function AdminOverviewDocScreen(): React.ReactElement {
@@ -20,6 +22,7 @@ export function AdminOverviewDocScreen(): React.ReactElement {
return (
<DocPage>
<DocPageHeader title={p("title")} description={p("description")} />
<DocFigure src={ADMIN_MANUAL_IMAGES.cover} alt={p("figureAlt")} />
<DocNote>{p("loginNote")}</DocNote>
<DocSection title={p("scope")}>
<DocList items={list("scopeItems")} />
@@ -65,6 +68,7 @@ export function AdminSiteSetupDocScreen(): React.ReactElement {
return (
<DocPage>
<DocPageHeader title={p("title")} description={p("description")} />
<DocFigure src={ADMIN_MANUAL_IMAGES.integrationSite} alt={p("figureAlt")} />
<DocSection title={p("path")}>
<DocOrderedList items={list("pathItems")} />
</DocSection>
@@ -94,6 +98,7 @@ export function AdminDrawsDocScreen(): React.ReactElement {
<DocPage>
<DocPageHeader title={p("title")} description={p("description")} />
<DocSection title={p("lifecycle")}>
<DocFigure src={ADMIN_MANUAL_IMAGES.drawLifecycle} alt={p("figureAlt")} />
<DocTable compact headers={header("status")} rows={rows("statusRows")} />
</DocSection>
<DocSection title={p("workflow")}>
@@ -128,6 +133,7 @@ export function AdminSettlementCenterDocScreen(): React.ReactElement {
return (
<DocPage>
<DocPageHeader title={p("title")} description={p("description")} />
<DocFigure src={ADMIN_MANUAL_IMAGES.settlementDual} alt={p("figureAlt")} />
<DocSection title={p("entry")}>
<DocList items={list("entryItems")} />
</DocSection>
@@ -172,6 +178,7 @@ export function AdminAgentsDocScreen(): React.ReactElement {
return (
<DocPage>
<DocPageHeader title={p("title")} description={p("description")} />
<DocFigure src={ADMIN_MANUAL_IMAGES.agentTree} alt={p("figureAlt")} />
<DocSection title={p("structure")}>
<DocOrderedList items={list("structureItems")} />
</DocSection>
@@ -208,6 +215,7 @@ export function AdminPlayersDocScreen(): React.ReactElement {
<DocOrderedList items={list("freezeSteps")} />
</DocSection>
<DocSection title={p("modes")}>
<DocFigure src={ADMIN_MANUAL_IMAGES.walletVsCredit} alt={p("figureAlt")} />
<DocTable compact headers={header("module")} rows={rows("modeRows")} />
</DocSection>
<DocSection title={p("detail")}>
@@ -300,6 +308,7 @@ export function AdminFundOperationsDocScreen(): React.ReactElement {
<DocPage>
<DocPageHeader title={p("title")} description={p("description")} />
<DocSection title={p("twoSystems")}>
<DocFigure src={ADMIN_MANUAL_IMAGES.settlementDual} alt={p("figureAlt")} />
<DocList items={list("twoSystemsItems")} />
</DocSection>
<DocSection title={p("creditModel")}>
@@ -340,6 +349,7 @@ export function AdminManualReviewDocScreen(): React.ReactElement {
return (
<DocPage>
<DocPageHeader title={p("title")} description={p("description")} />
<DocFigure src={ADMIN_MANUAL_IMAGES.reviewPayout} alt={p("figureAlt")} />
<DocSection title={p("distinction")}>
<DocList items={list("distinctionItems")} />
</DocSection>

View File

@@ -1,13 +1,11 @@
"use client";
import Link from "next/link";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
@@ -20,7 +18,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { cn } from "@/lib/utils";
import { canManageDrawResults } from "@/lib/draw-access";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -76,22 +73,12 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">{t("resultsTitle")}</h2>
<p className="text-sm text-muted-foreground">
{t("drawNo")} {data.draw_no} · <DrawStatusBadge status={data.draw_status} />
</p>
</div>
{canManageDraw ? (
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("reviewAndPublish")}
</Link>
) : null}
</div>
{published.length === 0 ? (
<Card>

View File

@@ -15,13 +15,6 @@ const segments = [
{ suffix: "/results", key: "results", label: "subnav.results", requiresManage: false },
{ suffix: "/finance", key: "finance", label: "subnav.finance", requiresManage: false },
{ suffix: "/review", key: "review", label: "subnav.review", requiresManage: true },
{
suffix: "/risk/occupancy",
key: "riskLockLogs",
label: "subnav.riskLockLogs",
requiresManage: false,
requiresRisk: true,
},
{
suffix: "/risk/pools",
key: "riskPools",

View File

@@ -12,7 +12,6 @@ import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { buttonVariants } from "@/components/ui/button";
@@ -43,7 +42,6 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
import { Eye } from "lucide-react";
function playerStatusLabel(status: number, t: (key: string) => string): string {
if (status === 0) return t("statusNormal");
@@ -434,18 +432,22 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TableHead className="text-center">{t("winAmount", { ns: "tickets" })}</TableHead>
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
<TableHead>{t("updatedAt", { ns: "tickets" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-12 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("table.actions", { ns: "common" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ticketsLoading && tickets.length === 0 ? (
<AdminTableLoadingRow colSpan={11} />
<AdminTableLoadingRow colSpan={10} />
) : null}
{tickets.map((row) => (
<TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
<TableCell className="font-mono text-xs">
<Link
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
className="text-primary hover:underline"
>
{row.ticket_no}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
@@ -472,22 +474,10 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TableCell className="text-xs text-muted-foreground">
{row.updated_at ? formatDt(row.updated_at) : "—"}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{
key: "view-ticket-in-list",
label: t("viewTicketInList", { ns: "tickets" }),
icon: Eye,
href: `/admin/tickets?player_id=${player.id}&number=${encodeURIComponent(row.ticket_no)}${row.draw_no ? `&draw_no=${encodeURIComponent(row.draw_no)}` : ""}`,
},
]}
/>
</TableCell>
</TableRow>
))}
{!ticketsLoading && tickets.length === 0 ? (
<AdminTableNoResourceRow colSpan={11} className="text-muted-foreground" />
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>

View File

@@ -1,7 +1,6 @@
"use client";
import Link from "next/link";
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
import { Eye, ShieldAlert } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -13,13 +12,19 @@ import {
getAdminReconcileJobs,
postAdminReconcileJob,
} from "@/api/admin-reconcile";
import {
completeTransferInCredit,
manuallyProcessTransferOrder,
reverseTransferOrder,
} from "@/api/admin-wallet";
import { ReconcileItemActions } from "@/modules/reconcile/reconcile-item-actions";
import { getAdminPlayers } from "@/api/admin-player";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -36,6 +41,7 @@ import {
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -84,6 +90,10 @@ function itemStatusLabel(status: string, t: (key: string) => string): string {
return t("itemMissingRefund");
case "missing_reversal":
return t("itemMissingReversal");
case "main_site_record_missing":
return t("itemMainSiteRecordMissing");
case "main_site_failed":
return t("itemMainSiteFailed");
default:
return status;
}
@@ -98,6 +108,21 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
}
}
function mainSiteCheckLabel(status: string | null | undefined, t: (key: string) => string): string {
switch (status) {
case "matched":
return t("mainSiteMatched");
case "not_found":
return t("mainSiteNotFound");
case "failed_on_main":
return t("mainSiteFailed");
case "unavailable":
return t("mainSiteUnavailable");
default:
return t("mainSiteSkipped");
}
}
function itemResolutionLabel(
row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">,
t: (key: string) => string,
@@ -109,71 +134,6 @@ function itemResolutionTone(row: Pick<AdminReconcileItemsData["items"][number],
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
}
function itemDiagnosisLabel(status: string, t: (key: string) => string): string {
switch (status) {
case "stale_processing":
return t("diagnosisStaleProcessing");
case "pending_reconcile":
return t("diagnosisPendingReconcile");
case "missing_wallet_txn":
return t("diagnosisMissingWalletTxn");
case "unexpected_wallet_txn":
return t("diagnosisUnexpectedWalletTxn");
case "missing_refund":
return t("diagnosisMissingRefund");
case "missing_reversal":
return t("diagnosisMissingReversal");
case "matched":
return t("diagnosisMatched");
default:
return t("diagnosisPendingCheck");
}
}
function itemSuggestedAction(
row: Pick<AdminReconcileItemsData["items"][number], "status" | "resolved_at" | "is_resolved" | "current_transfer_status">,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
if (row.is_resolved === true || row.resolved_at) {
return t("actionResolved", {
status: row.current_transfer_status ? itemTransferStatusLabel(row.current_transfer_status, t) : t("statusCompleted"),
});
}
const status = row.status;
switch (status) {
case "stale_processing":
return t("actionStaleProcessing");
case "pending_reconcile":
return t("actionPendingReconcile");
case "missing_wallet_txn":
return t("actionMissingWalletTxn");
case "unexpected_wallet_txn":
return t("actionUnexpectedWalletTxn");
case "missing_refund":
return t("actionMissingRefund");
case "missing_reversal":
return t("actionMissingReversal");
case "matched":
return t("actionMatched");
default:
return t("actionPendingCheck");
}
}
function itemTransferStatusLabel(status: string, t: (key: string) => string): string {
switch (status) {
case "success":
return t("transferStatusSuccess");
case "reversed":
return t("transferStatusReversed");
case "manually_processed":
return t("transferStatusManual");
default:
return status;
}
}
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
const raw = summary?.[key];
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
@@ -194,6 +154,7 @@ export function ReconcileConsole(): React.ReactElement {
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
const formatTs = useAdminDateTimeFormatter();
const [jobs, setJobs] = useState<AdminReconcileJobListData | null>(null);
@@ -216,6 +177,7 @@ export function ReconcileConsole(): React.ReactElement {
const [playerLoading, setPlayerLoading] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
const [submitting, setSubmitting] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const loadJobs = useCallback(async () => {
setJobsLoading(true);
@@ -299,13 +261,17 @@ export function ReconcileConsole(): React.ReactElement {
setSubmitting(true);
try {
await postAdminReconcileJob({
const resp = await postAdminReconcileJob({
reconcile_type: RECONCILE_TYPE,
date_from: dateFrom,
date_to: dateTo,
player_id: selectedPlayer ? selectedPlayer.id : null,
});
toast.success(t("createSuccess"));
const count =
typeof resp.summary_json?.item_count === "number"
? resp.summary_json.item_count
: resp.item_count ?? 0;
toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty"));
setPage(1);
setDateFrom("");
setDateTo("");
@@ -320,13 +286,54 @@ export function ReconcileConsole(): React.ReactElement {
}
}
async function runTransferAction(
transferNo: string,
action: "reverse" | "complete_credit" | "manually_process",
): Promise<void> {
const confirmKey =
action === "reverse"
? "reverse"
: action === "complete_credit"
? "completeCredit"
: "markCaseClosed";
const successKey =
action === "reverse"
? "reverseSuccess"
: action === "complete_credit"
? "completeCreditSuccess"
: "markCaseClosedSuccess";
requestConfirm({
title: t(`confirm.${confirmKey}Title`, { ns: "wallet" }),
description: t(`confirm.${confirmKey}Description`, { ns: "wallet", transferNo }),
onConfirm: async () => {
setActionBusy(true);
try {
if (action === "reverse") {
await reverseTransferOrder(transferNo);
} else if (action === "complete_credit") {
await completeTransferInCredit(transferNo);
} else {
await manuallyProcessTransferOrder(transferNo);
}
toast.success(t(successKey, { ns: "wallet" }));
await loadItems();
await loadJobs();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { ns: "wallet" }));
} finally {
setActionBusy(false);
}
},
});
}
const jm = jobs?.meta;
const im = items?.meta;
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
const hasSelectedRange = dateFrom.trim() !== "" && dateTo.trim() !== "";
return (
<div className="flex w-full max-w-none flex-col gap-6">
@@ -334,24 +341,14 @@ export function ReconcileConsole(): React.ReactElement {
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("createHint")}</p>
</CardHeader>
<CardContent className="admin-list-content pt-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="rounded-xl border bg-muted/15 p-4">
<div className="mb-4 flex items-start gap-3">
<div className="rounded-lg bg-background p-2 text-muted-foreground">
<CalendarRange className="size-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{t("scopeTitle")}</div>
<p className="text-sm text-muted-foreground">{t("scopeDescription")}</p>
</div>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
<p className="text-xs text-muted-foreground">{t("reconcileTypeHint")}</p>
</div>
<div className="grid gap-1.5">
<AdminDateRangeField
@@ -364,22 +361,10 @@ export function ReconcileConsole(): React.ReactElement {
setDateTo(to);
}}
/>
<p className="text-xs text-muted-foreground">{t("dateRangeHint")}</p>
</div>
</div>
</div>
<div className="rounded-xl border bg-background p-4">
<div className="mb-4 flex items-start gap-3">
<div className="rounded-lg bg-muted/20 p-2 text-muted-foreground">
<UserRound className="size-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{t("playerScopeTitle")}</div>
<p className="text-sm text-muted-foreground">{t("playerSearchHint")}</p>
</div>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
<Input
@@ -391,16 +376,12 @@ export function ReconcileConsole(): React.ReactElement {
</div>
{selectedPlayer ? (
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-foreground">
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="min-w-0 truncate font-medium text-foreground">
{selectedPlayer.site_player_id}
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
</div>
<div className="truncate text-xs text-muted-foreground">
{t("playerSelected")} · {selectedPlayer.site_code}
</div>
{` · ${selectedPlayer.site_code}`}
</div>
<Button
type="button"
@@ -418,7 +399,7 @@ export function ReconcileConsole(): React.ReactElement {
) : null}
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
<div className="mt-4 rounded-lg border bg-background">
<div className="rounded-lg border bg-background">
<div className="max-h-56 overflow-y-auto">
{playerLoading ? (
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
@@ -433,25 +414,19 @@ export function ReconcileConsole(): React.ReactElement {
key={player.id}
type="button"
className={cn(
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
active && "bg-muted/30",
"flex w-full px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
active && "bg-muted/30 font-medium",
)}
onClick={() => {
setSelectedPlayer(player);
setPlayerSearch(player.site_player_id);
}}
>
<div className="min-w-0">
<div className="truncate font-medium text-foreground">
<span className="min-w-0 truncate">
{player.site_player_id}
{player.nickname ? ` · ${player.nickname}` : ""}
</div>
<div className="truncate text-xs text-muted-foreground">
{player.username ?? "—"} · {player.site_code}
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{active ? t("playerSelectedShort") : t("playerChoose")}
{player.username ? ` · ${player.username}` : ""}
{` · ${player.site_code}`}
</span>
</button>
);
@@ -460,31 +435,11 @@ export function ReconcileConsole(): React.ReactElement {
)}
</div>
</div>
) : (
<div className="mt-4 rounded-lg border border-dashed bg-muted/10 px-3 py-3 text-sm text-muted-foreground">
{t("playerAllPlayersHint")}
</div>
)}
) : null}
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3">
<div className="min-w-0 text-sm text-muted-foreground">
{hasSelectedRange
? selectedPlayer
? t("createSummaryPlayer", {
player: selectedPlayer.site_player_id,
from: dateFrom,
to: dateTo,
})
: t("createSummaryAll", {
from: dateFrom,
to: dateTo,
})
: t("createSummaryPending", {
defaultValue: "请选择完整的对账日期范围后,再创建任务。",
})}
</div>
<div className="mt-4 flex justify-end">
<Button
type="button"
className="w-full sm:w-auto"
@@ -696,14 +651,14 @@ export function ReconcileConsole(): React.ReactElement {
<TableHeader>
<TableRow>
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="min-w-[10rem]">{t("sideARef")}</TableHead>
<TableHead className="min-w-[10rem]">{t("sideBRef")}</TableHead>
<TableHead className="min-w-[10rem]">{t("transferNo")}</TableHead>
<TableHead className="min-w-[10rem]">{t("walletTxnNo")}</TableHead>
<TableHead className="min-w-[10rem]">{t("mainSiteRef")}</TableHead>
<TableHead className="w-28">{t("mainSiteCheck")}</TableHead>
<TableHead className="w-28 text-right">{t("differenceAmount")}</TableHead>
<TableHead className="w-32">{t("itemResult")}</TableHead>
<TableHead className="min-w-[16rem] whitespace-normal leading-snug">{t("diagnosis")}</TableHead>
<TableHead className="min-w-[16rem] whitespace-normal leading-snug">{t("suggestedAction")}</TableHead>
<TableHead className="w-28">{t("processingStatus")}</TableHead>
<TableHead className="w-32">{t("quickAccess")}</TableHead>
<TableHead className="min-w-[12rem]">{t("actions")}</TableHead>
<TableHead className="w-36">{t("detectedAt")}</TableHead>
</TableRow>
</TableHeader>
@@ -715,13 +670,18 @@ export function ReconcileConsole(): React.ReactElement {
<TableRow
key={r.id}
className={cn(
r.status === "mismatch" && "bg-amber-500/5",
r.status === "matched" && "bg-emerald-500/5",
r.is_resolved !== true && "bg-amber-500/5",
)}
>
<TableCell className="align-top">{r.id}</TableCell>
<TableCell className="align-top font-mono text-xs break-all">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="align-top font-mono text-xs break-all">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="align-top font-mono text-xs break-all">
{r.main_site_external_ref_no ?? r.external_ref_no ?? "—"}
</TableCell>
<TableCell className="align-top text-xs">
{mainSiteCheckLabel(r.main_site_check, t)}
</TableCell>
<TableCell className="align-top text-right tabular-nums">
<span
className={cn(
@@ -736,39 +696,20 @@ export function ReconcileConsole(): React.ReactElement {
{itemStatusLabel(r.status, t)}
</AdminStatusBadge>
</TableCell>
<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 className="align-top min-w-[12rem]">
<ReconcileItemActions
row={r}
canWriteWallet={canWriteWallet}
busy={actionBusy}
onCompleteCredit={(transferNo) => void runTransferAction(transferNo, "complete_credit")}
onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")}
onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")}
/>
</TableCell>
<TableCell className="align-top whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(r.created_at)}

View File

@@ -0,0 +1,97 @@
"use client";
import Link from "next/link";
import { RotateCcw, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { buttonVariants } from "@/components/ui/button";
import {
canCompleteTransferInCredit,
canManuallyProcessTransferOrder,
canReverseTransferOrder,
transferOrderHasReconcileAction,
} from "@/lib/wallet-transfer-actions";
import { cn } from "@/lib/utils";
import type { AdminReconcileItemRow } from "@/types/api/admin-reconcile";
type ReconcileItemActionsProps = {
row: AdminReconcileItemRow;
canWriteWallet: boolean;
busy: boolean;
onCompleteCredit: (transferNo: string) => void;
onReverse: (transferNo: string) => void;
onManualProcess: (transferNo: string) => void;
};
export function ReconcileItemActions({
row,
canWriteWallet,
busy,
onCompleteCredit,
onReverse,
onManualProcess,
}: ReconcileItemActionsProps): React.ReactElement {
const { t } = useTranslation(["reconcile", "wallet"]);
const transferNo = row.side_a_ref ?? "";
const actionRow = {
direction: row.transfer_direction ?? undefined,
status: row.current_transfer_status ?? row.status,
fail_reason: row.transfer_fail_reason,
external_ref_no: row.external_ref_no,
can_reverse: row.can_reverse,
can_complete_credit: row.can_complete_credit,
can_manually_process: row.can_manually_process,
};
return (
<div className="flex flex-wrap gap-2">
{transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet) ? (
<AdminRowActionsMenu
busy={busy}
actions={[
{
key: "complete",
label: t("completeCredit", { ns: "wallet" }),
hidden: !canCompleteTransferInCredit(actionRow, canWriteWallet),
onClick: () => onCompleteCredit(transferNo),
},
{
key: "manual",
label: t("markCaseClosed", { ns: "wallet" }),
icon: Wrench,
hidden: !canManuallyProcessTransferOrder(actionRow, canWriteWallet),
onClick: () => onManualProcess(transferNo),
},
{
key: "reverse",
label: t("reverse", { ns: "wallet" }),
icon: RotateCcw,
destructive: true,
hidden: !canReverseTransferOrder(actionRow, canWriteWallet),
onClick: () => onReverse(transferNo),
},
]}
/>
) : null}
{transferNo !== "" ? (
<Link
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
>
{t("openTransferOrder")}
</Link>
) : null}
{row.side_b_ref ? (
<Link
href={`/admin/wallet/transactions?txn_no=${encodeURIComponent(row.side_b_ref)}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
>
{t("openWalletTxn")}
</Link>
) : null}
{!transferNo && !row.side_b_ref ? <span></span> : null}
</div>
);
}

View File

@@ -70,7 +70,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
@@ -208,6 +208,68 @@ const emptyFilters: ReportFilters = {
dateTo: "",
};
function isoDateLocal(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function defaultReportPeriod(): Pick<ReportFilters, "dateFrom" | "dateTo"> {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 29);
return { dateFrom: isoDateLocal(from), dateTo: isoDateLocal(to) };
}
function createDefaultFilters(): ReportFilters {
return { ...emptyFilters, ...defaultReportPeriod() };
}
function reportHasPeriodField(report: ReportDefinition): boolean {
return report.fields.includes("period");
}
function resolveDisplayCurrency(apiCode?: string | null): string {
const trimmed = apiCode?.trim();
if (trimmed) {
return trimmed;
}
const fallback = getCachedAdminCurrencies().find((row) => row.is_default)?.code;
return fallback?.trim() || "NPR";
}
function reportTimeAxisKey(key: ReportKey): "businessDate" | "recordCreatedAt" | null {
switch (key) {
case "daily_profit":
case "player_win_loss":
case "play_dimension":
case "rebate_commission":
return "businessDate";
case "player_transfer":
case "admin_audit":
return "recordCreatedAt";
default:
return null;
}
}
function reportDisclaimerKey(key: ReportKey): string | null {
switch (key) {
case "draw_profit":
case "daily_profit":
case "player_win_loss":
case "play_dimension":
return "items.profit_reports.disclaimer";
case "player_transfer":
return "items.player_transfer.disclaimer";
case "rebate_commission":
return "items.rebate_commission.disclaimer";
default:
return null;
}
}
const emptySearch: SearchState = {
open: null,
query: "",
@@ -306,6 +368,7 @@ function buildDailyProfitRowsAndSummary(
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "rows" | "summary"> {
let totalBet = 0;
let totalPayout = 0;
@@ -327,11 +390,11 @@ function buildDailyProfitRowsAndSummary(
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
{
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(totalGross, "NPR"),
value: formatPlainMoney(totalGross, currencyCode),
tone: totalGross >= 0 ? "good" : "bad",
},
],
@@ -390,6 +453,7 @@ function buildPlayDimensionRowsAndSummary(
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
let totalBet = 0;
let totalPayout = 0;
@@ -411,8 +475,8 @@ function buildPlayDimensionRowsAndSummary(
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
],
};
}
@@ -422,6 +486,7 @@ function buildRebateCommissionRowsAndSummary(
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "rebate_commission" }>, "rows" | "summary"> {
let totalRebate = 0;
let totalOrders = 0;
@@ -442,7 +507,7 @@ function buildRebateCommissionRowsAndSummary(
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, "NPR") },
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, currencyCode) },
{ label: pageScopedLabel("orders"), value: String(totalOrders) },
],
};
@@ -654,7 +719,8 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const [selectedKey, setSelectedKey] = useState<ReportKey>(
filteredReports[0]?.key ?? REPORTS[0].key,
);
const [filters, setFilters] = useState<ReportFilters>(emptyFilters);
const [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
const [result, setResult] = useState<ReportResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -852,6 +918,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
});
const summary = await getAdminDrawFinanceSummary(draw.id);
setDisplayCurrency(resolveDisplayCurrency(summary.currency_code));
setResult({
key: "draw_profit",
raw: summary,
@@ -874,7 +941,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const payload = await getAdminReportDailyProfit(
reportListParams(filters, page, perPage),
);
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
setResult({
key: "daily_profit",
raw: payload.items,
@@ -888,6 +957,8 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const payload = await getAdminReportPlayerWinLoss(
reportListParams(filters, page, perPage),
);
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const rows = payload.items.map((item) => ({
player_id: item.player_id,
username: item.username,
@@ -907,7 +978,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
"NPR",
currencyCode,
),
tone: (() => {
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
@@ -937,6 +1008,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
t,
pageScopedLabel,
);
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
setResult({
key: "player_transfer",
raw: payload.items,
@@ -956,6 +1028,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
});
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
setDisplayCurrency(resolveDisplayCurrency(detail.currency_code));
const rows: ExportRow[] = [
{
row_type: "risk_pool",
@@ -1007,6 +1080,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
});
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
setDisplayCurrency(resolveDisplayCurrency(payload.currency_code));
const rows = payload.items.map((item) => ({
draw_id: payload.draw_id,
draw_no: payload.draw_no,
@@ -1038,7 +1112,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const payload = await getAdminReportPlayDimension(
reportListParams(filters, page, perPage),
);
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
setResult({
key: "play_dimension",
raw: payload.items,
@@ -1052,7 +1128,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const payload = await getAdminReportRebateCommission(
reportListParams(filters, page, perPage),
);
const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
setResult({
key: "rebate_commission",
raw: payload.items,
@@ -1149,7 +1227,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}
function resetFilters(): void {
setFilters(emptyFilters);
setFilters(reportHasPeriodField(selectedReport) ? createDefaultFilters() : { ...emptyFilters });
setResult(null);
setError(null);
setPage(1);
@@ -1521,9 +1599,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableRow key={item.normalized_number}>
<TableCell className="font-medium">{item.normalized_number}</TableCell>
<TableCell>{filters.drawNo}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_cap_amount, null)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_cap_amount, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, displayCurrency)}</TableCell>
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
<TableCell>v{item.version}</TableCell>
@@ -1536,10 +1614,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableRow key={item.business_date}>
<TableCell className="font-medium">{item.business_date}</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, "NPR")}>
{formatPlainMoney(item.approx_house_gross_minor, "NPR")}
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1556,10 +1634,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
{adminAgentDisplayLabel(item)}
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className={signedProfitCell(item.net_win_loss_minor, "NPR")}>
{formatPlainMoney(item.net_win_loss_minor, "NPR")}
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
<TableCell className={signedProfitCell(item.net_win_loss_minor, displayCurrency)}>
{formatPlainMoney(item.net_win_loss_minor, displayCurrency)}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1573,10 +1651,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableRow key={`${item.play_code}-${item.dimension}`}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.dimension}D</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, "NPR")}>
{formatPlainMoney(item.approx_house_gross_minor, "NPR")}
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1590,7 +1668,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableRow key={item.play_code}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.order_count}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, displayCurrency)}</TableCell>
<TableCell className="text-center">{item.ticket_item_count}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1648,6 +1726,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
})}
</div>
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
{reportTimeAxisKey(selectedReport.key) ? (
<p className="text-xs text-muted-foreground">{t(`timeAxis.${reportTimeAxisKey(selectedReport.key)}`)}</p>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
@@ -1655,7 +1736,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
{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="space-y-1 text-xs text-muted-foreground">
<div>{t("filterPanel")}</div>
<div>{t("queryHint")}</div>
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
{t("reset")}
@@ -1686,12 +1770,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
))}
</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:
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
})}
{reportDisclaimerKey(selectedReport.key) ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
{t(reportDisclaimerKey(selectedReport.key)!)}
</div>
) : null}

View File

@@ -209,7 +209,7 @@ export function RiskIndexConsole() {
key: "risk",
label: t("enterRisk"),
icon: Shield,
href: `/admin/draws/${row.id}/risk/occupancy`,
href: `/admin/draws/${row.id}/risk/pools`,
},
]}
/>

View File

@@ -1,317 +0,0 @@
"use client";
import Link from "next/link";
import { useCallback, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { riskActionTypeLabel, riskSourceReasonLabel } from "@/modules/risk/risk-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminRiskLockLogListData,
AdminRiskLockLogRow,
AdminRiskLockLogTicketRow,
} from "@/types/api/admin-risk";
const ACTION_ALL = "__all__";
const GROUP_TICKET = "ticket";
const GROUP_ENTRY = "entry";
function riskActionFilterLabel(value: string, t: (key: string) => string): string {
if (value === ACTION_ALL) {
return t("noLimit");
}
return riskActionTypeLabel(value, t);
}
function isTicketGroupData(
data: AdminRiskLockLogListData | null,
): data is AdminRiskLockLogListData & { group_by: "ticket"; items: AdminRiskLockLogTicketRow[] } {
return data?.group_by === "ticket";
}
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
const { t } = useTranslation(["risk", "common"]);
const tRef = useTranslationRef(["risk", "common"]);
const exportLabels = useExportLabels("riskLockLogs");
useAdminCurrencyCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const formatDt = useAdminDateTimeFormatter();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [data, setData] = useState<AdminRiskLockLogListData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draftNumber, setDraftNumber] = useState("");
const [appliedNumber, setAppliedNumber] = useState("");
const [draftAction, setDraftAction] = useState<string>(ACTION_ALL);
const [appliedAction, setAppliedAction] = useState<string>(ACTION_ALL);
const [draftGroupBy, setDraftGroupBy] = useState<"ticket" | "entry">(GROUP_TICKET);
const [appliedGroupBy, setAppliedGroupBy] = useState<"ticket" | "entry">(GROUP_TICKET);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const d = await getAdminRiskPoolLockLogs(drawId, {
page,
per_page: perPage,
group_by: appliedGroupBy,
normalized_number: appliedNumber.trim() === "" ? undefined : appliedNumber.trim(),
action_type:
appliedAction === ACTION_ALL ? undefined : (appliedAction as "lock" | "release"),
});
setData(d);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadLogsFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [drawId, page, perPage, appliedAction, appliedNumber, appliedGroupBy, tRef]);
useAsyncEffect(() => {
void load();
}, [drawId, page, perPage, appliedAction, appliedNumber, appliedGroupBy]);
const ticketGrouped = isTicketGroupData(data);
const currencyCode = data?.currency_code ?? "NPR";
return (
<Card className="admin-list-card">
<CardHeader className="admin-list-header space-y-1">
<CardTitle className="admin-list-title">{t("lockLogsTitle")}</CardTitle>
<CardDescription className="text-xs">{t("lockLogsGroupedHint")}</CardDescription>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="risk-log-group" className="sm:w-20 sm:shrink-0">
{t("groupBy")}
</Label>
<Select
modal={false}
value={draftGroupBy}
onValueChange={(v) => {
if (v === GROUP_TICKET || v === GROUP_ENTRY) setDraftGroupBy(v);
}}
>
<SelectTrigger id="risk-log-group" size="sm" className="h-8 w-full sm:w-44">
<SelectValue>
{draftGroupBy === GROUP_TICKET ? t("groupByTicket") : t("groupByEntry")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={GROUP_TICKET}>{t("groupByTicket")}</SelectItem>
<SelectItem value={GROUP_ENTRY}>{t("groupByEntry")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="admin-list-field">
<Label htmlFor="risk-log-number" className="sm:w-20 sm:shrink-0">
{t("number4d")}
</Label>
<Input
id="risk-log-number"
inputMode="numeric"
maxLength={4}
value={draftNumber}
className="h-8 w-full font-mono sm:w-32"
onChange={(e) => setDraftNumber(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t("optional")}
/>
</div>
<div className="admin-list-field">
<Label htmlFor="risk-log-action" className="sm:w-20 sm:shrink-0">
{t("actionFilter")}
</Label>
<Select
modal={false}
value={draftAction}
onValueChange={(v) => {
if (v) setDraftAction(v);
}}
>
<SelectTrigger id="risk-log-action" size="sm" className="h-8 w-full sm:w-40">
<SelectValue>{riskActionFilterLabel(draftAction, t)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTION_ALL}>{t("noLimit")}</SelectItem>
<SelectItem value="lock">{t("lock")}</SelectItem>
<SelectItem value="release">{t("release")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="admin-list-actions">
<AdminTableExportButton
tableId={`risk-lock-logs-table-${drawId}`}
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button
type="button"
size="sm"
onClick={() => {
setAppliedNumber(draftNumber);
setAppliedAction(draftAction);
setAppliedGroupBy(draftGroupBy);
setPage(1);
}}
>
{t("applyFilter")}
</Button>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="admin-table-shell">
<Table id={`risk-lock-logs-table-${drawId}`}>
<TableHeader>
{ticketGrouped ? (
<TableRow>
<TableHead>{t("time")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
<TableHead>{t("number")}</TableHead>
<TableHead className="text-center">{t("combinationCount")}</TableHead>
<TableHead className="text-center">{t("lockReleaseSummary")}</TableHead>
<TableHead className="text-center">{t("amount")}</TableHead>
<TableHead className="text-center">{t("viewDetail")}</TableHead>
</TableRow>
) : (
<TableRow>
<TableHead>{t("time")}</TableHead>
<TableHead>{t("searchNumber")}</TableHead>
<TableHead>{t("action")}</TableHead>
<TableHead className="text-center">{t("amount")}</TableHead>
<TableHead>{t("source")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
</TableRow>
)}
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={ticketGrouped ? 8 : 7} />
) : null}
{ticketGrouped
? data.items.map((row: AdminRiskLockLogTicketRow) => (
<TableRow key={row.ticket_item_id}>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{row.last_at ? formatDt(row.last_at) : "—"}
</TableCell>
<TableCell className="font-mono text-sm">{row.ticket_no}</TableCell>
<TableCell className="text-sm">{playCodeLabel(row.play_code)}</TableCell>
<TableCell className="font-mono text-sm">{row.original_number}</TableCell>
<TableCell className="text-center text-sm tabular-nums">
{row.combination_count}
{row.number_count !== row.combination_count ? (
<span className="text-muted-foreground"> / {row.number_count}</span>
) : null}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{t("lock")} {row.lock_entry_count} · {t("release")} {row.release_entry_count}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
<div>{formatAdminMinorUnits(row.total_lock_amount, currencyCode)}</div>
<div className="text-muted-foreground">
{formatAdminMinorUnits(row.total_release_amount, currencyCode)}
</div>
</TableCell>
<TableCell className="text-center">
<Link
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
className="text-xs font-medium text-primary hover:underline"
>
{t("viewTicket")}
</Link>
</TableCell>
</TableRow>
))
: (data?.items as AdminRiskLockLogRow[] | undefined)?.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{row.created_at ? formatDt(row.created_at) : "—"}
</TableCell>
<TableCell className="font-mono text-sm font-medium">
{row.normalized_number}
</TableCell>
<TableCell className="text-sm">
{riskActionTypeLabel(row.action_type, t)}
</TableCell>
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.amount, currencyCode)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{riskSourceReasonLabel(row.source_reason, t)}
</TableCell>
<TableCell className="font-mono text-sm">
{row.ticket_no ? (
<Link
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
className="text-primary hover:underline"
>
{row.ticket_no}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell className="text-sm">{playCodeLabel(row.play_code)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{data ? (
<AdminListPaginationFooter
selectId={`risk-logs-${drawId}-${appliedGroupBy}`}
total={data.meta.total}
page={data.meta.current_page}
lastPage={data.meta.last_page}
perPage={data.meta.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</CardContent>
</Card>
);
}

View File

@@ -1,52 +1,23 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import {
AdminSubnav,
AdminSubnavBar,
AdminSubnavLink,
adminSubnavItemClassName,
} from "@/components/admin/admin-subnav";
import { cn } from "@/lib/utils";
const segments = [
{ suffix: "/occupancy", key: "occupancy", label: "subnavOccupancy" },
{ suffix: "/pools", key: "pools", label: "subnavPools" },
] as const;
function isPoolsTabActive(pathname: string, base: string): boolean {
const poolsPrefix = `${base}/pools`;
return (
pathname === poolsPrefix
|| pathname.startsWith(`${poolsPrefix}/`)
|| pathname === `${base}/hot`
|| pathname === `${base}/sold-out`
);
}
export function RiskSubnav({ drawId }: { drawId: string }) {
const { t } = useTranslation("risk");
const pathname = usePathname();
const base = `/admin/draws/${drawId}/risk`;
const base = `/admin/draws/${drawId}/risk/pools`;
return (
<AdminSubnavBar className="mb-6">
<AdminSubnav aria-label={t("subnavLabel", { defaultValue: "风控导航" })}>
{segments.map(({ suffix, key, label }) => {
const href = `${base}${suffix}`;
const active =
key === "pools" ? isPoolsTabActive(pathname, base) : pathname === href;
return (
<AdminSubnavLink key={key} href={href} active={active}>
{t(label)}
</AdminSubnavLink>
);
})}
</AdminSubnav>
<Link href={base} className={cn(adminSubnavItemClassName(true))}>
{t("subnavPools")}
</Link>
<Link
href="/admin/draws"
className={cn(adminSubnavItemClassName(false), "text-muted-foreground")}

View File

@@ -42,6 +42,7 @@ import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
import { ChevronDown, Eye } from "lucide-react";
import Link from "next/link";
/** 与玩家端、注项表 status 字段对齐(不含无效的 success */
const TICKET_STATUS_OPTIONS = [
@@ -98,6 +99,10 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
return t("statusSelectedCount", { count: statuses.length });
}
function ticketDetailPath(ticketNo: string): string {
return `/admin/tickets/${encodeURIComponent(ticketNo)}`;
}
function TicketFilterField({
id,
label,
@@ -372,7 +377,14 @@ export function PlayerTicketsConsole(): React.ReactElement {
: row.win_amount_formatted;
return (
<TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
<TableCell className="font-mono text-xs">
<Link
href={ticketDetailPath(row.ticket_no)}
className="text-primary hover:underline"
>
{row.ticket_no}
</Link>
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell className="text-xs">
@@ -401,14 +413,8 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableCell className="text-xs">{formatTs(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",
label: t("viewTicketDetail"),
icon: Eye,
href: `/admin/tickets/${encodeURIComponent(row.ticket_no)}`,
},
...(row.player_id
actions={
row.player_id
? [
{
key: "view-player",
@@ -417,8 +423,8 @@ export function PlayerTicketsConsole(): React.ReactElement {
href: adminPlayerDetailPath(row.player_id),
},
]
: []),
]}
: []
}
/>
</TableCell>
</TableRow>

View File

@@ -48,6 +48,11 @@ import {
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd";
import {
canCompleteTransferInCredit,
canManuallyProcessTransferOrder,
canReverseTransferOrder,
} from "@/lib/wallet-transfer-actions";
import { useAdminProfile } from "@/stores/admin-session";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
@@ -251,51 +256,6 @@ function walletAdminSelectDisplayedLabel(
return key ? (t ? t(key) : key) : v;
}
function canReverseTransferOrder(
row: { status: string; can_reverse?: boolean },
canWriteWallet: boolean,
): boolean {
return canWriteWallet && (row.can_reverse ?? row.status === "pending_reconcile");
}
function canCompleteTransferInCredit(
row: {
direction: string;
status: string;
fail_reason?: string | null;
external_ref_no?: string | null;
can_complete_credit?: boolean;
},
canWriteWallet: boolean,
): boolean {
return (
canWriteWallet &&
(row.can_complete_credit ??
(row.direction === "in" &&
row.status === "pending_reconcile" &&
row.fail_reason === "lottery_credit_failed" &&
Boolean(row.external_ref_no?.trim())))
);
}
function canManuallyProcessTransferOrder(
row: {
direction?: string;
status: string;
fail_reason?: string | null;
can_manually_process?: boolean;
},
canWriteWallet: boolean,
): boolean {
return (
canWriteWallet &&
(row.can_manually_process ??
(["processing", "failed", "pending_reconcile"].includes(row.status) &&
!(row.direction === "out" && row.status === "pending_reconcile") &&
row.fail_reason !== "lottery_credit_failed"))
);
}
type TransferOrderRowActionsProps = {
row: AdminTransferOrderItem;
canWriteWallet: boolean;

View File

@@ -33,11 +33,20 @@ export type AdminReconcileItemRow = {
id: number;
side_a_ref: string | null;
side_b_ref: string | null;
external_ref_no?: string | null;
difference_amount: number;
status: string;
resolved_at: string | null;
is_resolved?: boolean;
current_transfer_status?: string | null;
transfer_direction?: string | null;
transfer_fail_reason?: string | null;
main_site_check?: string | null;
main_site_check_message?: string | null;
main_site_external_ref_no?: string | null;
can_reverse?: boolean;
can_complete_credit?: boolean;
can_manually_process?: boolean;
created_at: string | null;
};

View File

@@ -39,6 +39,8 @@ export type AdminReportListData<T> = {
total: number;
last_page: number;
};
currency_code?: string | null;
disclaimer?: string | null;
};
export type AdminReportQueryParams = {

View File

@@ -24,43 +24,6 @@ export type AdminRiskPoolListData = {
meta: AdminRiskPoolListMeta;
};
export type AdminRiskLockLogRow = {
id: number;
normalized_number: string;
action_type: string;
amount: number;
source_reason: string | null;
ticket_item_id: number | null;
ticket_no: string | null;
play_code: string | null;
player_id: number | null;
created_at: string | null;
};
export type AdminRiskLockLogTicketRow = {
ticket_item_id: number;
ticket_no: string;
play_code: string;
original_number: string;
combination_count: number;
player_id: number;
number_count: number;
lock_entry_count: number;
release_entry_count: number;
total_lock_amount: number;
total_release_amount: number;
last_at: string | null;
};
export type AdminRiskLockLogListData = {
draw_id: number;
draw_no: string;
currency_code: string | null;
group_by: "ticket" | "entry";
items: AdminRiskLockLogRow[] | AdminRiskLockLogTicketRow[];
meta: AdminRiskPoolListMeta;
};
export type AdminRiskPoolDetailLogRow = {
id: number;
action_type: string;