refactor: 合并多语言支持的显示名称字段,优化奖池手动爆发功能的返回数据结构,增强管理端权限控制
This commit is contained in:
@@ -28,9 +28,7 @@ export async function patchAdminPlayType(
|
|||||||
body: Partial<{
|
body: Partial<{
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
display_name_zh: string | null;
|
display_name: string | null;
|
||||||
display_name_en: string | null;
|
|
||||||
display_name_ne: string | null;
|
|
||||||
supports_multi_number: boolean;
|
supports_multi_number: boolean;
|
||||||
reserved_rule_json: unknown;
|
reserved_rule_json: unknown;
|
||||||
}>,
|
}>,
|
||||||
@@ -64,9 +62,7 @@ export async function putPlayConfigItems(
|
|||||||
category: string | null;
|
category: string | null;
|
||||||
dimension: number | null;
|
dimension: number | null;
|
||||||
bet_mode: string | null;
|
bet_mode: string | null;
|
||||||
display_name_zh: string;
|
display_name: string;
|
||||||
display_name_en?: string | null;
|
|
||||||
display_name_ne?: string | null;
|
|
||||||
is_enabled?: boolean;
|
is_enabled?: boolean;
|
||||||
min_bet_amount: number;
|
min_bet_amount: number;
|
||||||
max_bet_amount: number;
|
max_bet_amount: number;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { adminRequest } from "@/lib/admin-http";
|
|||||||
import { API_V1_PREFIX } from "./paths";
|
import { API_V1_PREFIX } from "./paths";
|
||||||
|
|
||||||
import type { AdminDashboardData } from "@/types/api/admin-dashboard";
|
import type { AdminDashboardData } from "@/types/api/admin-dashboard";
|
||||||
|
import type {
|
||||||
|
AdminDashboardAnalyticsData,
|
||||||
|
AdminDashboardAnalyticsQuery,
|
||||||
|
} from "@/types/api/admin-dashboard-analytics";
|
||||||
|
|
||||||
const A = `${API_V1_PREFIX}/admin`;
|
const A = `${API_V1_PREFIX}/admin`;
|
||||||
|
|
||||||
@@ -10,3 +14,30 @@ const A = `${API_V1_PREFIX}/admin`;
|
|||||||
export async function getAdminDashboard(): Promise<AdminDashboardData> {
|
export async function getAdminDashboard(): Promise<AdminDashboardData> {
|
||||||
return adminRequest.get<AdminDashboardData>(`${A}/dashboard`);
|
return adminRequest.get<AdminDashboardData>(`${A}/dashboard`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仪表盘可筛选分析(区间汇总、日趋势、玩法拆解) */
|
||||||
|
export async function getAdminDashboardAnalytics(
|
||||||
|
query: AdminDashboardAnalyticsQuery = {},
|
||||||
|
): Promise<AdminDashboardAnalyticsData> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.period) {
|
||||||
|
params.set("period", query.period);
|
||||||
|
}
|
||||||
|
if (query.date_from) {
|
||||||
|
params.set("date_from", query.date_from);
|
||||||
|
}
|
||||||
|
if (query.date_to) {
|
||||||
|
params.set("date_to", query.date_to);
|
||||||
|
}
|
||||||
|
if (query.metric) {
|
||||||
|
params.set("metric", query.metric);
|
||||||
|
}
|
||||||
|
if (query.play_code) {
|
||||||
|
params.set("play_code", query.play_code);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
|
||||||
|
return adminRequest.get<AdminDashboardAnalyticsData>(
|
||||||
|
`${A}/dashboard/analytics${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,8 +35,15 @@ export async function putAdminJackpotPool(
|
|||||||
|
|
||||||
export async function postAdminJackpotManualBurst(
|
export async function postAdminJackpotManualBurst(
|
||||||
poolId: number,
|
poolId: number,
|
||||||
body: { draw_id: number; amount?: number },
|
body: { draw_id: number },
|
||||||
): Promise<{ current_amount: number; burst_amount: number; log_id: number | null }> {
|
): Promise<{
|
||||||
|
current_amount: number;
|
||||||
|
burst_amount: number;
|
||||||
|
log_id: number | null;
|
||||||
|
winner_count: number;
|
||||||
|
draw_no: string;
|
||||||
|
wallet_credited: boolean;
|
||||||
|
}> {
|
||||||
return adminRequest.post(`${A}/jackpot/pools/${poolId}/manual-burst`, body);
|
return adminRequest.post(`${A}/jackpot/pools/${poolId}/manual-burst`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,11 @@ export async function putAdminPlayer(
|
|||||||
export async function deleteAdminPlayer(playerId: number): Promise<AdminPlayerDeleteResult> {
|
export async function deleteAdminPlayer(playerId: number): Promise<AdminPlayerDeleteResult> {
|
||||||
return adminRequest.delete<AdminPlayerDeleteResult>(`${A}/players/${playerId}`);
|
return adminRequest.delete<AdminPlayerDeleteResult>(`${A}/players/${playerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function postAdminPlayerFreeze(playerId: number): Promise<AdminPlayerRow> {
|
||||||
|
return adminRequest.post<AdminPlayerRow>(`${A}/players/${playerId}/freeze`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAdminPlayerUnfreeze(playerId: number): Promise<AdminPlayerRow> {
|
||||||
|
return adminRequest.post<AdminPlayerRow>(`${A}/players/${playerId}/unfreeze`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import { AccountSettingsConsole } from "@/modules/account/account-settings-console";
|
import { AccountSettingsConsole } from "@/modules/account/account-settings-console";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = buildPageMetadata("common", "accountSettings");
|
||||||
title: "账号设置 - 管理后台",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminAccountPage() {
|
export default function AdminAccountPage() {
|
||||||
return <AccountSettingsConsole />;
|
return <AccountSettingsConsole />;
|
||||||
|
|||||||
12
src/app/admin/(shell)/draws/[drawId]/risk/layout.tsx
Normal file
12
src/app/admin/(shell)/draws/[drawId]/risk/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
|
|
||||||
|
export default function AdminDrawRiskLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AdminPermissionGate requiredAny={PRD_RISK_ACCESS_ANY}>{children}</AdminPermissionGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { PRD_JACKPOT_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { JackpotConfigScreen } from "@/modules/jackpot/jackpot-config-screen";
|
import { JackpotConfigScreen } from "@/modules/jackpot/jackpot-config-screen";
|
||||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
@@ -10,9 +12,11 @@ export const metadata: Metadata = buildPageMetadata("jackpot", "configTitle");
|
|||||||
export default function AdminJackpotPage() {
|
export default function AdminJackpotPage() {
|
||||||
return (
|
return (
|
||||||
<RulesPageShell>
|
<RulesPageShell>
|
||||||
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}>
|
<AdminPermissionGate requiredAny={PRD_JACKPOT_ACCESS_ANY}>
|
||||||
<JackpotConfigScreen />
|
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}>
|
||||||
</Suspense>
|
<JackpotConfigScreen />
|
||||||
|
</Suspense>
|
||||||
|
</AdminPermissionGate>
|
||||||
</RulesPageShell>
|
</RulesPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
|
import { PRD_DASHBOARD_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
|
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("dashboard", "title");
|
|||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold className="max-w-7xl">
|
<ModuleScaffold className="max-w-7xl">
|
||||||
<DashboardConsole />
|
<AdminPermissionGate requiredAny={PRD_DASHBOARD_ACCESS_ANY}>
|
||||||
|
<DashboardConsole />
|
||||||
|
</AdminPermissionGate>
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
|
import { PRD_PLAYERS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { PlayersConsole } from "@/modules/players/players-console";
|
import { PlayersConsole } from "@/modules/players/players-console";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("players", "title");
|
|||||||
export default function AdminPlayersPage() {
|
export default function AdminPlayersPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold>
|
<ModuleScaffold>
|
||||||
<PlayersConsole />
|
<AdminPermissionGate requiredAny={PRD_PLAYERS_ACCESS_ANY}>
|
||||||
|
<PlayersConsole />
|
||||||
|
</AdminPermissionGate>
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
|
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import { ReportsConsole } from "@/modules/reports/reports-console";
|
import { ReportsConsole } from "@/modules/reports/reports-console";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("reports", "title");
|
|||||||
export default function AdminReportsPage() {
|
export default function AdminReportsPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold>
|
<ModuleScaffold>
|
||||||
<ReportsConsole />
|
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
|
||||||
|
<ReportsConsole />
|
||||||
|
</AdminPermissionGate>
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { PRD_RISK_CAP_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen";
|
import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen";
|
||||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
@@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("config", "nav.riskCapTitle"
|
|||||||
export default function AdminRiskCapPage() {
|
export default function AdminRiskCapPage() {
|
||||||
return (
|
return (
|
||||||
<RulesPageShell>
|
<RulesPageShell>
|
||||||
<RiskCapDocScreen />
|
<AdminPermissionGate requiredAny={PRD_RISK_CAP_ACCESS_ANY}>
|
||||||
|
<RiskCapDocScreen />
|
||||||
|
</AdminPermissionGate>
|
||||||
</RulesPageShell>
|
</RulesPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
|
import { PRD_TICKETS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { PlayerTicketsConsole } from "@/modules/tickets/player-tickets-console";
|
import { PlayerTicketsConsole } from "@/modules/tickets/player-tickets-console";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("tickets", "title");
|
|||||||
export default function AdminTicketsPage() {
|
export default function AdminTicketsPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold>
|
<ModuleScaffold>
|
||||||
<PlayerTicketsConsole />
|
<AdminPermissionGate requiredAny={PRD_TICKETS_ACCESS_ANY}>
|
||||||
|
<PlayerTicketsConsole />
|
||||||
|
</AdminPermissionGate>
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { PRD_WALLET_PLAYER_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { PlayerWalletPanel } from "@/modules/wallet/wallet-console";
|
import { PlayerWalletPanel } from "@/modules/wallet/wallet-console";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -5,5 +7,9 @@ import type { Metadata } from "next";
|
|||||||
export const metadata: Metadata = buildPageMetadata("wallet", "playerWalletQuery");
|
export const metadata: Metadata = buildPageMetadata("wallet", "playerWalletQuery");
|
||||||
|
|
||||||
export default function AdminWalletPlayerPage() {
|
export default function AdminWalletPlayerPage() {
|
||||||
return <PlayerWalletPanel />;
|
return (
|
||||||
|
<AdminPermissionGate requiredAny={PRD_WALLET_PLAYER_ACCESS_ANY}>
|
||||||
|
<PlayerWalletPanel />
|
||||||
|
</AdminPermissionGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { PRD_WALLET_TX_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { WalletTxnsPanel } from "@/modules/wallet/wallet-console";
|
import { WalletTxnsPanel } from "@/modules/wallet/wallet-console";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -5,5 +7,9 @@ import type { Metadata } from "next";
|
|||||||
export const metadata: Metadata = buildPageMetadata("wallet", "walletTransactions");
|
export const metadata: Metadata = buildPageMetadata("wallet", "walletTransactions");
|
||||||
|
|
||||||
export default function AdminWalletTransactionsPage() {
|
export default function AdminWalletTransactionsPage() {
|
||||||
return <WalletTxnsPanel />;
|
return (
|
||||||
|
<AdminPermissionGate requiredAny={PRD_WALLET_TX_ACCESS_ANY}>
|
||||||
|
<WalletTxnsPanel />
|
||||||
|
</AdminPermissionGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { PRD_WALLET_TRANSFER_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { TransferOrdersPanel } from "@/modules/wallet/wallet-console";
|
import { TransferOrdersPanel } from "@/modules/wallet/wallet-console";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -5,5 +7,9 @@ import type { Metadata } from "next";
|
|||||||
export const metadata: Metadata = buildPageMetadata("wallet", "transferOrders");
|
export const metadata: Metadata = buildPageMetadata("wallet", "transferOrders");
|
||||||
|
|
||||||
export default function AdminWalletTransferOrdersPage() {
|
export default function AdminWalletTransferOrdersPage() {
|
||||||
return <TransferOrdersPanel />;
|
return (
|
||||||
|
<AdminPermissionGate requiredAny={PRD_WALLET_TRANSFER_ACCESS_ANY}>
|
||||||
|
<TransferOrdersPanel />
|
||||||
|
</AdminPermissionGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,11 +67,12 @@
|
|||||||
--border: #d8e6fb;
|
--border: #d8e6fb;
|
||||||
--input: #d8e6fb;
|
--input: #d8e6fb;
|
||||||
--ring: #7aa7ee;
|
--ring: #7aa7ee;
|
||||||
--chart-1: oklch(0.87 0 0);
|
/* 仪表盘 / 图表:彩色序列(勿用 chroma=0 灰阶) */
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-1: oklch(0.52 0.19 264);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-2: oklch(0.62 0.17 162);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-3: oklch(0.72 0.16 75);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-4: oklch(0.56 0.22 303);
|
||||||
|
--chart-5: oklch(0.58 0.2 25);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: #01266c;
|
--sidebar: #01266c;
|
||||||
--sidebar-foreground: #f8fbff;
|
--sidebar-foreground: #f8fbff;
|
||||||
@@ -102,11 +103,11 @@
|
|||||||
--border: rgb(148 180 220 / 24%);
|
--border: rgb(148 180 220 / 24%);
|
||||||
--input: rgb(148 180 220 / 28%);
|
--input: rgb(148 180 220 / 28%);
|
||||||
--ring: #77a7ff;
|
--ring: #77a7ff;
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.7 0.14 264);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.72 0.13 162);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.78 0.13 75);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.68 0.17 303);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.7 0.16 25);
|
||||||
--sidebar: #01266c;
|
--sidebar: #01266c;
|
||||||
--sidebar-foreground: #f8fbff;
|
--sidebar-foreground: #f8fbff;
|
||||||
--sidebar-primary: #e60012;
|
--sidebar-primary: #e60012;
|
||||||
|
|||||||
44
src/components/admin/admin-page-card.tsx
Normal file
44
src/components/admin/admin-page-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AdminPageCardProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
/** 页内锚点(如 #records) */
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 与列表/运营台一致的 admin-list-card 外层,用于设置、奖池等多区块页。 */
|
||||||
|
export function AdminPageCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
id,
|
||||||
|
}: AdminPageCardProps) {
|
||||||
|
return (
|
||||||
|
<Card id={id} className={cn("admin-list-card scroll-mt-24", className)}>
|
||||||
|
<CardHeader
|
||||||
|
className={cn(
|
||||||
|
"admin-list-header",
|
||||||
|
actions != null && "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<CardTitle className="admin-list-title">{title}</CardTitle>
|
||||||
|
{description ? <CardDescription className="text-sm">{description}</CardDescription> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={cn("admin-list-content", contentClassName)}>{children}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/admin/admin-page-section.tsx
Normal file
30
src/components/admin/admin-page-section.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { AdminSectionHeader } from "@/components/admin/admin-section-header";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AdminPageSectionProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 无 Card 的页内分区,标题样式与 ConfigSection 相同。 */
|
||||||
|
export function AdminPageSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
}: AdminPageSectionProps) {
|
||||||
|
return (
|
||||||
|
<section id={id} className={cn("scroll-mt-24 space-y-4", className)}>
|
||||||
|
<AdminSectionHeader title={title} description={description} actions={actions} />
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/admin/admin-permission-gate.tsx
Normal file
40
src/components/admin/admin-permission-gate.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
|
type AdminPermissionGateProps = {
|
||||||
|
requiredAny: readonly string[];
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 深链进入无权限页面时展示拒绝说明,避免空白或反复 403。 */
|
||||||
|
export function AdminPermissionGate({
|
||||||
|
requiredAny,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: AdminPermissionGateProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const allowed = adminHasAnyPermission(profile?.permissions, [...requiredAny]);
|
||||||
|
|
||||||
|
if (allowed) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className ?? "admin-list-card"}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t("permission.deniedTitle")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("permission.deniedDescription")}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/admin/admin-section-header.tsx
Normal file
33
src/components/admin/admin-section-header.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type AdminSectionHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 页内区块标题(与 ConfigSection 一致),用于 Card 内子分区或配置文档。 */
|
||||||
|
export function AdminSectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: AdminSectionHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-start justify-between gap-3 border-b border-border/60 pb-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { adminNavIconBySegment } from "@/modules/_config/admin-nav-icons";
|
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
|
||||||
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export function AdminAppSidebar() {
|
|||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{visibleNav.map((item) => {
|
{visibleNav.map((item) => {
|
||||||
const Icon = adminNavIconBySegment[item.segment];
|
const Icon = resolveAdminNavIcon(item.segment);
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.segment}>
|
<SidebarMenuItem key={item.segment}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
|
|||||||
63
src/components/admin/confirm-action-dialog.tsx
Normal file
63
src/components/admin/confirm-action-dialog.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
type ConfirmActionDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
confirmVariant?: "default" | "destructive";
|
||||||
|
busy?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfirmActionDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
confirmVariant = "destructive",
|
||||||
|
busy = false,
|
||||||
|
onConfirm,
|
||||||
|
}: ConfirmActionDialogProps) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={confirmVariant}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{busy ? t("actions.submitting") : confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
UserRoundIcon,
|
UserRoundIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -67,11 +66,12 @@ export function ShellToolbar() {
|
|||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem
|
||||||
<Link href="/admin/account" className="flex items-center gap-2 cursor-pointer">
|
className="flex cursor-pointer items-center gap-2"
|
||||||
<UserRoundIcon className="size-4" />
|
onClick={() => router.push("/admin/account")}
|
||||||
{t("toolbar.accountSettings")}
|
>
|
||||||
</Link>
|
<UserRoundIcon className="size-4" />
|
||||||
|
{t("toolbar.accountSettings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
67
src/hooks/use-confirm-action.tsx
Normal file
67
src/hooks/use-confirm-action.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ConfirmActionDialog } from "@/components/admin/confirm-action-dialog";
|
||||||
|
|
||||||
|
export type ConfirmActionRequest = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
confirmVariant?: "default" | "destructive";
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useConfirmAction() {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const [pending, setPending] = useState<ConfirmActionRequest | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const request = useCallback((req: ConfirmActionRequest) => {
|
||||||
|
setPending(req);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
if (!busy) {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}, [busy]);
|
||||||
|
|
||||||
|
const confirm = useCallback(async () => {
|
||||||
|
if (!pending || busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await pending.onConfirm();
|
||||||
|
setPending(null);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [pending, busy]);
|
||||||
|
|
||||||
|
const ConfirmDialog = useCallback(
|
||||||
|
() => (
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={pending !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
dismiss();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={pending?.title ?? ""}
|
||||||
|
description={pending?.description ?? ""}
|
||||||
|
confirmLabel={pending?.confirmLabel ?? t("confirm.confirm")}
|
||||||
|
cancelLabel={pending?.cancelLabel ?? t("confirm.cancel")}
|
||||||
|
confirmVariant={pending?.confirmVariant ?? "destructive"}
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => void confirm()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[pending, busy, dismiss, confirm, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { request, dismiss, busy, ConfirmDialog };
|
||||||
|
}
|
||||||
@@ -110,7 +110,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;
|
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;
|
||||||
|
|
||||||
function normalizeAdminLanguage(lang: string | undefined): AdminLanguage {
|
export function normalizeAdminLanguage(lang: string | undefined): AdminLanguage {
|
||||||
const base = lang?.split("-")[0]?.toLowerCase();
|
const base = lang?.split("-")[0]?.toLowerCase();
|
||||||
if (base === "ne") return "ne";
|
if (base === "ne") return "ne";
|
||||||
if (base === "zh") return "zh";
|
if (base === "zh") return "zh";
|
||||||
|
|||||||
@@ -159,6 +159,7 @@
|
|||||||
"prd.rebate.view": "Commission/Rebate · View",
|
"prd.rebate.view": "Commission/Rebate · View",
|
||||||
"prd.jackpot.manage": "Jackpot Configuration · Manage",
|
"prd.jackpot.manage": "Jackpot Configuration · Manage",
|
||||||
"prd.jackpot.view": "Jackpot Configuration · View",
|
"prd.jackpot.view": "Jackpot Configuration · View",
|
||||||
|
"prd.jackpot.manual_burst": "Jackpot Manual Burst · Super Admin Only",
|
||||||
"prd.payout.manage": "Payout Confirmation · Manage",
|
"prd.payout.manage": "Payout Confirmation · Manage",
|
||||||
"prd.payout.review": "Payout Confirmation · Review",
|
"prd.payout.review": "Payout Confirmation · Review",
|
||||||
"prd.payout.view": "Payout Confirmation · View",
|
"prd.payout.view": "Payout Confirmation · View",
|
||||||
|
|||||||
@@ -26,7 +26,31 @@
|
|||||||
"createTask": "Create task",
|
"createTask": "Create task",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
"exportExcel": "Export Excel"
|
"exportExcel": "Export Excel",
|
||||||
|
"save": "Save changes",
|
||||||
|
"updateSuccess": "Updated successfully",
|
||||||
|
"updateFailed": "Update failed",
|
||||||
|
"updatePassword": "Update password"
|
||||||
|
},
|
||||||
|
"accountSettings": "Account settings",
|
||||||
|
"accountSettingsDesc": "Manage your profile and security settings.",
|
||||||
|
"profileSettings": "Profile",
|
||||||
|
"profileSettingsDesc": "Update your display name.",
|
||||||
|
"securitySettings": "Security",
|
||||||
|
"securitySettingsDesc": "Change your login password. Leave blank if you are not changing it.",
|
||||||
|
"fields": {
|
||||||
|
"nickname": "Nickname",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm password"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"nickname": "Enter nickname",
|
||||||
|
"password": "Enter new password",
|
||||||
|
"confirmPassword": "Re-enter new password"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "{{field}} is required",
|
||||||
|
"passwordMismatch": "Passwords do not match"
|
||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
@@ -59,7 +83,16 @@
|
|||||||
"date": {
|
"date": {
|
||||||
"placeholder": "Select date",
|
"placeholder": "Select date",
|
||||||
"rangePlaceholder": "Select date range",
|
"rangePlaceholder": "Select date range",
|
||||||
"rangeHint": "Select a start date, then an end date. For a single day, click the same date twice. Click Done to close."
|
"rangeHint": "Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.",
|
||||||
|
"weekdays": {
|
||||||
|
"sunday": "Sunday",
|
||||||
|
"monday": "Monday",
|
||||||
|
"tuesday": "Tuesday",
|
||||||
|
"wednesday": "Wednesday",
|
||||||
|
"thursday": "Thursday",
|
||||||
|
"friday": "Friday",
|
||||||
|
"saturday": "Saturday"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"perPage": "Per page",
|
"perPage": "Per page",
|
||||||
@@ -76,6 +109,10 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Failed to load"
|
"loadFailed": "Failed to load"
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"deniedTitle": "Access denied",
|
||||||
|
"deniedDescription": "Your account does not have permission to open this page. Ask an administrator to assign the required role permissions."
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"id": "ID"
|
"id": "ID"
|
||||||
},
|
},
|
||||||
@@ -98,6 +135,7 @@
|
|||||||
"draws": "Draws",
|
"draws": "Draws",
|
||||||
"rules_plays": "Play rules",
|
"rules_plays": "Play rules",
|
||||||
"rules_odds": "Odds & rebate",
|
"rules_odds": "Odds & rebate",
|
||||||
|
"rules": "Betting rules",
|
||||||
"risk_cap": "Risk cap rules",
|
"risk_cap": "Risk cap rules",
|
||||||
"risk": "Risk center",
|
"risk": "Risk center",
|
||||||
"settlement": "Settlement",
|
"settlement": "Settlement",
|
||||||
@@ -105,12 +143,18 @@
|
|||||||
"reconcile": "Reconcile",
|
"reconcile": "Reconcile",
|
||||||
"tickets": "Ticket list",
|
"tickets": "Ticket list",
|
||||||
"audit": "Audit Logs",
|
"audit": "Audit Logs",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"account": "Account settings"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "Checking sign-in status…"
|
"checking": "Checking sign-in status…"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"confirmSave": "Save"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,9 @@
|
|||||||
"outMin": "Per-order minimum from lottery wallet to main wallet",
|
"outMin": "Per-order minimum from lottery wallet to main wallet",
|
||||||
"outMax": "Per-order maximum from lottery wallet to main wallet"
|
"outMax": "Per-order maximum from lottery wallet to main wallet"
|
||||||
},
|
},
|
||||||
"discard": "Discard changes"
|
"discard": "Discard changes",
|
||||||
|
"confirmSaveTitle": "Save wallet limits?",
|
||||||
|
"confirmSaveDescription": "This updates per-order transfer-in/out limits and immediately affects player wallet transfers."
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"title": "Draw and settlement runtime settings",
|
"title": "Draw and settlement runtime settings",
|
||||||
@@ -99,19 +101,25 @@
|
|||||||
"manualReview": "Require manual review for draw results",
|
"manualReview": "Require manual review for draw results",
|
||||||
"cooldownMinutes": "Cooldown duration (minutes)",
|
"cooldownMinutes": "Cooldown duration (minutes)",
|
||||||
"autoSettlement": "Run settlement automatically",
|
"autoSettlement": "Run settlement automatically",
|
||||||
|
"autoApprove": "Auto-approve settlement batches",
|
||||||
|
"autoPayout": "Auto-credit winnings to wallets",
|
||||||
"playRulesHtml": "Play rules HTML (i18n)",
|
"playRulesHtml": "Play rules HTML (i18n)",
|
||||||
"playRulesHtmlDesc": "Rendered on the player play-rules page per locale. Leave empty to fall back to another language or the default empty state."
|
"playRulesHtmlDesc": "Rendered on the player play-rules page per locale. Leave empty to fall back to another language or the default empty state."
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin.",
|
"manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin.",
|
||||||
"cooldownMinutes": "How long to wait after publishing before entering settling. Use 0 to settle immediately.",
|
"cooldownMinutes": "How long to wait after publishing before entering settling. Use 0 to settle immediately.",
|
||||||
"autoSettlement": "When disabled, tick will not run settlement automatically and admins must trigger it manually."
|
"autoSettlement": "When disabled, tick will not run settlement automatically and admins must trigger it manually.",
|
||||||
|
"autoApprove": "After cooldown ends and settlement completes, whether batches are automatically marked as approved.",
|
||||||
|
"autoPayout": "After a batch is approved, whether tick automatically credits winnings to player wallets."
|
||||||
},
|
},
|
||||||
"states": {
|
"states": {
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"discard": "Discard changes"
|
"discard": "Discard changes",
|
||||||
|
"confirmSaveTitle": "Save system runtime parameters?",
|
||||||
|
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation."
|
||||||
},
|
},
|
||||||
"currencies": {
|
"currencies": {
|
||||||
"title": "Currency management",
|
"title": "Currency management",
|
||||||
@@ -173,9 +181,23 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"minMaxInvalid": "{{playCode}}: min bet cannot exceed max bet",
|
"minMaxInvalid": "{{playCode}}: min bet cannot exceed max bet",
|
||||||
"nameZhRequired": "Chinese display name is required"
|
"displayNameRequired": "Display name is required"
|
||||||
},
|
},
|
||||||
"publishFailed": "Publish failed",
|
"publishFailed": "Publish failed",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "Publish play configuration?",
|
||||||
|
"description": "New settings affect future bets. Existing tickets still settle by their saved snapshot.",
|
||||||
|
"confirm": "Confirm publish"
|
||||||
|
},
|
||||||
|
"batchSwitchConfirmTitle": "Batch {{action}}?",
|
||||||
|
"batchSwitchConfirmDescription": "{{action}} {{count}} play types under «{{group}}» and write to the current draft.",
|
||||||
|
"batchSwitchEnable": "Enable",
|
||||||
|
"batchSwitchDisable": "Disable",
|
||||||
|
"toggleConfirmTitle": "{{action}} play {{playCode}}?",
|
||||||
|
"toggleConfirmDescription": "This calls the API immediately (not draft-only).",
|
||||||
|
"toggleEnable": "Enable",
|
||||||
|
"toggleDisable": "Disable",
|
||||||
|
"toggleInstantFailed": "Failed to apply play switch. Try again later.",
|
||||||
"createDraftSuccess": "Created draft v{{version}}",
|
"createDraftSuccess": "Created draft v{{version}}",
|
||||||
"createDraftFailed": "Failed to create draft",
|
"createDraftFailed": "Failed to create draft",
|
||||||
"ruleSavedLocal": "Rule text was saved into the local draft. Save the draft to persist it.",
|
"ruleSavedLocal": "Rule text was saved into the local draft. Save the draft to persist it.",
|
||||||
@@ -191,7 +213,7 @@
|
|||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"ruleText": "Rule text",
|
"ruleText": "Rule text",
|
||||||
"displayNames": "Display names"
|
"editDisplayName": "Edit name"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
"zh": "Chinese",
|
"zh": "Chinese",
|
||||||
@@ -217,8 +239,8 @@
|
|||||||
"enablePlay": "Enable {{playCode}}"
|
"enablePlay": "Enable {{playCode}}"
|
||||||
},
|
},
|
||||||
"nameDialog": {
|
"nameDialog": {
|
||||||
"title": "Display names (i18n)",
|
"title": "Edit display name",
|
||||||
"description": "Play {{playCode}}. Chinese is required; English and Nepali are optional. The player site picks the label by locale after publish.",
|
"description": "Play {{playCode}}. The player site shows this label after you save and publish the draft.",
|
||||||
"apply": "Apply to draft",
|
"apply": "Apply to draft",
|
||||||
"savedLocal": "Display names were saved into the local draft. Save the draft to persist them."
|
"savedLocal": "Display names were saved into the local draft. Save the draft to persist them."
|
||||||
},
|
},
|
||||||
@@ -228,6 +250,13 @@
|
|||||||
"apply": "Apply to draft"
|
"apply": "Apply to draft"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"prizeScopes": {
|
||||||
|
"first": "First prize odds",
|
||||||
|
"second": "Second prize odds",
|
||||||
|
"third": "Third prize odds",
|
||||||
|
"starter": "Starter prize odds",
|
||||||
|
"consolation": "Consolation prize odds"
|
||||||
|
},
|
||||||
"odds": {
|
"odds": {
|
||||||
"sectionHint": "Pick a version to edit prize-tier odds; publishing applies to new tickets immediately.",
|
"sectionHint": "Pick a version to edit prize-tier odds; publishing applies to new tickets immediately.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -273,6 +302,11 @@
|
|||||||
"publishLabel": "Publish",
|
"publishLabel": "Publish",
|
||||||
"publishSuccess": "Published odds version with rebate",
|
"publishSuccess": "Published odds version with rebate",
|
||||||
"publishFailed": "Publish failed",
|
"publishFailed": "Publish failed",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "Publish rebate/odds version?",
|
||||||
|
"description": "After publish, rebate calculation applies to new tickets.",
|
||||||
|
"confirm": "Confirm publish"
|
||||||
|
},
|
||||||
"createDraftSuccess": "Created draft v{{version}}",
|
"createDraftSuccess": "Created draft v{{version}}",
|
||||||
"createDraftFailed": "Failed to create draft",
|
"createDraftFailed": "Failed to create draft",
|
||||||
"deleteFailed": "Delete failed",
|
"deleteFailed": "Delete failed",
|
||||||
@@ -297,6 +331,11 @@
|
|||||||
"enterValidCapAmount": "Enter a valid cap amount"
|
"enterValidCapAmount": "Enter a valid cap amount"
|
||||||
},
|
},
|
||||||
"publishFailed": "Publish failed",
|
"publishFailed": "Publish failed",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "Publish cap configuration?",
|
||||||
|
"description": "After publish, per-number risk-pool cap limits take effect.",
|
||||||
|
"confirm": "Confirm publish"
|
||||||
|
},
|
||||||
"createDraftSuccess": "Created draft v{{version}}",
|
"createDraftSuccess": "Created draft v{{version}}",
|
||||||
"createDraftFailed": "Failed to create draft",
|
"createDraftFailed": "Failed to create draft",
|
||||||
"savedLocalDraft": "Saved into local draft. Save the draft to persist it.",
|
"savedLocalDraft": "Saved into local draft. Save the draft to persist it.",
|
||||||
|
|||||||
@@ -2,7 +2,65 @@
|
|||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"notice": "Notice",
|
"notice": "Notice",
|
||||||
"todayBetTotal": "Current draw total bet",
|
"sections": {
|
||||||
|
"today": "Today",
|
||||||
|
"lifetime": "All-time totals",
|
||||||
|
"currentDraw": "Current draw",
|
||||||
|
"currentDrawDetail": "Current draw · {{drawNo}}",
|
||||||
|
"operations": "Operations (current draw)"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Financial analytics",
|
||||||
|
"periodLabel": "Period",
|
||||||
|
"metricLabel": "Metric",
|
||||||
|
"playLabel": "Play filter",
|
||||||
|
"allPlays": "All plays",
|
||||||
|
"customRange": "Custom dates",
|
||||||
|
"rangeHint": "Range {{range}}",
|
||||||
|
"selectPeriod": "Select a period",
|
||||||
|
"chartTruncated": "Trend shows {{from}} — {{to}} only ({{days}} days in full range)",
|
||||||
|
"summaryBet": "Period bet",
|
||||||
|
"summaryPayout": "Period payout",
|
||||||
|
"summaryProfit": "Period profit",
|
||||||
|
"dailyTrend": "Daily trend",
|
||||||
|
"playBreakdown": "Play breakdown",
|
||||||
|
"periodDistribution": "Period structure",
|
||||||
|
"noPlayData": "No play data in this period",
|
||||||
|
"periods": {
|
||||||
|
"today": "Today",
|
||||||
|
"last_7_days": "Last 7 days",
|
||||||
|
"last_30_days": "Last 30 days",
|
||||||
|
"this_month": "This month",
|
||||||
|
"lifetime": "All time",
|
||||||
|
"custom": "Custom"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"bet": "Bet",
|
||||||
|
"payout": "Payout",
|
||||||
|
"profit": "Profit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chartLegend": {
|
||||||
|
"bet": "Bet",
|
||||||
|
"payout": "Payout",
|
||||||
|
"profit": "Profit"
|
||||||
|
},
|
||||||
|
"playBreakdownHint": "Payout {{payout}} · Profit {{profit}}",
|
||||||
|
"viewReports": "Reports",
|
||||||
|
"lifetimeBetTotal": "Lifetime total bet",
|
||||||
|
"lifetimePayout": "Lifetime total payout",
|
||||||
|
"lifetimeProfit": "Lifetime platform profit",
|
||||||
|
"lifetimeActivityHint": "{{draws}} draws with bets · {{days}} business days",
|
||||||
|
"lifetimeDateRangeHint": "Range {{range}}",
|
||||||
|
"currentDrawBetTotal": "Draw total bet",
|
||||||
|
"currentDrawPayout": "Draw payout",
|
||||||
|
"currentDrawProfit": "Draw profit",
|
||||||
|
"drawFinanceDetails": "Draw finance details",
|
||||||
|
"todayBetTotal": "Today's total bet",
|
||||||
|
"todayPayout": "Today's payout",
|
||||||
|
"todayProfit": "Today's profit",
|
||||||
|
"todayBusinessDateHint": "Business date {{date}}",
|
||||||
"drawNoHint": "Draw {{drawNo}}",
|
"drawNoHint": "Draw {{drawNo}}",
|
||||||
"orderAndTicket": "{{orders}} orders · {{tickets}} items",
|
"orderAndTicket": "{{orders}} orders · {{tickets}} items",
|
||||||
"marginRate": "Gross margin ~{{rate}}%",
|
"marginRate": "Gross margin ~{{rate}}%",
|
||||||
@@ -21,8 +79,9 @@
|
|||||||
"settlementOverview": "Settlement batches",
|
"settlementOverview": "Settlement batches",
|
||||||
"noSettlementBatches": "No settlement batches",
|
"noSettlementBatches": "No settlement batches",
|
||||||
"quickLinksTitle": "Quick links",
|
"quickLinksTitle": "Quick links",
|
||||||
"currentPayout": "Current payout",
|
"currentPayout": "Current draw payout",
|
||||||
"currentProfit": "Current platform profit",
|
"currentProfit": "Current draw profit",
|
||||||
|
"currentDrawFinanceHint": "Charts below are for draw {{drawNo}}",
|
||||||
"currentDraw": "Current draw",
|
"currentDraw": "Current draw",
|
||||||
"drawSequence": "Round {{sequence}}",
|
"drawSequence": "Round {{sequence}}",
|
||||||
"drawDetails": "Draw details",
|
"drawDetails": "Draw details",
|
||||||
@@ -64,8 +123,9 @@
|
|||||||
"auditLogs": "Audit logs"
|
"auditLogs": "Audit logs"
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"drawPermission": "This account has no draw view/manage permission. Finance and risk data were not returned.",
|
"drawPermission": "This account has no draw/dashboard view permission. Finance and risk data were not returned.",
|
||||||
"walletPermission": "This account has no wallet reconciliation permission. Abnormal transfer count was not returned.",
|
"walletPermission": "This account has no wallet reconciliation permission. Abnormal transfer count was not returned.",
|
||||||
"loadFailed": "Failed to load. Check the API and login state."
|
"loadFailed": "Failed to load. Check the API and login state.",
|
||||||
|
"apiResourceMissing": "Dashboard analytics API is not registered. Run: php artisan lottery:admin-auth-sync (or apply the latest migration), then refresh."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,5 +144,23 @@
|
|||||||
"third": "3rd prize",
|
"third": "3rd prize",
|
||||||
"starter": "Starter {{index}}",
|
"starter": "Starter {{index}}",
|
||||||
"consolation": "Consolation {{index}}"
|
"consolation": "Consolation {{index}}"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"manualCloseTitle": "Confirm manual close?",
|
||||||
|
"manualCloseDescription": "Players will no longer be able to bet on this draw.",
|
||||||
|
"cancelDrawTitle": "Confirm cancel draw?",
|
||||||
|
"cancelDrawDescription": "This draw will not be drawn. Ensure there is no outstanding bet risk.",
|
||||||
|
"rngDrawTitle": "Confirm RNG draw?",
|
||||||
|
"rngDrawDescription": "The system will generate draw numbers and continue the pipeline.",
|
||||||
|
"reopenTitle": "Confirm cooldown reopen?",
|
||||||
|
"reopenDescription": "Results may need re-review; displayed numbers may change.",
|
||||||
|
"runSettlementTitle": "Confirm run settlement?",
|
||||||
|
"runSettlementDescription": "A settlement batch will be created from the published result.",
|
||||||
|
"saveManualDraftTitle": "Confirm save manual draft?",
|
||||||
|
"saveManualDraftDescription": "23 numbers will be saved for review.",
|
||||||
|
"publishTitle": "Confirm publish results?",
|
||||||
|
"publishDescription": "Results become visible to players and may trigger settlement.",
|
||||||
|
"generatePlanTitle": "Confirm generate draw plan?",
|
||||||
|
"generatePlanDescription": "Future bettable draws will be created per system rules."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,14 @@
|
|||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"manualBurstDrawId": "Manual burst draw number",
|
"manualBurstDrawId": "Draw ID for manual burst",
|
||||||
"manualBurstAmount": "Burst amount (empty for all)",
|
"manualBurstHint": "Super admin only. Requires a settled draw with first-prize winners. Pool release follows the configured payout rate.",
|
||||||
|
"manualBurstConfirmTitle": "Confirm manual jackpot burst?",
|
||||||
|
"manualBurstConfirmDescription": "Jackpot will be split among first-prize winners for draw {{drawId}} using the payout rate. Pool balance will be reduced. This cannot be undone automatically.",
|
||||||
"processing": "Processing…",
|
"processing": "Processing…",
|
||||||
"manualBurst": "Manual burst",
|
"manualBurst": "Manual burst (super admin only)",
|
||||||
|
"manualBurstConfirm": "Confirm burst",
|
||||||
|
"cancel": "Cancel",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"drawNo": "Draw no.",
|
"drawNo": "Draw no.",
|
||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
"lastLogin": "Last login",
|
"lastLogin": "Last login",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"freeze": "Freeze",
|
||||||
|
"unfreeze": "Unfreeze",
|
||||||
|
"freezeSuccess": "Player {{name}} frozen",
|
||||||
|
"unfreezeSuccess": "Player {{name}} unfrozen",
|
||||||
|
"freezeFailed": "Failed to freeze player",
|
||||||
|
"unfreezeFailed": "Failed to unfreeze player",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"createDialogTitle": "Create player",
|
"createDialogTitle": "Create player",
|
||||||
"editDialogTitle": "Edit player",
|
"editDialogTitle": "Edit player",
|
||||||
@@ -44,6 +50,10 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
|
"confirmFreezeTitle": "Confirm freeze player?",
|
||||||
|
"confirmFreezeDescription": "Player {{name}} will not be able to place bets.",
|
||||||
|
"confirmUnfreezeTitle": "Confirm unfreeze player?",
|
||||||
|
"confirmUnfreezeDescription": "Player {{name}} will return to normal status.",
|
||||||
"confirmDelete": "Confirm delete",
|
"confirmDelete": "Confirm delete",
|
||||||
"confirmDeleteDesc": "Delete player {{name}}? This action cannot be undone."
|
"confirmDeleteDesc": "Delete player {{name}}? This action cannot be undone."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
"periodRequired": "Enter both reconcile start and end dates",
|
"periodRequired": "Enter both reconcile start and end dates",
|
||||||
"periodInvalid": "Invalid date range",
|
"periodInvalid": "Invalid date range",
|
||||||
"periodOrderInvalid": "End time must be later than or equal to start time",
|
"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}}.",
|
||||||
|
"confirmCreatePlayer": " for the selected player",
|
||||||
|
"confirmCreateAllPlayers": " (all players)",
|
||||||
"createSuccess": "Reconcile job created",
|
"createSuccess": "Reconcile job created",
|
||||||
"createFailed": "Failed to create job",
|
"createFailed": "Failed to create job",
|
||||||
"noCreatePermission": "Current account cannot create reconcile jobs.",
|
"noCreatePermission": "Current account cannot create reconcile jobs.",
|
||||||
|
|||||||
@@ -46,6 +46,12 @@
|
|||||||
"manualCloseSuccess": "Number betting closed manually",
|
"manualCloseSuccess": "Number betting closed manually",
|
||||||
"recoverSuccess": "Number betting recovered",
|
"recoverSuccess": "Number betting recovered",
|
||||||
"actionFailed": "Action failed",
|
"actionFailed": "Action failed",
|
||||||
|
"confirm": {
|
||||||
|
"closeTitle": "Confirm close number?",
|
||||||
|
"closeDescription": "Number {{number}} will be blocked for this draw.",
|
||||||
|
"recoverTitle": "Confirm recover number?",
|
||||||
|
"recoverDescription": "Number {{number}} will be open for betting again."
|
||||||
|
},
|
||||||
"detailTitle": "Risk pool details",
|
"detailTitle": "Risk pool details",
|
||||||
"loadDetailFailed": "Failed to load risk pool details",
|
"loadDetailFailed": "Failed to load risk pool details",
|
||||||
"backToList": "Back to list",
|
"backToList": "Back to list",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"pending_confirm": "Pending confirmation",
|
"pending_confirm": "Pending confirmation",
|
||||||
"partial_pending_confirm": "Partially pending confirmation",
|
"partial_pending_confirm": "Partially pending confirmation",
|
||||||
"success": "Bet placed",
|
"success": "Bet placed",
|
||||||
|
"pending_draw": "Awaiting draw",
|
||||||
"failed": "Bet failed",
|
"failed": "Bet failed",
|
||||||
"pending_payout": "Pending payout",
|
"pending_payout": "Pending payout",
|
||||||
"settled_win": "Settled win",
|
"settled_win": "Settled win",
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
"reverseSuccess": "Reversed successfully",
|
"reverseSuccess": "Reversed successfully",
|
||||||
"manualProcessSuccess": "Manually processed successfully",
|
"manualProcessSuccess": "Manually processed successfully",
|
||||||
"actionFailed": "Action failed",
|
"actionFailed": "Action failed",
|
||||||
|
"confirm": {
|
||||||
|
"reverseTitle": "Confirm reverse transfer?",
|
||||||
|
"reverseDescription": "Reverse order {{transferNo}}. This may affect player wallet balance.",
|
||||||
|
"manualProcessTitle": "Confirm manual process?",
|
||||||
|
"manualProcessDescription": "Mark order {{transferNo}} as manually processed without automatic wallet adjustment."
|
||||||
|
},
|
||||||
"txnNo": "Txn no.",
|
"txnNo": "Txn no.",
|
||||||
"bizType": "Business type",
|
"bizType": "Business type",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
|||||||
@@ -159,6 +159,7 @@
|
|||||||
"prd.rebate.view": "कमिसन/रिबेट · हेर्नुहोस्",
|
"prd.rebate.view": "कमिसन/रिबेट · हेर्नुहोस्",
|
||||||
"prd.jackpot.manage": "ज्याकपोट कन्फिगरेसन · व्यवस्थापन",
|
"prd.jackpot.manage": "ज्याकपोट कन्फिगरेसन · व्यवस्थापन",
|
||||||
"prd.jackpot.view": "ज्याकपोट कन्फिगरेसन · हेर्नुहोस्",
|
"prd.jackpot.view": "ज्याकपोट कन्फिगरेसन · हेर्नुहोस्",
|
||||||
|
"prd.jackpot.manual_burst": "ज्याकपोट म्यानुअल बर्स्ट · सुपर एडमिन मात्र",
|
||||||
"prd.payout.manage": "भुक्तानी पुष्टि · व्यवस्थापन",
|
"prd.payout.manage": "भुक्तानी पुष्टि · व्यवस्थापन",
|
||||||
"prd.payout.review": "भुक्तानी पुष्टि · समीक्षा",
|
"prd.payout.review": "भुक्तानी पुष्टि · समीक्षा",
|
||||||
"prd.payout.view": "भुक्तानी पुष्टि · हेर्नुहोस्",
|
"prd.payout.view": "भुक्तानी पुष्टि · हेर्नुहोस्",
|
||||||
|
|||||||
@@ -26,7 +26,31 @@
|
|||||||
"createTask": "टास्क सिर्जना गर्नुहोस्",
|
"createTask": "टास्क सिर्जना गर्नुहोस्",
|
||||||
"clear": "खाली गर्नुहोस्",
|
"clear": "खाली गर्नुहोस्",
|
||||||
"done": "समाप्त",
|
"done": "समाप्त",
|
||||||
"exportExcel": "Excel निर्यात"
|
"exportExcel": "Excel निर्यात",
|
||||||
|
"save": "परिवर्तन सुरक्षित गर्नुहोस्",
|
||||||
|
"updateSuccess": "सफलतापूर्वक अद्यावधिक भयो",
|
||||||
|
"updateFailed": "अद्यावधिक असफल भयो",
|
||||||
|
"updatePassword": "पासवर्ड अद्यावधिक गर्नुहोस्"
|
||||||
|
},
|
||||||
|
"accountSettings": "खाता सेटिङ",
|
||||||
|
"accountSettingsDesc": "आफ्नो प्रोफाइल र सुरक्षा सेटिङ व्यवस्थापन गर्नुहोस्।",
|
||||||
|
"profileSettings": "आधारभूत प्रोफाइल",
|
||||||
|
"profileSettingsDesc": "आफ्नो प्रदर्शन नाम अद्यावधिक गर्नुहोस्।",
|
||||||
|
"securitySettings": "सुरक्षा सेटिङ",
|
||||||
|
"securitySettingsDesc": "लगइन पासवर्ड परिवर्तन गर्नुहोस्। नपरिवर्तन गर्दा खाली छोड्नुहोस्।",
|
||||||
|
"fields": {
|
||||||
|
"nickname": "उपनाम",
|
||||||
|
"newPassword": "नयाँ पासवर्ड",
|
||||||
|
"confirmPassword": "पासवर्ड पुष्टि"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"nickname": "उपनाम प्रविष्ट गर्नुहोस्",
|
||||||
|
"password": "नयाँ पासवर्ड प्रविष्ट गर्नुहोस्",
|
||||||
|
"confirmPassword": "पासवर्ड फेरि प्रविष्ट गर्नुहोस्"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "{{field}} अनिवार्य छ",
|
||||||
|
"passwordMismatch": "पासवर्ड मिलेन"
|
||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"expand": "खोल्नुहोस्",
|
"expand": "खोल्नुहोस्",
|
||||||
@@ -59,7 +83,16 @@
|
|||||||
"date": {
|
"date": {
|
||||||
"placeholder": "मिति छान्नुहोस्",
|
"placeholder": "मिति छान्नुहोस्",
|
||||||
"rangePlaceholder": "मिति दायरा छान्नुहोस्",
|
"rangePlaceholder": "मिति दायरा छान्नुहोस्",
|
||||||
"rangeHint": "सुरु मिति छान्नुहोस्, त्यसपछि अन्त्य मिति। एउटै दिनका लागि सोही मितिमा दुई पटक क्लिक गर्नुहोस्। बन्द गर्न Done थिच्नुहोस्।"
|
"rangeHint": "सुरु मिति छान्नुहोस्, त्यसपछि अन्त्य मिति। एउटै दिनका लागि सोही मितिमा दुई पटक क्लिक गर्नुहोस्। बन्द गर्न Done थिच्नुहोस्।",
|
||||||
|
"weekdays": {
|
||||||
|
"sunday": "आइतबार",
|
||||||
|
"monday": "सोमबार",
|
||||||
|
"tuesday": "मंगलबार",
|
||||||
|
"wednesday": "बुधबार",
|
||||||
|
"thursday": "बिहिबार",
|
||||||
|
"friday": "शुक्रबार",
|
||||||
|
"saturday": "शनिबार"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"perPage": "प्रति पृष्ठ",
|
"perPage": "प्रति पृष्ठ",
|
||||||
@@ -76,6 +109,10 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "लोड असफल भयो"
|
"loadFailed": "लोड असफल भयो"
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"deniedTitle": "पहुँच अनुमति छैन",
|
||||||
|
"deniedDescription": "यो पृष्ठ खोल्ने अनुमति तपाईंको खातामा छैन। भूमिका व्यवस्थापनबाट आवश्यक अनुमति दिन प्रशासकलाई सम्पर्क गर्नुहोस्।"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"id": "ID"
|
"id": "ID"
|
||||||
},
|
},
|
||||||
@@ -98,6 +135,7 @@
|
|||||||
"draws": "ड्रअहरू",
|
"draws": "ड्रअहरू",
|
||||||
"rules_plays": "खेल नियम",
|
"rules_plays": "खेल नियम",
|
||||||
"rules_odds": "बाधा र रिबेट",
|
"rules_odds": "बाधा र रिबेट",
|
||||||
|
"rules": "खेल नियम",
|
||||||
"risk_cap": "जोखिम क्याप संस्करण",
|
"risk_cap": "जोखिम क्याप संस्करण",
|
||||||
"risk": "जोखिम केन्द्र",
|
"risk": "जोखिम केन्द्र",
|
||||||
"settlement": "सेटलमेन्ट",
|
"settlement": "सेटलमेन्ट",
|
||||||
@@ -105,12 +143,18 @@
|
|||||||
"reconcile": "मिलान",
|
"reconcile": "मिलान",
|
||||||
"tickets": "टिकट सूची",
|
"tickets": "टिकट सूची",
|
||||||
"audit": "अडिट लग",
|
"audit": "अडिट लग",
|
||||||
"settings": "सेटिङ"
|
"settings": "सेटिङ",
|
||||||
|
"account": "खाता सेटिङ"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "कार्यस्थान"
|
"workspace": "कार्यस्थान"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "लगइन स्थिति जाँच हुँदैछ…"
|
"checking": "लगइन स्थिति जाँच हुँदैछ…"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"cancel": "रद्द",
|
||||||
|
"confirm": "पुष्टि गर्नुहोस्",
|
||||||
|
"confirmSave": "सुरक्षित गर्नुहोस्"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,9 @@
|
|||||||
"outMin": "लटरी वालेटबाट मुख्य वालेटमा प्रति अर्डर न्यूनतम",
|
"outMin": "लटरी वालेटबाट मुख्य वालेटमा प्रति अर्डर न्यूनतम",
|
||||||
"outMax": "लटरी वालेटबाट मुख्य वालेटमा प्रति अर्डर अधिकतम"
|
"outMax": "लटरी वालेटबाट मुख्य वालेटमा प्रति अर्डर अधिकतम"
|
||||||
},
|
},
|
||||||
"discard": "परिवर्तन त्याग्नुहोस्"
|
"discard": "परिवर्तन त्याग्नुहोस्",
|
||||||
|
"confirmSaveTitle": "वालेट सीमा सुरक्षित गर्ने?",
|
||||||
|
"confirmSaveDescription": "ट्रान्सफर-इन/आउटको प्रति अर्डर सीमा अद्यावधिक हुन्छ र खेलाडीको वालेट ट्रान्सफरमा तुरुन्त असर पर्छ।"
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"title": "ड्रअ र सेटलमेन्ट रनटाइम सेटिङ",
|
"title": "ड्रअ र सेटलमेन्ट रनटाइम सेटिङ",
|
||||||
@@ -99,19 +101,25 @@
|
|||||||
"manualReview": "ड्रअ परिणामका लागि म्यानुअल समीक्षा चाहिने",
|
"manualReview": "ड्रअ परिणामका लागि म्यानुअल समीक्षा चाहिने",
|
||||||
"cooldownMinutes": "कूलडाउन अवधि (मिनेट)",
|
"cooldownMinutes": "कूलडाउन अवधि (मिनेट)",
|
||||||
"autoSettlement": "सेटलमेन्ट स्वतः चलाउने",
|
"autoSettlement": "सेटलमेन्ट स्वतः चलाउने",
|
||||||
|
"autoApprove": "सेटलमेन्ट ब्याच स्वतः स्वीकृत",
|
||||||
|
"autoPayout": "जित रकम स्वतः वालेटमा जम्मा",
|
||||||
"playRulesHtml": "खेल नियम HTML (बहुभाषी)",
|
"playRulesHtml": "खेल नियम HTML (बहुभाषी)",
|
||||||
"playRulesHtmlDesc": "खेलाडीको नियम पृष्ठमा भाषा अनुसार HTML देखिन्छ। खाली छोड्दा अर्को भाषा वा पूर्वनिर्धारित खाली सूचना देखिन्छ।"
|
"playRulesHtmlDesc": "खेलाडीको नियम पृष्ठमा भाषा अनुसार HTML देखिन्छ। खाली छोड्दा अर्को भाषा वा पूर्वनिर्धारित खाली सूचना देखिन्छ।"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।",
|
"manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।",
|
||||||
"cooldownMinutes": "प्रकाशनपछि settling मा जानुअघि कति समय पर्खने। 0 राखे तुरुन्त सेटलमेन्ट सुरु हुन्छ।",
|
"cooldownMinutes": "प्रकाशनपछि settling मा जानुअघि कति समय पर्खने। 0 राखे तुरुन्त सेटलमेन्ट सुरु हुन्छ।",
|
||||||
"autoSettlement": "बन्द हुँदा tick ले सेटलमेन्ट स्वतः चलाउँदैन र एडमिनले म्यानुअल रूपमा ट्रिगर गर्नुपर्छ।"
|
"autoSettlement": "बन्द हुँदा tick ले सेटलमेन्ट स्वतः चलाउँदैन र एडमिनले म्यानुअल रूपमा ट्रिगर गर्नुपर्छ।",
|
||||||
|
"autoApprove": "कूलडाउन सकिएर सेटलमेन्ट पूरा भएपछि ब्याच स्वतः अनुमोदित हुने हो कि होइन।",
|
||||||
|
"autoPayout": "ब्याच अनुमोदित भएपछि tick ले जित रकम खेलाडीको वालेटमा स्वतः जम्मा गर्ने हो कि होइन।"
|
||||||
},
|
},
|
||||||
"states": {
|
"states": {
|
||||||
"enabled": "सक्रिय",
|
"enabled": "सक्रिय",
|
||||||
"disabled": "बन्द"
|
"disabled": "बन्द"
|
||||||
},
|
},
|
||||||
"discard": "परिवर्तन त्याग्नुहोस्"
|
"discard": "परिवर्तन त्याग्नुहोस्",
|
||||||
|
"confirmSaveTitle": "प्रणाली रनटाइम प्यारामिटर सुरक्षित गर्ने?",
|
||||||
|
"confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।"
|
||||||
},
|
},
|
||||||
"currencies": {
|
"currencies": {
|
||||||
"title": "मुद्रा व्यवस्थापन",
|
"title": "मुद्रा व्यवस्थापन",
|
||||||
@@ -173,9 +181,23 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन",
|
"minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन",
|
||||||
"nameZhRequired": "चिनियाँ प्रदर्शित नाम अनिवार्य छ"
|
"displayNameRequired": "प्रदर्शित नाम अनिवार्य छ"
|
||||||
},
|
},
|
||||||
"publishFailed": "प्रकाशन असफल भयो",
|
"publishFailed": "प्रकाशन असफल भयो",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "खेल कन्फिग प्रकाशित गर्ने?",
|
||||||
|
"description": "नयाँ सेटिङले आगामी बेटहरूमा असर गर्छ। पुराना टिकटहरू आफ्नो snapshot अनुसार नै सेटल हुन्छन्।",
|
||||||
|
"confirm": "प्रकाशन पुष्टि गर्नुहोस्"
|
||||||
|
},
|
||||||
|
"batchSwitchConfirmTitle": "समूह {{action}} पुष्टि गर्ने?",
|
||||||
|
"batchSwitchConfirmDescription": "«{{group}}» अन्तर्गत {{count}} खेल प्रकार {{action}} गरी हालको ड्राफ्टमा लेखिनेछ।",
|
||||||
|
"batchSwitchEnable": "सक्रिय",
|
||||||
|
"batchSwitchDisable": "निष्क्रिय",
|
||||||
|
"toggleConfirmTitle": "खेल {{playCode}} {{action}} गर्ने?",
|
||||||
|
"toggleConfirmDescription": "यो तुरुन्त API मार्फत लागू हुन्छ (केवल ड्राफ्ट मात्र होइन)।",
|
||||||
|
"toggleEnable": "सक्रिय",
|
||||||
|
"toggleDisable": "निष्क्रिय",
|
||||||
|
"toggleInstantFailed": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।",
|
||||||
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
|
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
|
||||||
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
|
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
|
||||||
"ruleSavedLocal": "नियम पाठ स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।",
|
"ruleSavedLocal": "नियम पाठ स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।",
|
||||||
@@ -191,7 +213,7 @@
|
|||||||
"enable": "सक्रिय",
|
"enable": "सक्रिय",
|
||||||
"disable": "निष्क्रिय",
|
"disable": "निष्क्रिय",
|
||||||
"ruleText": "नियम पाठ",
|
"ruleText": "नियम पाठ",
|
||||||
"displayNames": "बहुभाषी नाम"
|
"editDisplayName": "नाम सम्पादन"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
"zh": "चिनियाँ",
|
"zh": "चिनियाँ",
|
||||||
@@ -228,6 +250,13 @@
|
|||||||
"apply": "ड्राफ्टमा लागू गर्नुहोस्"
|
"apply": "ड्राफ्टमा लागू गर्नुहोस्"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"prizeScopes": {
|
||||||
|
"first": "पहिलो पुरस्कार बाधा",
|
||||||
|
"second": "दोस्रो पुरस्कार बाधा",
|
||||||
|
"third": "तेस्रो पुरस्कार बाधा",
|
||||||
|
"starter": "स्टार्टर पुरस्कार बाधा",
|
||||||
|
"consolation": "सान्त्वना पुरस्कार बाधा"
|
||||||
|
},
|
||||||
"odds": {
|
"odds": {
|
||||||
"sectionHint": "संस्करण छानेर पुरस्कार-स्तर बाधा सम्पादन गर्नुहोस्; प्रकाशनपछि नयाँ टिकटमा लागू हुन्छ।",
|
"sectionHint": "संस्करण छानेर पुरस्कार-स्तर बाधा सम्पादन गर्नुहोस्; प्रकाशनपछि नयाँ टिकटमा लागू हुन्छ।",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -273,6 +302,11 @@
|
|||||||
"publishLabel": "प्रकाशन",
|
"publishLabel": "प्रकाशन",
|
||||||
"publishSuccess": "रिबेटसहितको अड्स संस्करण प्रकाशित भयो",
|
"publishSuccess": "रिबेटसहितको अड्स संस्करण प्रकाशित भयो",
|
||||||
"publishFailed": "प्रकाशन असफल भयो",
|
"publishFailed": "प्रकाशन असफल भयो",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "रिबेट/अड्स संस्करण प्रकाशित गर्ने?",
|
||||||
|
"description": "प्रकाशनपछि नयाँ टिकटहरूको रिबेट गणनामा असर पर्छ।",
|
||||||
|
"confirm": "प्रकाशन पुष्टि गर्नुहोस्"
|
||||||
|
},
|
||||||
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
|
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
|
||||||
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
|
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
|
||||||
"deleteFailed": "मेटाउन असफल",
|
"deleteFailed": "मेटाउन असफल",
|
||||||
@@ -297,6 +331,11 @@
|
|||||||
"enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्"
|
"enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्"
|
||||||
},
|
},
|
||||||
"publishFailed": "प्रकाशन असफल भयो",
|
"publishFailed": "प्रकाशन असफल भयो",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "क्याप कन्फिग प्रकाशित गर्ने?",
|
||||||
|
"description": "प्रकाशनपछि प्रत्येक नम्बरको जोखिम पूल क्याप सीमा लागू हुन्छ।",
|
||||||
|
"confirm": "प्रकाशन पुष्टि गर्नुहोस्"
|
||||||
|
},
|
||||||
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
|
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
|
||||||
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
|
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
|
||||||
"savedLocalDraft": "स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।",
|
"savedLocalDraft": "स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।",
|
||||||
|
|||||||
@@ -2,7 +2,65 @@
|
|||||||
"title": "ड्यासबोर्ड",
|
"title": "ड्यासबोर्ड",
|
||||||
"refresh": "रिफ्रेस",
|
"refresh": "रिफ्रेस",
|
||||||
"notice": "सूचना",
|
"notice": "सूचना",
|
||||||
"todayBetTotal": "हालको ड्रअ कुल बेट",
|
"sections": {
|
||||||
|
"today": "आजको सारांश",
|
||||||
|
"lifetime": "ऐतिहासिक कुल",
|
||||||
|
"currentDraw": "हालको ड्रअ",
|
||||||
|
"currentDrawDetail": "हालको ड्रअ · {{drawNo}}",
|
||||||
|
"operations": "सञ्चालन (हालको ड्रअ)"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "वित्त विश्लेषण",
|
||||||
|
"periodLabel": "अवधि",
|
||||||
|
"metricLabel": "मेट्रिक",
|
||||||
|
"playLabel": "प्ले फिल्टर",
|
||||||
|
"allPlays": "सबै प्ले",
|
||||||
|
"customRange": "मिति दायरा",
|
||||||
|
"rangeHint": "अवधि {{range}}",
|
||||||
|
"selectPeriod": "अवधि छान्नुहोस्",
|
||||||
|
"chartTruncated": "ट्रेन्ड {{from}} — {{to}} मात्र (कुल {{days}} दिन)",
|
||||||
|
"summaryBet": "अवधि बेट",
|
||||||
|
"summaryPayout": "अवधि भुक्तानी",
|
||||||
|
"summaryProfit": "अवधि नाफा",
|
||||||
|
"dailyTrend": "दैनिक ट्रेन्ड",
|
||||||
|
"playBreakdown": "प्ले विभाजन",
|
||||||
|
"periodDistribution": "अवधि संरचना",
|
||||||
|
"noPlayData": "यस अवधिमा प्ले डाटा छैन",
|
||||||
|
"periods": {
|
||||||
|
"today": "आज",
|
||||||
|
"last_7_days": "पछिल्लो ७ दिन",
|
||||||
|
"last_30_days": "पछिल्लो ३० दिन",
|
||||||
|
"this_month": "यो महिना",
|
||||||
|
"lifetime": "सबै",
|
||||||
|
"custom": "अनुकूल"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"overview": "सिंहावलोकन",
|
||||||
|
"bet": "बेट",
|
||||||
|
"payout": "भुक्तानी",
|
||||||
|
"profit": "नाफा"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chartLegend": {
|
||||||
|
"bet": "बेट",
|
||||||
|
"payout": "भुक्तानी",
|
||||||
|
"profit": "नाफा"
|
||||||
|
},
|
||||||
|
"playBreakdownHint": "भुक्तानी {{payout}} · नाफा {{profit}}",
|
||||||
|
"viewReports": "प्रतिवेदन",
|
||||||
|
"lifetimeBetTotal": "जम्मा बेट",
|
||||||
|
"lifetimePayout": "जम्मा भुक्तानी",
|
||||||
|
"lifetimeProfit": "जम्मा प्लेटफर्म नाफा",
|
||||||
|
"lifetimeActivityHint": "{{draws}} ड्रअमा बेट · {{days}} व्यापार दिन",
|
||||||
|
"lifetimeDateRangeHint": "अवधि {{range}}",
|
||||||
|
"currentDrawBetTotal": "हालको ड्रअ बेट",
|
||||||
|
"currentDrawPayout": "हालको भुक्तानी",
|
||||||
|
"currentDrawProfit": "हालको नाफा/नोक्सान",
|
||||||
|
"drawFinanceDetails": "ड्रअ वित्त विवरण",
|
||||||
|
"todayBetTotal": "आजको कुल बेट",
|
||||||
|
"todayPayout": "आजको भुक्तानी",
|
||||||
|
"todayProfit": "आजको नाफा/नोक्सान",
|
||||||
|
"todayBusinessDateHint": "व्यापार मिति {{date}}",
|
||||||
"drawNoHint": "ड्रअ {{drawNo}}",
|
"drawNoHint": "ड्रअ {{drawNo}}",
|
||||||
"orderAndTicket": "{{orders}} अर्डर · {{tickets}} वस्तु",
|
"orderAndTicket": "{{orders}} अर्डर · {{tickets}} वस्तु",
|
||||||
"marginRate": "सकल मार्जिन ~{{rate}}%",
|
"marginRate": "सकल मार्जिन ~{{rate}}%",
|
||||||
@@ -64,7 +122,7 @@
|
|||||||
"auditLogs": "अडिट लग"
|
"auditLogs": "अडिट लग"
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"drawPermission": "यो खातासँग ड्रअ हेर्ने वा व्यवस्थापन अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
|
"drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
|
||||||
"walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।",
|
"walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।",
|
||||||
"loadFailed": "लोड असफल भयो। API र लगइन अवस्था जाँच गर्नुहोस्।"
|
"loadFailed": "लोड असफल भयो। API र लगइन अवस्था जाँच गर्नुहोस्।"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,5 +144,23 @@
|
|||||||
"third": "तेस्रो पुरस्कार",
|
"third": "तेस्रो पुरस्कार",
|
||||||
"starter": "विशेष {{index}}",
|
"starter": "विशेष {{index}}",
|
||||||
"consolation": "सान्त्वना {{index}}"
|
"consolation": "सान्त्वना {{index}}"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"manualCloseTitle": "म्यानुअल बन्द पुष्टि?",
|
||||||
|
"manualCloseDescription": "खेलाडीहरूले यो ड्रअमा थप दांव लगाउन सक्ने छैनन्।",
|
||||||
|
"cancelDrawTitle": "ड्रअ रद्द पुष्टि?",
|
||||||
|
"cancelDrawDescription": "यो ड्रअ खुल्ने छैन।",
|
||||||
|
"rngDrawTitle": "RNG ड्रअ पुष्टि?",
|
||||||
|
"rngDrawDescription": "प्रणालीले नतिजा सिर्जना गर्नेछ।",
|
||||||
|
"reopenTitle": "कुलडाउन पुनः खोल्ने पुष्टि?",
|
||||||
|
"reopenDescription": "नतिजा पुनः समीक्षा हुन सक्छ।",
|
||||||
|
"runSettlementTitle": "सेटलमेन्ट सुरु पुष्टि?",
|
||||||
|
"runSettlementDescription": "प्रकाशित नतिजाबाट सेटलमेन्ट ब्याच बन्नेछ।",
|
||||||
|
"saveManualDraftTitle": "म्यानुअल ड्राफ्ट सुरक्षित पुष्टि?",
|
||||||
|
"saveManualDraftDescription": "२३ नम्बर समीक्षाका लागि सुरक्षित हुनेछ।",
|
||||||
|
"publishTitle": "नतिजा प्रकाशन पुष्टि?",
|
||||||
|
"publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।",
|
||||||
|
"generatePlanTitle": "ड्रअ योजना सिर्जना पुष्टि?",
|
||||||
|
"generatePlanDescription": "भविष्यका ड्रअहरू सिर्जना हुनेछन्।"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,14 @@
|
|||||||
"enabled": "खुला",
|
"enabled": "खुला",
|
||||||
"saving": "सुरक्षित हुँदैछ…",
|
"saving": "सुरक्षित हुँदैछ…",
|
||||||
"save": "सुरक्षित गर्नुहोस्",
|
"save": "सुरक्षित गर्नुहोस्",
|
||||||
"manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ नम्बर",
|
"manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ ID",
|
||||||
"manualBurstAmount": "बर्स्ट रकम (खाली भए सबै)",
|
"manualBurstHint": "सुपर एडमिन मात्र। बसेको ड्रअ र प्रथम पुरस्कार विजेताहरू चाहिन्छ। पेआउट दर अनुसार वितरण हुन्छ।",
|
||||||
|
"manualBurstConfirmTitle": "म्यानुअल बर्स्ट पुष्टि गर्ने?",
|
||||||
|
"manualBurstConfirmDescription": "ड्रअ {{drawId}} का प्रथम पुरस्कार विजेताहरूलाई Jackpot वितरण गरिनेछ।",
|
||||||
"processing": "प्रक्रियामा…",
|
"processing": "प्रक्रियामा…",
|
||||||
"manualBurst": "म्यानुअल बर्स्ट",
|
"manualBurst": "म्यानुअल बर्स्ट (सुपर एडमिन)",
|
||||||
|
"manualBurstConfirm": "बर्स्ट पुष्टि",
|
||||||
|
"cancel": "रद्द",
|
||||||
"filter": "फिल्टर",
|
"filter": "फिल्टर",
|
||||||
"drawNo": "ड्रअ नं.",
|
"drawNo": "ड्रअ नं.",
|
||||||
"optional": "वैकल्पिक",
|
"optional": "वैकल्पिक",
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
"lastLogin": "अन्तिम लगइन",
|
"lastLogin": "अन्तिम लगइन",
|
||||||
"actions": "कार्य",
|
"actions": "कार्य",
|
||||||
"edit": "सम्पादन",
|
"edit": "सम्पादन",
|
||||||
|
"freeze": "रोक्नुहोस्",
|
||||||
|
"unfreeze": "फुकाउनुहोस्",
|
||||||
|
"freezeSuccess": "खेलाडी {{name}} रोकियो",
|
||||||
|
"unfreezeSuccess": "खेलाडी {{name}} फुकाइयो",
|
||||||
|
"freezeFailed": "रोक्न सकिएन",
|
||||||
|
"unfreezeFailed": "फुकाउन सकिएन",
|
||||||
"delete": "मेटाउनुहोस्",
|
"delete": "मेटाउनुहोस्",
|
||||||
"createDialogTitle": "खेलाडी सिर्जना",
|
"createDialogTitle": "खेलाडी सिर्जना",
|
||||||
"editDialogTitle": "खेलाडी सम्पादन",
|
"editDialogTitle": "खेलाडी सम्पादन",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"pending_confirm": "पुष्टि बाँकी",
|
"pending_confirm": "पुष्टि बाँकी",
|
||||||
"partial_pending_confirm": "आंशिक पुष्टि बाँकी",
|
"partial_pending_confirm": "आंशिक पुष्टि बाँकी",
|
||||||
"success": "बेट सफल",
|
"success": "बेट सफल",
|
||||||
|
"pending_draw": "ड्र पर्खँदै",
|
||||||
"failed": "बेट असफल",
|
"failed": "बेट असफल",
|
||||||
"pending_payout": "भुक्तानी बाँकी",
|
"pending_payout": "भुक्तानी बाँकी",
|
||||||
"settled_win": "जित सेटल भयो",
|
"settled_win": "जित सेटल भयो",
|
||||||
|
|||||||
@@ -119,6 +119,16 @@
|
|||||||
"confirmTitle": "删除角色",
|
"confirmTitle": "删除角色",
|
||||||
"confirmDescription": "确认删除角色 {{name}}?"
|
"confirmDescription": "确认删除角色 {{name}}?"
|
||||||
},
|
},
|
||||||
|
"confirmSaveRolesTitle": "确认保存管理员角色?",
|
||||||
|
"confirmSaveRolesDescription": "将更新管理员 {{name}} 的角色绑定,其后台权限会随之变化。",
|
||||||
|
"confirmSaveAccountTitle": "确认保存管理员账号?",
|
||||||
|
"confirmSaveAccountCreateDescription": "将创建新管理员账号并授予所选角色。",
|
||||||
|
"confirmSaveAccountEditDescription": "将更新管理员 {{name}} 的账号信息(含状态与密码变更)。",
|
||||||
|
"confirmSaveRolePermissionsTitle": "确认保存角色权限?",
|
||||||
|
"confirmSaveRolePermissionsDescription": "将更新角色「{{name}}」的功能权限,所有绑定该角色的管理员会立即生效。",
|
||||||
|
"confirmSaveRoleTitle": "确认保存角色信息?",
|
||||||
|
"confirmSaveRoleCreateDescription": "将创建新角色 {{name}}。",
|
||||||
|
"confirmSaveRoleEditDescription": "将更新角色 {{name}} 的名称、说明与状态。",
|
||||||
"permissionGroups": {
|
"permissionGroups": {
|
||||||
"all": "全部权限",
|
"all": "全部权限",
|
||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表盘",
|
||||||
@@ -159,6 +169,7 @@
|
|||||||
"prd.rebate.view": "佣金/回水·查看",
|
"prd.rebate.view": "佣金/回水·查看",
|
||||||
"prd.jackpot.manage": "奖池配置·可管理",
|
"prd.jackpot.manage": "奖池配置·可管理",
|
||||||
"prd.jackpot.view": "奖池配置·查看",
|
"prd.jackpot.view": "奖池配置·查看",
|
||||||
|
"prd.jackpot.manual_burst": "奖池手动爆池·仅超管",
|
||||||
"prd.payout.manage": "派彩确认·可管理",
|
"prd.payout.manage": "派彩确认·可管理",
|
||||||
"prd.payout.review": "派彩确认·可审核",
|
"prd.payout.review": "派彩确认·可审核",
|
||||||
"prd.payout.view": "派彩确认·查看",
|
"prd.payout.view": "派彩确认·查看",
|
||||||
|
|||||||
@@ -26,7 +26,31 @@
|
|||||||
"createTask": "创建任务",
|
"createTask": "创建任务",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"done": "完成",
|
"done": "完成",
|
||||||
"exportExcel": "导出 Excel"
|
"exportExcel": "导出 Excel",
|
||||||
|
"save": "保存修改",
|
||||||
|
"updateSuccess": "更新成功",
|
||||||
|
"updateFailed": "更新失败",
|
||||||
|
"updatePassword": "更新密码"
|
||||||
|
},
|
||||||
|
"accountSettings": "账号设置",
|
||||||
|
"accountSettingsDesc": "管理您的基本账号资料及安全设置。",
|
||||||
|
"profileSettings": "基本资料",
|
||||||
|
"profileSettingsDesc": "更新您的显示名称。",
|
||||||
|
"securitySettings": "安全设置",
|
||||||
|
"securitySettingsDesc": "修改您的登录密码。如不修改请留空。",
|
||||||
|
"fields": {
|
||||||
|
"nickname": "昵称",
|
||||||
|
"newPassword": "新密码",
|
||||||
|
"confirmPassword": "确认密码"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"nickname": "请输入昵称",
|
||||||
|
"password": "请输入新密码",
|
||||||
|
"confirmPassword": "请再次输入新密码"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "请填写{{field}}",
|
||||||
|
"passwordMismatch": "两次输入的密码不一致"
|
||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
@@ -59,7 +83,16 @@
|
|||||||
"date": {
|
"date": {
|
||||||
"placeholder": "选择日期",
|
"placeholder": "选择日期",
|
||||||
"rangePlaceholder": "选择日期范围",
|
"rangePlaceholder": "选择日期范围",
|
||||||
"rangeHint": "先选开始日,再选结束日(单日可对同一天点两次);点「完成」关闭面板。"
|
"rangeHint": "先选开始日,再选结束日(单日可对同一天点两次);点「完成」关闭面板。",
|
||||||
|
"weekdays": {
|
||||||
|
"sunday": "星期日",
|
||||||
|
"monday": "星期一",
|
||||||
|
"tuesday": "星期二",
|
||||||
|
"wednesday": "星期三",
|
||||||
|
"thursday": "星期四",
|
||||||
|
"friday": "星期五",
|
||||||
|
"saturday": "星期六"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"perPage": "每页条数",
|
"perPage": "每页条数",
|
||||||
@@ -76,6 +109,10 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "加载失败"
|
"loadFailed": "加载失败"
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"deniedTitle": "无访问权限",
|
||||||
|
"deniedDescription": "当前账号没有访问此页面的权限。如需开通,请联系管理员在角色管理中分配相应功能权限。"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"id": "ID"
|
"id": "ID"
|
||||||
},
|
},
|
||||||
@@ -98,6 +135,7 @@
|
|||||||
"draws": "期号列表",
|
"draws": "期号列表",
|
||||||
"rules_plays": "投注规则",
|
"rules_plays": "投注规则",
|
||||||
"rules_odds": "赔率与回水",
|
"rules_odds": "赔率与回水",
|
||||||
|
"rules": "投注规则",
|
||||||
"risk_cap": "限额版本",
|
"risk_cap": "限额版本",
|
||||||
"risk": "风控中心",
|
"risk": "风控中心",
|
||||||
"settlement": "结算",
|
"settlement": "结算",
|
||||||
@@ -105,12 +143,18 @@
|
|||||||
"reconcile": "对账",
|
"reconcile": "对账",
|
||||||
"tickets": "注单列表",
|
"tickets": "注单列表",
|
||||||
"audit": "审计日志",
|
"audit": "审计日志",
|
||||||
"settings": "系统设置"
|
"settings": "系统设置",
|
||||||
|
"account": "账号设置"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "工作台"
|
"workspace": "工作台"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "正在校验登录状态…"
|
"checking": "正在校验登录状态…"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认执行",
|
||||||
|
"confirmSave": "确认保存"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,9 @@
|
|||||||
"outMin": "彩票钱包转出主站钱包的单笔下限",
|
"outMin": "彩票钱包转出主站钱包的单笔下限",
|
||||||
"outMax": "彩票钱包转出主站钱包的单笔上限"
|
"outMax": "彩票钱包转出主站钱包的单笔上限"
|
||||||
},
|
},
|
||||||
"discard": "放弃更改"
|
"discard": "放弃更改",
|
||||||
|
"confirmSaveTitle": "确认保存钱包限额?",
|
||||||
|
"confirmSaveDescription": "将更新转入/转出单笔限额,立即影响玩家钱包转账。"
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
"title": "开奖与结算运行参数",
|
"title": "开奖与结算运行参数",
|
||||||
@@ -99,19 +101,25 @@
|
|||||||
"manualReview": "开奖结果必须人工审核",
|
"manualReview": "开奖结果必须人工审核",
|
||||||
"cooldownMinutes": "冷静期时长(分钟)",
|
"cooldownMinutes": "冷静期时长(分钟)",
|
||||||
"autoSettlement": "自动执行结算",
|
"autoSettlement": "自动执行结算",
|
||||||
|
"autoApprove": "自动审核结算批次",
|
||||||
|
"autoPayout": "自动派彩入账",
|
||||||
"playRulesHtml": "玩法规则 HTML(多语言)",
|
"playRulesHtml": "玩法规则 HTML(多语言)",
|
||||||
"playRulesHtmlDesc": "该内容将直接在玩家端的玩法规则页面作为 HTML 渲染。按语言分别配置;留空则回退其它语言或显示默认提示。"
|
"playRulesHtmlDesc": "该内容将直接在玩家端的玩法规则页面作为 HTML 渲染。按语言分别配置;留空则回退其它语言或显示默认提示。"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"manualReview": "开启后,RNG 开奖结果会先进入待审核,必须由后台人工发布。",
|
"manualReview": "开启后,RNG 开奖结果会先进入待审核,必须由后台人工发布。",
|
||||||
"cooldownMinutes": "结果发布后等待多久再进入 settling。填 0 表示发布后直接进入结算。",
|
"cooldownMinutes": "结果发布后等待多久再进入 settling。填 0 表示发布后直接进入结算。",
|
||||||
"autoSettlement": "关闭后,tick 不会自动跑结算,只能由后台手工执行。"
|
"autoSettlement": "关闭后,tick 不会自动跑结算,只能由后台手工执行。",
|
||||||
|
"autoApprove": "冷静期结束并跑完结算后,是否自动将批次标记为已审核。",
|
||||||
|
"autoPayout": "批次已审核后,是否由 tick 自动把中奖金额打入玩家钱包。"
|
||||||
},
|
},
|
||||||
"states": {
|
"states": {
|
||||||
"enabled": "已开启",
|
"enabled": "已开启",
|
||||||
"disabled": "已关闭"
|
"disabled": "已关闭"
|
||||||
},
|
},
|
||||||
"discard": "放弃更改"
|
"discard": "放弃更改",
|
||||||
|
"confirmSaveTitle": "确认保存系统运行参数?",
|
||||||
|
"confirmSaveDescription": "将更新开奖审核、冷静期、自动结算/审核/派彩及玩法规则展示,可能影响全站运行。"
|
||||||
},
|
},
|
||||||
"currencies": {
|
"currencies": {
|
||||||
"title": "币种管理",
|
"title": "币种管理",
|
||||||
@@ -173,9 +181,23 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"minMaxInvalid": "{{playCode}}:最小下注额不能大于最大下注额",
|
"minMaxInvalid": "{{playCode}}:最小下注额不能大于最大下注额",
|
||||||
"nameZhRequired": "中文显示名称不能为空"
|
"displayNameRequired": "显示名称不能为空"
|
||||||
},
|
},
|
||||||
"publishFailed": "发布失败",
|
"publishFailed": "发布失败",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "确认发布玩法配置?",
|
||||||
|
"description": "新配置将影响后续下注;已下注注单仍按各自快照结算。",
|
||||||
|
"confirm": "确认发布"
|
||||||
|
},
|
||||||
|
"batchSwitchConfirmTitle": "确认批量{{action}}?",
|
||||||
|
"batchSwitchConfirmDescription": "将{{action}}「{{group}}」下 {{count}} 个玩法,并写入当前草稿。",
|
||||||
|
"batchSwitchEnable": "开启",
|
||||||
|
"batchSwitchDisable": "关闭",
|
||||||
|
"toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}?",
|
||||||
|
"toggleConfirmDescription": "将立即调用接口生效(不仅限于草稿)。",
|
||||||
|
"toggleEnable": "开启",
|
||||||
|
"toggleDisable": "关闭",
|
||||||
|
"toggleInstantFailed": "玩法开关即时生效失败,请稍后重试",
|
||||||
"createDraftSuccess": "已创建草稿 v{{version}}",
|
"createDraftSuccess": "已创建草稿 v{{version}}",
|
||||||
"createDraftFailed": "创建草稿失败",
|
"createDraftFailed": "创建草稿失败",
|
||||||
"ruleSavedLocal": "规则文案已写入本地草稿,记得保存草稿后再发布。",
|
"ruleSavedLocal": "规则文案已写入本地草稿,记得保存草稿后再发布。",
|
||||||
@@ -191,7 +213,7 @@
|
|||||||
"enable": "开启",
|
"enable": "开启",
|
||||||
"disable": "关闭",
|
"disable": "关闭",
|
||||||
"ruleText": "规则文案",
|
"ruleText": "规则文案",
|
||||||
"displayNames": "多语言名称"
|
"editDisplayName": "编辑名称"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
"zh": "中文",
|
"zh": "中文",
|
||||||
@@ -217,8 +239,8 @@
|
|||||||
"enablePlay": "切换 {{playCode}} 启用状态"
|
"enablePlay": "切换 {{playCode}} 启用状态"
|
||||||
},
|
},
|
||||||
"nameDialog": {
|
"nameDialog": {
|
||||||
"title": "显示名称(多语言)",
|
"title": "编辑显示名称",
|
||||||
"description": "玩法 {{playCode}};中文必填,英文与尼泊尔语可选。保存草稿并发布后,前台按玩家语言展示。",
|
"description": "玩法 {{playCode}};保存草稿并发布后,玩家端将展示该名称。",
|
||||||
"apply": "应用到草稿",
|
"apply": "应用到草稿",
|
||||||
"savedLocal": "显示名称已写入本地草稿,记得保存草稿后再发布。"
|
"savedLocal": "显示名称已写入本地草稿,记得保存草稿后再发布。"
|
||||||
},
|
},
|
||||||
@@ -228,6 +250,13 @@
|
|||||||
"apply": "应用到草稿"
|
"apply": "应用到草稿"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"prizeScopes": {
|
||||||
|
"first": "头奖赔率",
|
||||||
|
"second": "二奖赔率",
|
||||||
|
"third": "三奖赔率",
|
||||||
|
"starter": "特别奖赔率",
|
||||||
|
"consolation": "安慰奖赔率"
|
||||||
|
},
|
||||||
"odds": {
|
"odds": {
|
||||||
"sectionHint": "选择版本后可编辑各奖级赔率;发布后立即作用于新注单。",
|
"sectionHint": "选择版本后可编辑各奖级赔率;发布后立即作用于新注单。",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
@@ -273,6 +302,11 @@
|
|||||||
"publishLabel": "发布",
|
"publishLabel": "发布",
|
||||||
"publishSuccess": "已发布带回水的赔率版本",
|
"publishSuccess": "已发布带回水的赔率版本",
|
||||||
"publishFailed": "发布失败",
|
"publishFailed": "发布失败",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "确认发布回水/赔率版本?",
|
||||||
|
"description": "发布后将影响后续新注单的回水计算。",
|
||||||
|
"confirm": "确认发布"
|
||||||
|
},
|
||||||
"createDraftSuccess": "已创建草稿 v{{version}}",
|
"createDraftSuccess": "已创建草稿 v{{version}}",
|
||||||
"createDraftFailed": "创建草稿失败",
|
"createDraftFailed": "创建草稿失败",
|
||||||
"deleteFailed": "删除失败",
|
"deleteFailed": "删除失败",
|
||||||
@@ -297,6 +331,11 @@
|
|||||||
"enterValidCapAmount": "请输入有效的封顶金额"
|
"enterValidCapAmount": "请输入有效的封顶金额"
|
||||||
},
|
},
|
||||||
"publishFailed": "发布失败",
|
"publishFailed": "发布失败",
|
||||||
|
"publishDialog": {
|
||||||
|
"title": "确认发布封顶配置?",
|
||||||
|
"description": "发布后将影响各号码的风险池封顶额度。",
|
||||||
|
"confirm": "确认发布"
|
||||||
|
},
|
||||||
"createDraftSuccess": "已创建草稿 v{{version}}",
|
"createDraftSuccess": "已创建草稿 v{{version}}",
|
||||||
"createDraftFailed": "创建草稿失败",
|
"createDraftFailed": "创建草稿失败",
|
||||||
"savedLocalDraft": "已写入本地草稿,记得保存草稿后再发布。",
|
"savedLocalDraft": "已写入本地草稿,记得保存草稿后再发布。",
|
||||||
|
|||||||
@@ -2,7 +2,65 @@
|
|||||||
"title": "仪表盘",
|
"title": "仪表盘",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"notice": "提示",
|
"notice": "提示",
|
||||||
"todayBetTotal": "当期投注总额",
|
"sections": {
|
||||||
|
"today": "今日概览",
|
||||||
|
"lifetime": "历史累计",
|
||||||
|
"currentDraw": "当前期号",
|
||||||
|
"currentDrawDetail": "当期明细 · {{drawNo}}",
|
||||||
|
"operations": "运营监控(当期)"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "财务分析",
|
||||||
|
"periodLabel": "统计区间",
|
||||||
|
"metricLabel": "指标类型",
|
||||||
|
"playLabel": "玩法筛选",
|
||||||
|
"allPlays": "全部玩法",
|
||||||
|
"customRange": "自定义日期",
|
||||||
|
"rangeHint": "区间 {{range}}",
|
||||||
|
"selectPeriod": "选择统计区间",
|
||||||
|
"chartTruncated": "趋势图仅展示最近区间 {{from}} — {{to}}(全区间共 {{days}} 天)",
|
||||||
|
"summaryBet": "区间下注",
|
||||||
|
"summaryPayout": "区间派彩",
|
||||||
|
"summaryProfit": "区间盈亏",
|
||||||
|
"dailyTrend": "每日趋势",
|
||||||
|
"playBreakdown": "玩法拆解 Top",
|
||||||
|
"periodDistribution": "区间结构对比",
|
||||||
|
"noPlayData": "该区间暂无玩法数据",
|
||||||
|
"periods": {
|
||||||
|
"today": "今日",
|
||||||
|
"last_7_days": "近 7 天",
|
||||||
|
"last_30_days": "近 30 天",
|
||||||
|
"this_month": "本月",
|
||||||
|
"lifetime": "全部历史",
|
||||||
|
"custom": "自定义"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"overview": "综合",
|
||||||
|
"bet": "投注",
|
||||||
|
"payout": "派彩",
|
||||||
|
"profit": "盈亏"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chartLegend": {
|
||||||
|
"bet": "投注",
|
||||||
|
"payout": "派彩",
|
||||||
|
"profit": "盈亏"
|
||||||
|
},
|
||||||
|
"playBreakdownHint": "派彩 {{payout}} · 盈亏 {{profit}}",
|
||||||
|
"viewReports": "报表中心",
|
||||||
|
"lifetimeBetTotal": "累计下注",
|
||||||
|
"lifetimePayout": "累计派彩",
|
||||||
|
"lifetimeProfit": "累计平台盈亏",
|
||||||
|
"lifetimeActivityHint": "{{draws}} 期有投注 · {{days}} 个业务日",
|
||||||
|
"lifetimeDateRangeHint": "统计区间 {{range}}",
|
||||||
|
"currentDrawBetTotal": "当期投注",
|
||||||
|
"currentDrawPayout": "当期派彩",
|
||||||
|
"currentDrawProfit": "当期盈亏",
|
||||||
|
"drawFinanceDetails": "期号财务详情",
|
||||||
|
"todayBetTotal": "今日下注总额",
|
||||||
|
"todayPayout": "今日派彩",
|
||||||
|
"todayProfit": "今日盈亏",
|
||||||
|
"todayBusinessDateHint": "业务日 {{date}}",
|
||||||
"drawNoHint": "期号 {{drawNo}}",
|
"drawNoHint": "期号 {{drawNo}}",
|
||||||
"orderAndTicket": "{{orders}} 单 · {{tickets}} 笔",
|
"orderAndTicket": "{{orders}} 单 · {{tickets}} 笔",
|
||||||
"marginRate": "毛利率约 {{rate}}%",
|
"marginRate": "毛利率约 {{rate}}%",
|
||||||
@@ -23,6 +81,7 @@
|
|||||||
"quickLinksTitle": "快捷入口",
|
"quickLinksTitle": "快捷入口",
|
||||||
"currentPayout": "当期派彩",
|
"currentPayout": "当期派彩",
|
||||||
"currentProfit": "当期平台盈亏",
|
"currentProfit": "当期平台盈亏",
|
||||||
|
"currentDrawFinanceHint": "下方图表为当期 {{drawNo}}",
|
||||||
"currentDraw": "当前期号",
|
"currentDraw": "当前期号",
|
||||||
"drawSequence": "第 {{sequence}} 期",
|
"drawSequence": "第 {{sequence}} 期",
|
||||||
"drawDetails": "期号详情",
|
"drawDetails": "期号详情",
|
||||||
@@ -64,8 +123,9 @@
|
|||||||
"auditLogs": "审计日志"
|
"auditLogs": "审计日志"
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"drawPermission": "当前账号无开奖查看/管理权限,财务与风控数据未返回。",
|
"drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。",
|
||||||
"walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。",
|
"walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。",
|
||||||
"loadFailed": "加载失败,请检查 API 与登录状态。"
|
"loadFailed": "加载失败,请检查 API 与登录状态。",
|
||||||
|
"apiResourceMissing": "仪表盘分析接口未注册。请在服务端执行:php artisan lottery:admin-auth-sync,或运行最新数据库迁移后重试。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,5 +144,23 @@
|
|||||||
"third": "三奖",
|
"third": "三奖",
|
||||||
"starter": "特别奖 {{index}}",
|
"starter": "特别奖 {{index}}",
|
||||||
"consolation": "安慰奖 {{index}}"
|
"consolation": "安慰奖 {{index}}"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"manualCloseTitle": "确认手动封盘?",
|
||||||
|
"manualCloseDescription": "封盘后玩家将无法继续对该期下注。",
|
||||||
|
"cancelDrawTitle": "确认取消期号?",
|
||||||
|
"cancelDrawDescription": "取消后该期将不再开奖,请确认无未处理注单风险。",
|
||||||
|
"rngDrawTitle": "确认 RNG 自动生成开奖?",
|
||||||
|
"rngDrawDescription": "将按系统规则生成本期开奖号码并进入后续流程。",
|
||||||
|
"reopenTitle": "确认冷静期重开?",
|
||||||
|
"reopenDescription": "重开后需重新审核/发布结果,可能影响已展示的开奖信息。",
|
||||||
|
"runSettlementTitle": "确认触发结算?",
|
||||||
|
"runSettlementDescription": "将按已发布开奖结果生成本期结算批次。",
|
||||||
|
"saveManualDraftTitle": "确认保存人工开奖草稿?",
|
||||||
|
"saveManualDraftDescription": "将写入 23 个开奖号码草稿,提交后进入审核流程。",
|
||||||
|
"publishTitle": "确认发布开奖结果?",
|
||||||
|
"publishDescription": "发布后将对玩家可见并可能触发结算,请再次核对号码。",
|
||||||
|
"generatePlanTitle": "确认批量生成期号计划?",
|
||||||
|
"generatePlanDescription": "将按系统规则补充未来可下注期号。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,16 @@
|
|||||||
"enabled": "开启",
|
"enabled": "开启",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
"confirmSavePoolTitle": "确认保存奖池配置?",
|
||||||
|
"confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数,可能影响后续 Jackpot 行为。",
|
||||||
"manualBurstDrawId": "手动爆池期号 ID",
|
"manualBurstDrawId": "手动爆池期号 ID",
|
||||||
"manualBurstAmount": "爆池金额(空为全部)",
|
"manualBurstHint": "仅超级管理员可在紧急情况下触发;须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。",
|
||||||
|
"manualBurstConfirmTitle": "确认手动爆池?",
|
||||||
|
"manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot,并扣减奖池余额。此操作不可自动撤销。",
|
||||||
"processing": "处理中…",
|
"processing": "处理中…",
|
||||||
"manualBurst": "手动爆池",
|
"manualBurst": "手动触发爆池(仅超管)",
|
||||||
|
"manualBurstConfirm": "确认爆池",
|
||||||
|
"cancel": "取消",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
"drawNo": "期号",
|
"drawNo": "期号",
|
||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
"lastLogin": "最后登录",
|
"lastLogin": "最后登录",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
|
"freeze": "冻结",
|
||||||
|
"unfreeze": "解冻",
|
||||||
|
"freezeSuccess": "已冻结玩家 {{name}}",
|
||||||
|
"unfreezeSuccess": "已解冻玩家 {{name}}",
|
||||||
|
"freezeFailed": "冻结失败",
|
||||||
|
"unfreezeFailed": "解冻失败",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"createDialogTitle": "新建玩家",
|
"createDialogTitle": "新建玩家",
|
||||||
"editDialogTitle": "编辑玩家",
|
"editDialogTitle": "编辑玩家",
|
||||||
@@ -44,6 +50,10 @@
|
|||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
|
"confirmFreezeTitle": "确认冻结玩家?",
|
||||||
|
"confirmFreezeDescription": "冻结后玩家 {{name}} 将无法下注。",
|
||||||
|
"confirmUnfreezeTitle": "确认解冻玩家?",
|
||||||
|
"confirmUnfreezeDescription": "解冻后玩家 {{name}} 将恢复正常。",
|
||||||
"confirmDelete": "确认删除",
|
"confirmDelete": "确认删除",
|
||||||
"confirmDeleteDesc": "确定要删除玩家 {{name}} 吗?此操作不可恢复。"
|
"confirmDeleteDesc": "确定要删除玩家 {{name}} 吗?此操作不可恢复。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
"periodRequired": "请填写对账日期范围(开始与结束)",
|
"periodRequired": "请填写对账日期范围(开始与结束)",
|
||||||
"periodInvalid": "日期无效,请检查所选日期",
|
"periodInvalid": "日期无效,请检查所选日期",
|
||||||
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
|
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
|
||||||
|
"confirmCreateTitle": "确认创建对账任务?",
|
||||||
|
"confirmCreateDescription": "将按所选日期范围{{playerHint}}发起人工对账。",
|
||||||
|
"confirmCreatePlayer": "及指定玩家",
|
||||||
|
"confirmCreateAllPlayers": "(全量玩家)",
|
||||||
"createSuccess": "已创建对账任务",
|
"createSuccess": "已创建对账任务",
|
||||||
"createFailed": "创建失败",
|
"createFailed": "创建失败",
|
||||||
"noCreatePermission": "当前账号无新建对账任务权限。",
|
"noCreatePermission": "当前账号无新建对账任务权限。",
|
||||||
|
|||||||
@@ -46,6 +46,12 @@
|
|||||||
"manualCloseSuccess": "已手动关闭号码下注",
|
"manualCloseSuccess": "已手动关闭号码下注",
|
||||||
"recoverSuccess": "已恢复号码下注",
|
"recoverSuccess": "已恢复号码下注",
|
||||||
"actionFailed": "操作失败",
|
"actionFailed": "操作失败",
|
||||||
|
"confirm": {
|
||||||
|
"closeTitle": "确认关闭该号码下注?",
|
||||||
|
"closeDescription": "号码 {{number}} 在本期将被禁止下注。",
|
||||||
|
"recoverTitle": "确认恢复该号码下注?",
|
||||||
|
"recoverDescription": "号码 {{number}} 将恢复为可下注状态。"
|
||||||
|
},
|
||||||
"detailTitle": "风险池详情",
|
"detailTitle": "风险池详情",
|
||||||
"loadDetailFailed": "加载风险池详情失败",
|
"loadDetailFailed": "加载风险池详情失败",
|
||||||
"backToList": "返回列表",
|
"backToList": "返回列表",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"pending_confirm": "待确认",
|
"pending_confirm": "待确认",
|
||||||
"partial_pending_confirm": "部分待确认",
|
"partial_pending_confirm": "部分待确认",
|
||||||
"success": "已投注成功",
|
"success": "已投注成功",
|
||||||
|
"pending_draw": "待开奖",
|
||||||
"failed": "投注失败",
|
"failed": "投注失败",
|
||||||
"pending_payout": "待派奖",
|
"pending_payout": "待派奖",
|
||||||
"settled_win": "已中奖结算",
|
"settled_win": "已中奖结算",
|
||||||
|
|||||||
@@ -47,6 +47,12 @@
|
|||||||
"reverseSuccess": "冲正成功",
|
"reverseSuccess": "冲正成功",
|
||||||
"manualProcessSuccess": "人工处理成功",
|
"manualProcessSuccess": "人工处理成功",
|
||||||
"actionFailed": "操作失败",
|
"actionFailed": "操作失败",
|
||||||
|
"confirm": {
|
||||||
|
"reverseTitle": "确认冲正转账单?",
|
||||||
|
"reverseDescription": "将对单号 {{transferNo}} 执行冲正,可能影响玩家钱包余额。",
|
||||||
|
"manualProcessTitle": "确认人工处理?",
|
||||||
|
"manualProcessDescription": "将标记单号 {{transferNo}} 为已人工处理,不会自动调整钱包。"
|
||||||
|
},
|
||||||
"txnNo": "流水号",
|
"txnNo": "流水号",
|
||||||
"bizType": "类型(业务)",
|
"bizType": "类型(业务)",
|
||||||
"type": "类型",
|
"type": "类型",
|
||||||
|
|||||||
@@ -23,6 +23,35 @@ function formatParts(date: Date, timeZone?: string): string {
|
|||||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_KEYS = [
|
||||||
|
"sunday",
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AdminWeekdayKey = (typeof WEEKDAY_KEYS)[number];
|
||||||
|
|
||||||
|
export function adminWeekdayKeyForDate(date: Date = new Date()): AdminWeekdayKey {
|
||||||
|
return WEEKDAY_KEYS[date.getDay()] ?? "sunday";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘顶栏日期:数字日期 + i18n 星期(避免 Intl 在 ne 等语言下回退到系统中文)。
|
||||||
|
*/
|
||||||
|
export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: string): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
const datePart = locale === "en" ? `${m}/${day}/${y}` : `${y}-${m}-${day}`;
|
||||||
|
|
||||||
|
return `${datePart} ${weekdayLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将接口返回的 ISO 时间串格式化为浏览器本地时区下的 `YYYY-MM-DD HH:mm:ss`。
|
* 将接口返回的 ISO 时间串格式化为浏览器本地时区下的 `YYYY-MM-DD HH:mm:ss`。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
|||||||
"/admin/jackpot": { ns: "jackpot", key: "configTitle" },
|
"/admin/jackpot": { ns: "jackpot", key: "configTitle" },
|
||||||
"/admin/risk/cap": { ns: "config", key: "nav.riskCapTitle" },
|
"/admin/risk/cap": { ns: "config", key: "nav.riskCapTitle" },
|
||||||
"/admin/login": { ns: "auth", key: "title" },
|
"/admin/login": { ns: "auth", key: "title" },
|
||||||
|
"/admin/account": { ns: "common", key: "accountSettings" },
|
||||||
};
|
};
|
||||||
|
|
||||||
type RoutePattern = {
|
type RoutePattern = {
|
||||||
|
|||||||
@@ -44,32 +44,10 @@ export function getAdminPlayTypesLoadPromise(
|
|||||||
return inflightLoad;
|
return inflightLoad;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickDisplayName(row: AdminPlayTypeRow, language: string): string | null {
|
/** 解析玩法显示名;无配置时回退 play_code */
|
||||||
const lang = language.split("-")[0]?.toLowerCase() ?? "zh";
|
|
||||||
|
|
||||||
if (lang === "en" && row.display_name_en?.trim()) {
|
|
||||||
return row.display_name_en.trim();
|
|
||||||
}
|
|
||||||
if (lang === "ne" && row.display_name_ne?.trim()) {
|
|
||||||
return row.display_name_ne.trim();
|
|
||||||
}
|
|
||||||
if (row.display_name_zh?.trim()) {
|
|
||||||
return row.display_name_zh.trim();
|
|
||||||
}
|
|
||||||
if (row.display_name_en?.trim()) {
|
|
||||||
return row.display_name_en.trim();
|
|
||||||
}
|
|
||||||
if (row.display_name_ne?.trim()) {
|
|
||||||
return row.display_name_ne.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按当前语言解析玩法显示名;无配置时回退 play_code */
|
|
||||||
export function resolveAdminPlayTypeDisplayName(
|
export function resolveAdminPlayTypeDisplayName(
|
||||||
playCode: string | null | undefined,
|
playCode: string | null | undefined,
|
||||||
language: string,
|
_language?: string,
|
||||||
row?: AdminPlayTypeRow,
|
row?: AdminPlayTypeRow,
|
||||||
): string {
|
): string {
|
||||||
if (playCode == null || playCode === "") {
|
if (playCode == null || playCode === "") {
|
||||||
@@ -81,13 +59,14 @@ export function resolveAdminPlayTypeDisplayName(
|
|||||||
return playCode;
|
return playCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pickDisplayName(resolved, language) ?? playCode;
|
const name = resolved.display_name?.trim();
|
||||||
|
return name ? name : playCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 表格展示:显示名 + 编码(与报表筛选一致) */
|
/** 表格展示:显示名 + 编码(与报表筛选一致) */
|
||||||
export function formatAdminPlayCodeLabel(
|
export function formatAdminPlayCodeLabel(
|
||||||
playCode: string | null | undefined,
|
playCode: string | null | undefined,
|
||||||
language: string,
|
language?: string,
|
||||||
): string {
|
): string {
|
||||||
if (playCode == null || playCode === "") {
|
if (playCode == null || playCode === "") {
|
||||||
return "—";
|
return "—";
|
||||||
|
|||||||
112
src/lib/admin-prd.ts
Normal file
112
src/lib/admin-prd.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/** 与 Laravel {@see AdminAuthorizationRegistry} 中 `prd.*` slug 对齐 */
|
||||||
|
|
||||||
|
export const PRD_ADMIN_USER_MANAGE = "prd.admin_user.manage" as const;
|
||||||
|
export const PRD_ADMIN_ROLE_MANAGE = "prd.admin_role.manage" as const;
|
||||||
|
|
||||||
|
export const PRD_USERS_MANAGE = "prd.users.manage" as const;
|
||||||
|
export const PRD_USERS_VIEW_FINANCE = "prd.users.view_finance" as const;
|
||||||
|
export const PRD_USERS_VIEW_CS = "prd.users.view_cs" as const;
|
||||||
|
export const PRD_PLAYER_FREEZE_MANAGE = "prd.player_freeze.manage" as const;
|
||||||
|
|
||||||
|
export const PRD_CURRENCY_MANAGE = "prd.currency.manage" as const;
|
||||||
|
|
||||||
|
export const PRD_WALLET_RECONCILE_MANAGE = "prd.wallet_reconcile.manage" as const;
|
||||||
|
export const PRD_WALLET_RECONCILE_VIEW = "prd.wallet_reconcile.view" as const;
|
||||||
|
export const PRD_WALLET_RECONCILE_VIEW_CS = "prd.wallet_reconcile.view_cs" as const;
|
||||||
|
export const PRD_WALLET_ADJUST_MANAGE = "prd.wallet_adjust.manage" as const;
|
||||||
|
|
||||||
|
export const PRD_DRAW_RESULT_MANAGE = "prd.draw_result.manage" as const;
|
||||||
|
export const PRD_DRAW_RESULT_VIEW = "prd.draw_result.view" as const;
|
||||||
|
export const PRD_DRAW_REOPEN_MANAGE = "prd.draw_reopen.manage" as const;
|
||||||
|
|
||||||
|
export const PRD_PLAY_SWITCH_MANAGE = "prd.play_switch.manage" as const;
|
||||||
|
export const PRD_ODDS_MANAGE = "prd.odds.manage" as const;
|
||||||
|
export const PRD_REBATE_MANAGE = "prd.rebate.manage" as const;
|
||||||
|
export const PRD_REBATE_VIEW = "prd.rebate.view" as const;
|
||||||
|
export const PRD_RISK_CAP_MANAGE = "prd.risk_cap.manage" as const;
|
||||||
|
export const PRD_RISK_CAP_VIEW = "prd.risk_cap.view" as const;
|
||||||
|
export const PRD_JACKPOT_MANAGE = "prd.jackpot.manage" as const;
|
||||||
|
export const PRD_JACKPOT_VIEW = "prd.jackpot.view" as const;
|
||||||
|
/** 超管紧急手动爆池(产品文档 §5.13) */
|
||||||
|
export const PRD_JACKPOT_MANUAL_BURST = "prd.jackpot.manual_burst" as const;
|
||||||
|
|
||||||
|
export const PRD_PAYOUT_MANAGE = "prd.payout.manage" as const;
|
||||||
|
export const PRD_PAYOUT_REVIEW = "prd.payout.review" as const;
|
||||||
|
export const PRD_PAYOUT_VIEW = "prd.payout.view" as const;
|
||||||
|
|
||||||
|
export const PRD_AUDIT_VIEW = "prd.audit.view" as const;
|
||||||
|
export const PRD_DASHBOARD_VIEW = "prd.dashboard.view" as const;
|
||||||
|
export const PRD_REPORT_VIEW = "prd.report.view" as const;
|
||||||
|
export const PRD_REPORT_EXPORT = "prd.report.export" as const;
|
||||||
|
export const PRD_TICKETS_VIEW = "prd.tickets.view" as const;
|
||||||
|
export const PRD_RISK_VIEW = "prd.risk.view" as const;
|
||||||
|
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,
|
||||||
|
PRD_WALLET_RECONCILE_MANAGE,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 玩家列表页(与侧栏 requiredAny 一致) */
|
||||||
|
export const PRD_PLAYERS_ACCESS_ANY = [
|
||||||
|
PRD_USERS_MANAGE,
|
||||||
|
PRD_USERS_VIEW_FINANCE,
|
||||||
|
PRD_USERS_VIEW_CS,
|
||||||
|
PRD_PLAYER_FREEZE_MANAGE,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 注单列表页 */
|
||||||
|
export const PRD_TICKETS_ACCESS_ANY = [PRD_TICKETS_VIEW] as const;
|
||||||
|
|
||||||
|
/** 仪表盘 */
|
||||||
|
export const PRD_DASHBOARD_ACCESS_ANY = [PRD_DASHBOARD_VIEW] as const;
|
||||||
|
|
||||||
|
/** 风控中心(含期号内风控页) */
|
||||||
|
export const PRD_RISK_ACCESS_ANY = [
|
||||||
|
PRD_RISK_VIEW,
|
||||||
|
PRD_RISK_MANAGE,
|
||||||
|
PRD_DRAW_RESULT_VIEW,
|
||||||
|
PRD_DRAW_RESULT_MANAGE,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 报表查看 / 导出 */
|
||||||
|
export const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
|
||||||
|
export const PRD_REPORTS_EXPORT_ACCESS_ANY = [PRD_REPORT_EXPORT] as const;
|
||||||
|
|
||||||
|
/** 钱包流水 */
|
||||||
|
export const PRD_WALLET_TX_ACCESS_ANY = [
|
||||||
|
PRD_WALLET_RECONCILE_MANAGE,
|
||||||
|
PRD_WALLET_RECONCILE_VIEW,
|
||||||
|
PRD_WALLET_RECONCILE_VIEW_CS,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 转账订单 */
|
||||||
|
export const PRD_WALLET_TRANSFER_ACCESS_ANY = [
|
||||||
|
...PRD_WALLET_TX_ACCESS_ANY,
|
||||||
|
PRD_WALLET_ADJUST_MANAGE,
|
||||||
|
PRD_USERS_MANAGE,
|
||||||
|
PRD_USERS_VIEW_FINANCE,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 单玩家钱包查询 */
|
||||||
|
export const PRD_WALLET_PLAYER_ACCESS_ANY = [
|
||||||
|
PRD_USERS_MANAGE,
|
||||||
|
PRD_USERS_VIEW_FINANCE,
|
||||||
|
...PRD_WALLET_TX_ACCESS_ANY,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 赔率与回水配置页 */
|
||||||
|
export const PRD_RULES_ODDS_ACCESS_ANY = [
|
||||||
|
PRD_ODDS_MANAGE,
|
||||||
|
PRD_ODDS_VIEW,
|
||||||
|
PRD_REBATE_MANAGE,
|
||||||
|
PRD_REBATE_VIEW,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 封顶配置页 */
|
||||||
|
export const PRD_RISK_CAP_ACCESS_ANY = [PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW] as const;
|
||||||
|
|
||||||
|
/** Jackpot 配置页 */
|
||||||
|
export const PRD_JACKPOT_ACCESS_ANY = [PRD_JACKPOT_MANAGE, PRD_JACKPOT_VIEW] as const;
|
||||||
@@ -42,4 +42,17 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 旧版 localStorage / 接口缓存中的 segment,避免首屏侧栏崩溃 */
|
||||||
|
const legacyAdminNavIconBySegment: Record<string, LucideIcon> = {
|
||||||
|
config: SlidersHorizontal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveAdminNavIcon(segment: string): LucideIcon {
|
||||||
|
return (
|
||||||
|
adminNavIconBySegment[segment as AdminNavItem["segment"]] ??
|
||||||
|
legacyAdminNavIconBySegment[segment] ??
|
||||||
|
LayoutDashboard
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { LogIn };
|
export { LogIn };
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ export function AccountSettingsConsole() {
|
|||||||
|
|
||||||
async function handleUpdateProfile() {
|
async function handleUpdateProfile() {
|
||||||
if (!nickname.trim()) {
|
if (!nickname.trim()) {
|
||||||
toast.error(t("validation.required", { field: t("fields.nickname", { defaultValue: "昵称" }) }));
|
toast.error(t("validation.required", { field: t("fields.nickname") }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await putAdminMe({ nickname: nickname.trim() });
|
await putAdminMe({ nickname: nickname.trim() });
|
||||||
toast.success(t("actions.updateSuccess", { defaultValue: "更新成功" }));
|
toast.success(t("actions.updateSuccess"));
|
||||||
void refreshAdminProfile();
|
void refreshAdminProfile();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed", { defaultValue: "更新失败" }));
|
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -49,21 +49,21 @@ export function AccountSettingsConsole() {
|
|||||||
|
|
||||||
async function handleUpdatePassword() {
|
async function handleUpdatePassword() {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
toast.error(t("validation.required", { field: t("fields.newPassword", { defaultValue: "新密码" }) }));
|
toast.error(t("validation.required", { field: t("fields.newPassword") }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
toast.error(t("validation.passwordMismatch", { defaultValue: "两次输入的密码不一致" }));
|
toast.error(t("validation.passwordMismatch"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await putAdminMe({ password });
|
await putAdminMe({ password });
|
||||||
toast.success(t("actions.updateSuccess", { defaultValue: "更新成功" }));
|
toast.success(t("actions.updateSuccess"));
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed", { defaultValue: "更新失败" }));
|
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -73,68 +73,68 @@ export function AccountSettingsConsole() {
|
|||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4 md:p-6 lg:p-8">
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4 md:p-6 lg:p-8">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-xl font-semibold tracking-tight text-[#13315f]">
|
<h1 className="text-xl font-semibold tracking-tight text-[#13315f]">
|
||||||
{t("accountSettings", { defaultValue: "账号设置" })}
|
{t("accountSettings")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("accountSettingsDesc", { defaultValue: "管理您的基本账号资料及安全设置。" })}
|
{t("accountSettingsDesc")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{t("profileSettings", { defaultValue: "基本资料" })}</CardTitle>
|
<CardTitle className="text-base">{t("profileSettings")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("profileSettingsDesc", { defaultValue: "更新您的显示名称。" })}
|
{t("profileSettingsDesc")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 max-w-md">
|
<CardContent className="space-y-4 max-w-md">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="nickname">{t("fields.nickname", { defaultValue: "昵称" })}</Label>
|
<Label htmlFor="nickname">{t("fields.nickname")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="nickname"
|
id="nickname"
|
||||||
value={nickname}
|
value={nickname}
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
placeholder={t("placeholders.nickname", { defaultValue: "请输入昵称" })}
|
placeholder={t("placeholders.nickname")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleUpdateProfile} disabled={loading}>
|
<Button onClick={handleUpdateProfile} disabled={loading}>
|
||||||
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
|
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
{t("actions.save", { defaultValue: "保存修改" })}
|
{t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{t("securitySettings", { defaultValue: "安全设置" })}</CardTitle>
|
<CardTitle className="text-base">{t("securitySettings")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("securitySettingsDesc", { defaultValue: "修改您的登录密码。如不修改请留空。" })}
|
{t("securitySettingsDesc")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 max-w-md">
|
<CardContent className="space-y-4 max-w-md">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="password">{t("fields.newPassword", { defaultValue: "新密码" })}</Label>
|
<Label htmlFor="password">{t("fields.newPassword")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder={t("placeholders.password", { defaultValue: "请输入新密码" })}
|
placeholder={t("placeholders.password")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="confirm-password">{t("fields.confirmPassword", { defaultValue: "确认密码" })}</Label>
|
<Label htmlFor="confirm-password">{t("fields.confirmPassword")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder={t("placeholders.confirmPassword", { defaultValue: "请再次输入新密码" })}
|
placeholder={t("placeholders.confirmPassword")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleUpdatePassword} disabled={loading || !password}>
|
<Button onClick={handleUpdatePassword} disabled={loading || !password}>
|
||||||
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
|
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||||
{t("actions.updatePassword", { defaultValue: "更新密码" })}
|
{t("actions.updatePassword")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -37,7 +38,10 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_ADMIN_ROLE_MANAGE } from "@/lib/admin-prd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
|
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
@@ -53,6 +57,9 @@ function permissionLabel(slug: string, fallback: string, t: (key: string) => str
|
|||||||
|
|
||||||
export function AdminRolesConsole(): React.ReactElement {
|
export function AdminRolesConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["adminUsers", "common"]);
|
const { t } = useTranslation(["adminUsers", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
|
||||||
const exportLabels = useExportLabels("adminRoles");
|
const exportLabels = useExportLabels("adminRoles");
|
||||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||||
@@ -130,13 +137,13 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
function isDirectGroupOpen(key: string): boolean {
|
function isDirectGroupOpen(key: string): boolean {
|
||||||
return directMenuExpanded[key] !== false;
|
return directMenuExpanded[key] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDirectGroup(key: string): void {
|
function toggleDirectGroup(key: string): void {
|
||||||
setDirectMenuExpanded((prev) => {
|
setDirectMenuExpanded((prev) => {
|
||||||
const wasOpen = prev[key] !== false;
|
const wasOpen = prev[key] === true;
|
||||||
return { ...prev, [key]: wasOpen ? false : true };
|
return { ...prev, [key]: !wasOpen };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,9 +314,11 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<CardTitle>{t("roleListTitle")}</CardTitle>
|
<CardTitle>{t("roleListTitle")}</CardTitle>
|
||||||
<Button type="button" size="sm" onClick={() => openCreateRole()}>
|
{canManageRoles ? (
|
||||||
{t("createRole")}
|
<Button type="button" size="sm" onClick={() => openCreateRole()}>
|
||||||
</Button>
|
{t("createRole")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-list-actions">
|
<div className="admin-list-actions">
|
||||||
<AdminTableExportButton
|
<AdminTableExportButton
|
||||||
@@ -374,23 +383,27 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
||||||
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-1">
|
{canManageRoles ? (
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => openRolePermissionEditor(role)}>
|
<div className="flex flex-wrap gap-1">
|
||||||
{t("roleActions.permissions")}
|
<Button type="button" size="sm" variant="outline" onClick={() => openRolePermissionEditor(role)}>
|
||||||
</Button>
|
{t("roleActions.permissions")}
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => openEditRole(role)}>
|
</Button>
|
||||||
{t("actions.edit")}
|
<Button type="button" size="sm" variant="outline" onClick={() => openEditRole(role)}>
|
||||||
</Button>
|
{t("actions.edit")}
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant="destructive"
|
size="sm"
|
||||||
disabled={role.is_system || role.user_count > 0}
|
variant="destructive"
|
||||||
onClick={() => setRoleDeleteTarget(role)}
|
disabled={role.is_system || role.user_count > 0}
|
||||||
>
|
onClick={() => setRoleDeleteTarget(role)}
|
||||||
{t("actions.delete")}
|
>
|
||||||
</Button>
|
{t("actions.delete")}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@@ -500,7 +513,20 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
<Button type="button" variant="outline" onClick={() => handleRolePermissionDialogOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => handleRolePermissionDialogOpenChange(false)}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={!selectedRole || roleSaving} onClick={() => void saveRolePermissions()}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!selectedRole || roleSaving}
|
||||||
|
onClick={() =>
|
||||||
|
selectedRole &&
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirmSaveRolePermissionsTitle"),
|
||||||
|
description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }),
|
||||||
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
|
confirmVariant: "destructive",
|
||||||
|
onConfirm: () => saveRolePermissions(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{roleSaving ? t("saving") : t("actions.save")}
|
{roleSaving ? t("saving") : t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,7 +566,21 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
<Button type="button" variant="outline" onClick={() => handleRoleDialogOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => handleRoleDialogOpenChange(false)}>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={roleFormSaving} onClick={() => void submitRole()}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={roleFormSaving}
|
||||||
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirmSaveRoleTitle"),
|
||||||
|
description:
|
||||||
|
roleMode === "create"
|
||||||
|
? t("confirmSaveRoleCreateDescription", { name: roleName || roleSlug || "—" })
|
||||||
|
: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
|
||||||
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
|
onConfirm: () => submitRole(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{roleFormSaving ? t("saving") : t("actions.save")}
|
{roleFormSaving ? t("saving") : t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -565,6 +605,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -38,6 +39,8 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_ADMIN_USER_MANAGE } from "@/lib/admin-prd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
|
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
|
||||||
@@ -45,8 +48,10 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
|||||||
|
|
||||||
export function AdminUsersConsole(): React.ReactElement {
|
export function AdminUsersConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["adminUsers", "common"]);
|
const { t } = useTranslation(["adminUsers", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const exportLabels = useExportLabels("adminUsers");
|
const exportLabels = useExportLabels("adminUsers");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
|
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_USER_MANAGE]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
@@ -310,9 +315,11 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
<CardHeader className="admin-list-header flex flex-col gap-4">
|
<CardHeader className="admin-list-header flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
||||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
{canManageUsers ? (
|
||||||
{t("createAdmin")}
|
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||||
</Button>
|
{t("createAdmin")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-list-toolbar">
|
<div className="admin-list-toolbar">
|
||||||
<div className="admin-list-field xl:min-w-0">
|
<div className="admin-list-field xl:min-w-0">
|
||||||
@@ -411,6 +418,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex w-full flex-nowrap justify-center gap-1 whitespace-nowrap">
|
<div className="flex w-full flex-nowrap justify-center gap-1 whitespace-nowrap">
|
||||||
|
{canManageUsers ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -419,6 +427,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
{t("actions.permissions")}
|
{t("actions.permissions")}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageUsers ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -427,6 +437,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
{t("actions.edit")}
|
{t("actions.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManageUsers ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -441,6 +453,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
{t("actions.delete")}
|
{t("actions.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -518,7 +531,15 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
type="button"
|
type="button"
|
||||||
className="w-full shrink-0 sm:w-auto"
|
className="w-full shrink-0 sm:w-auto"
|
||||||
disabled={!selectedUser || savingRoles}
|
disabled={!selectedUser || savingRoles}
|
||||||
onClick={() => void saveRoles()}
|
onClick={() =>
|
||||||
|
selectedUser &&
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirmSaveRolesTitle"),
|
||||||
|
description: t("confirmSaveRolesDescription", { name: selectedUser.username }),
|
||||||
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
|
onConfirm: () => saveRoles(),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
|
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -633,7 +654,23 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={accountSaving} onClick={() => void submitAccount()}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={accountSaving}
|
||||||
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirmSaveAccountTitle"),
|
||||||
|
description:
|
||||||
|
accountMode === "create"
|
||||||
|
? t("confirmSaveAccountCreateDescription")
|
||||||
|
: t("confirmSaveAccountEditDescription", {
|
||||||
|
name: formUsername || "—",
|
||||||
|
}),
|
||||||
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
|
onConfirm: () => submitAccount(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{accountSaving ? t("saving") : t("actions.save")}
|
{accountSaving ? t("saving") : t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,6 +705,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { AdminSectionHeader } from "@/components/admin/admin-section-header";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ConfigSectionProps = {
|
type ConfigSectionProps = {
|
||||||
@@ -22,15 +23,7 @@ export function ConfigSection({
|
|||||||
}: ConfigSectionProps) {
|
}: ConfigSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section id={id} className={cn("scroll-mt-24 space-y-4", className)}>
|
<section id={id} className={cn("scroll-mt-24 space-y-4", className)}>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/60 pb-3">
|
<AdminSectionHeader title={title} description={description} actions={actions} />
|
||||||
<div className="min-w-0 space-y-1">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
|
||||||
{description ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
type ConfigVersionActionsProps = {
|
type ConfigVersionActionsProps = {
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
|
/** 为 false 时仅保留刷新,隐藏新建/保存/发布(只读权限) */
|
||||||
|
canManage?: boolean;
|
||||||
loadingList?: boolean;
|
loadingList?: boolean;
|
||||||
loadingDetail?: boolean;
|
loadingDetail?: boolean;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
@@ -21,6 +23,7 @@ type ConfigVersionActionsProps = {
|
|||||||
|
|
||||||
export function ConfigVersionActions({
|
export function ConfigVersionActions({
|
||||||
isDraft,
|
isDraft,
|
||||||
|
canManage = true,
|
||||||
loadingList = false,
|
loadingList = false,
|
||||||
loadingDetail = false,
|
loadingDetail = false,
|
||||||
saving = false,
|
saving = false,
|
||||||
@@ -41,11 +44,13 @@ export function ConfigVersionActions({
|
|||||||
<RefreshCw className={loadingList ? "size-4 animate-spin" : "size-4"} aria-hidden />
|
<RefreshCw className={loadingList ? "size-4 animate-spin" : "size-4"} aria-hidden />
|
||||||
{loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
|
{loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={saving} onClick={onNewDraft}>
|
{canManage ? (
|
||||||
<Plus className="size-4" aria-hidden />
|
<Button type="button" disabled={saving} onClick={onNewDraft}>
|
||||||
{t("versionActions.newDraft")}
|
<Plus className="size-4" aria-hidden />
|
||||||
</Button>
|
{t("versionActions.newDraft")}
|
||||||
{isDraft ? (
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManage && isDraft ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
|||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
AdminPlayTypeRow,
|
AdminPlayTypeRow,
|
||||||
@@ -41,9 +45,9 @@ import type {
|
|||||||
} from "@/types/api/admin-config";
|
} from "@/types/api/admin-config";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PRIZE_SCOPE_LABELS,
|
|
||||||
PRIZE_SCOPE_MULTIPLIER_HINT,
|
PRIZE_SCOPE_MULTIPLIER_HINT,
|
||||||
PRIZE_SCOPE_ORDER,
|
PRIZE_SCOPE_ORDER,
|
||||||
|
prizeScopeLabel,
|
||||||
type PrizeScopeCode,
|
type PrizeScopeCode,
|
||||||
} from "@/modules/config/doc/prize-scopes";
|
} from "@/modules/config/doc/prize-scopes";
|
||||||
|
|
||||||
@@ -67,7 +71,9 @@ type OddsConfigDocScreenProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
|
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
@@ -190,6 +196,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
|
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
|
||||||
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
|
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
|
||||||
const isDraft = selectedStatus === "draft";
|
const isDraft = selectedStatus === "draft";
|
||||||
|
const canEditDraft = isDraft && canManage;
|
||||||
|
|
||||||
const scopeRows = useMemo(() => {
|
const scopeRows = useMemo(() => {
|
||||||
const rows: Partial<Record<PrizeScopeCode, OddsItemRow>> = {};
|
const rows: Partial<Record<PrizeScopeCode, OddsItemRow>> = {};
|
||||||
@@ -243,7 +250,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!detail || !isDraft) {
|
if (!detail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -270,7 +277,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublish() {
|
async function handlePublish() {
|
||||||
if (!detail || !isDraft) {
|
if (!detail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -289,7 +296,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestPublishConfirm() {
|
async function requestPublishConfirm() {
|
||||||
if (!detail || !isDraft) {
|
if (!detail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const active = list.find((x) => x.status === "active");
|
const active = list.find((x) => x.status === "active");
|
||||||
@@ -386,12 +393,12 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
|
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
|
||||||
return {
|
return {
|
||||||
scope,
|
scope,
|
||||||
label: PRIZE_SCOPE_LABELS[scope],
|
label: prizeScopeLabel(scope, t),
|
||||||
oldValue: old?.odds_value ?? null,
|
oldValue: old?.odds_value ?? null,
|
||||||
newValue: next?.odds_value ?? null,
|
newValue: next?.odds_value ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
|
}, [activeCompareRows, detail, draftRows, resolvedPlayCode, t, i18n.language]);
|
||||||
|
|
||||||
const catTabs: { id: CatTab; label: string }[] = [
|
const catTabs: { id: CatTab; label: string }[] = [
|
||||||
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
|
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
|
||||||
@@ -423,7 +430,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
active={resolvedPlayCode === type.play_code}
|
active={resolvedPlayCode === type.play_code}
|
||||||
onClick={() => setPlayCode(type.play_code)}
|
onClick={() => setPlayCode(type.play_code)}
|
||||||
>
|
>
|
||||||
{type.display_name_zh ?? type.play_code}
|
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
|
||||||
</ConfigChip>
|
</ConfigChip>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -449,6 +456,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
actions={
|
actions={
|
||||||
<ConfigVersionActions
|
<ConfigVersionActions
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
|
canManage={canManage}
|
||||||
loadingList={loadingList}
|
loadingList={loadingList}
|
||||||
loadingDetail={loadingDetail}
|
loadingDetail={loadingDetail}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
@@ -499,12 +507,12 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
return (
|
return (
|
||||||
<div key={scope} className="grid gap-1">
|
<div key={scope} className="grid gap-1">
|
||||||
<Label className="flex items-baseline gap-2">
|
<Label className="flex items-baseline gap-2">
|
||||||
{PRIZE_SCOPE_LABELS[scope]}
|
{prizeScopeLabel(scope, t)}
|
||||||
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
|
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
|
||||||
</Label>
|
</Label>
|
||||||
{row && idx >= 0 ? (
|
{row && idx >= 0 ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -540,7 +548,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
})}
|
})}
|
||||||
<div className="grid gap-1 pt-2 border-t">
|
<div className="grid gap-1 pt-2 border-t">
|
||||||
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
|
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getPlayConfigVersion,
|
getPlayConfigVersion,
|
||||||
getPlayConfigVersions,
|
getPlayConfigVersions,
|
||||||
postPlayConfigVersion,
|
postPlayConfigVersion,
|
||||||
|
patchAdminPlayType,
|
||||||
publishPlayConfigVersion,
|
publishPlayConfigVersion,
|
||||||
putPlayConfigItems,
|
putPlayConfigItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
@@ -43,6 +44,10 @@ import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
|||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_PLAY_SWITCH_MANAGE } from "@/lib/admin-prd";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
ConfigVersionSummary,
|
ConfigVersionSummary,
|
||||||
@@ -55,9 +60,7 @@ type PlayConfigSaveItemPayload = {
|
|||||||
category: string;
|
category: string;
|
||||||
dimension: number | null;
|
dimension: number | null;
|
||||||
bet_mode: string | null;
|
bet_mode: string | null;
|
||||||
display_name_zh: string;
|
display_name: string;
|
||||||
display_name_en: string | null;
|
|
||||||
display_name_ne: string | null;
|
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
min_bet_amount: number;
|
min_bet_amount: number;
|
||||||
max_bet_amount: number;
|
max_bet_amount: number;
|
||||||
@@ -117,9 +120,7 @@ function buildPlayConfigSavePayload(
|
|||||||
category: row.category ?? "",
|
category: row.category ?? "",
|
||||||
dimension: row.dimension,
|
dimension: row.dimension,
|
||||||
bet_mode: row.bet_mode,
|
bet_mode: row.bet_mode,
|
||||||
display_name_zh: row.display_name_zh ?? row.play_code,
|
display_name: row.display_name ?? row.play_code,
|
||||||
display_name_en: row.display_name_en ?? null,
|
|
||||||
display_name_ne: row.display_name_ne ?? null,
|
|
||||||
is_enabled: row.is_enabled,
|
is_enabled: row.is_enabled,
|
||||||
min_bet_amount: row.min_bet_amount,
|
min_bet_amount: row.min_bet_amount,
|
||||||
max_bet_amount: row.max_bet_amount,
|
max_bet_amount: row.max_bet_amount,
|
||||||
@@ -135,6 +136,9 @@ function buildPlayConfigSavePayload(
|
|||||||
|
|
||||||
export function PlayConfigDocScreen() {
|
export function PlayConfigDocScreen() {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
@@ -149,9 +153,7 @@ export function PlayConfigDocScreen() {
|
|||||||
|
|
||||||
const [nameDialogOpen, setNameDialogOpen] = useState(false);
|
const [nameDialogOpen, setNameDialogOpen] = useState(false);
|
||||||
const [namePlayCode, setNamePlayCode] = useState<string | null>(null);
|
const [namePlayCode, setNamePlayCode] = useState<string | null>(null);
|
||||||
const [nameDraftZh, setNameDraftZh] = useState("");
|
const [nameDraft, setNameDraft] = useState("");
|
||||||
const [nameDraftEn, setNameDraftEn] = useState("");
|
|
||||||
const [nameDraftNe, setNameDraftNe] = useState("");
|
|
||||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||||
const [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
|
const [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
|
||||||
const [ruleDraftZh, setRuleDraftZh] = useState("");
|
const [ruleDraftZh, setRuleDraftZh] = useState("");
|
||||||
@@ -269,10 +271,25 @@ export function PlayConfigDocScreen() {
|
|||||||
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
|
async function applyPlayToggleInstant(playCode: string, enabled: boolean) {
|
||||||
|
try {
|
||||||
|
await patchAdminPlayType(playCode, { is_enabled: enabled });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : t("play.toggleInstantFailed", { ns: "config" }),
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
|
||||||
|
const targets = draftRows.filter(group.match);
|
||||||
setDraftRows((prev) =>
|
setDraftRows((prev) =>
|
||||||
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
|
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
|
||||||
);
|
);
|
||||||
|
for (const row of targets) {
|
||||||
|
await applyPlayToggleInstant(row.play_code, enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchSwitchStates = useMemo(
|
const batchSwitchStates = useMemo(
|
||||||
@@ -359,9 +376,7 @@ export function PlayConfigDocScreen() {
|
|||||||
function openNameEditor(play_code: string) {
|
function openNameEditor(play_code: string) {
|
||||||
const item = draftRows.find((row) => row.play_code === play_code);
|
const item = draftRows.find((row) => row.play_code === play_code);
|
||||||
setNamePlayCode(play_code);
|
setNamePlayCode(play_code);
|
||||||
setNameDraftZh(item?.display_name_zh ?? item?.play_code ?? "");
|
setNameDraft(item?.display_name ?? item?.play_code ?? "");
|
||||||
setNameDraftEn(item?.display_name_en ?? "");
|
|
||||||
setNameDraftNe(item?.display_name_ne ?? "");
|
|
||||||
setNameDialogOpen(true);
|
setNameDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,15 +384,13 @@ export function PlayConfigDocScreen() {
|
|||||||
if (!namePlayCode) {
|
if (!namePlayCode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const zh = nameDraftZh.trim();
|
const name = nameDraft.trim();
|
||||||
if (!zh) {
|
if (!name) {
|
||||||
toast.error(t("play.validation.nameZhRequired", { ns: "config" }));
|
toast.error(t("play.validation.displayNameRequired", { ns: "config" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateConfigRow(namePlayCode, {
|
updateConfigRow(namePlayCode, {
|
||||||
display_name_zh: zh,
|
display_name: name,
|
||||||
display_name_en: nameDraftEn.trim() || null,
|
|
||||||
display_name_ne: nameDraftNe.trim() || null,
|
|
||||||
});
|
});
|
||||||
setNameDialogOpen(false);
|
setNameDialogOpen(false);
|
||||||
setNamePlayCode(null);
|
setNamePlayCode(null);
|
||||||
@@ -408,26 +421,8 @@ export function PlayConfigDocScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDisplayNameReadonly(row: PlayConfigItemRow) {
|
function renderDisplayNameReadonly(row: PlayConfigItemRow) {
|
||||||
const lines = [
|
const name = row.display_name?.trim();
|
||||||
{ label: t("play.locales.zh", { ns: "config" }), value: row.display_name_zh },
|
return <span>{name || row.play_code}</span>;
|
||||||
{ label: t("play.locales.en", { ns: "config" }), value: row.display_name_en },
|
|
||||||
{ label: t("play.locales.ne", { ns: "config" }), value: row.display_name_ne },
|
|
||||||
].filter((line) => line.value?.trim());
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return <span>—</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5 text-center text-sm">
|
|
||||||
{lines.map((line) => (
|
|
||||||
<p key={line.label}>
|
|
||||||
<span className="text-muted-foreground text-xs">{line.label}: </span>
|
|
||||||
{line.value}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeHead = list.find((x) => x.status === "active");
|
const activeHead = list.find((x) => x.status === "active");
|
||||||
@@ -461,13 +456,22 @@ export function PlayConfigDocScreen() {
|
|||||||
actions={
|
actions={
|
||||||
<ConfigVersionActions
|
<ConfigVersionActions
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
|
canManage={canManage}
|
||||||
loadingList={loadingList}
|
loadingList={loadingList}
|
||||||
loadingDetail={loadingDetail}
|
loadingDetail={loadingDetail}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onRefresh={() => void refreshList()}
|
onRefresh={() => void refreshList()}
|
||||||
onNewDraft={() => void handleNewDraft()}
|
onNewDraft={() => void handleNewDraft()}
|
||||||
onSaveDraft={() => void handleSaveDraft()}
|
onSaveDraft={() => void handleSaveDraft()}
|
||||||
onPublish={() => void handlePublish()}
|
onPublish={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("play.publishDialog.title", { ns: "config" }),
|
||||||
|
description: t("play.publishDialog.description", { ns: "config" }),
|
||||||
|
confirmLabel: t("play.publishDialog.confirm", { ns: "config" }),
|
||||||
|
confirmVariant: "destructive",
|
||||||
|
onConfirm: () => handlePublish(),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -519,7 +523,23 @@ export function PlayConfigDocScreen() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={group.allEnabled ? "secondary" : "outline"}
|
variant={group.allEnabled ? "secondary" : "outline"}
|
||||||
disabled={!isDraft || saving || group.total === 0}
|
disabled={!isDraft || saving || group.total === 0}
|
||||||
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
|
onClick={() => {
|
||||||
|
const enable = !group.allEnabled;
|
||||||
|
const action = enable
|
||||||
|
? t("play.batchSwitchEnable", { ns: "config" })
|
||||||
|
: t("play.batchSwitchDisable", { ns: "config" });
|
||||||
|
requestConfirm({
|
||||||
|
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||||
|
description: t("play.batchSwitchConfirmDescription", {
|
||||||
|
ns: "config",
|
||||||
|
action,
|
||||||
|
group: group.label,
|
||||||
|
count: group.total,
|
||||||
|
}),
|
||||||
|
confirmVariant: enable ? "default" : "destructive",
|
||||||
|
onConfirm: () => applyBatchSwitch(group, enable),
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{group.allEnabled
|
{group.allEnabled
|
||||||
? t("play.actions.disable", { ns: "config" })
|
? t("play.actions.disable", { ns: "config" })
|
||||||
@@ -560,7 +580,23 @@ export function PlayConfigDocScreen() {
|
|||||||
checked={row.is_enabled}
|
checked={row.is_enabled}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onCheckedChange={(v) => {
|
onCheckedChange={(v) => {
|
||||||
updateConfigRow(row.play_code, { is_enabled: v === true });
|
const enabled = v === true;
|
||||||
|
const action = enabled
|
||||||
|
? t("play.toggleEnable", { ns: "config" })
|
||||||
|
: t("play.toggleDisable", { ns: "config" });
|
||||||
|
requestConfirm({
|
||||||
|
title: t("play.toggleConfirmTitle", {
|
||||||
|
ns: "config",
|
||||||
|
action,
|
||||||
|
playCode: row.play_code,
|
||||||
|
}),
|
||||||
|
description: t("play.toggleConfirmDescription", { ns: "config" }),
|
||||||
|
confirmVariant: enabled ? "default" : "destructive",
|
||||||
|
onConfirm: () => {
|
||||||
|
updateConfigRow(row.play_code, { is_enabled: enabled });
|
||||||
|
void applyPlayToggleInstant(row.play_code, enabled);
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
|
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
|
||||||
/>
|
/>
|
||||||
@@ -578,7 +614,7 @@ export function PlayConfigDocScreen() {
|
|||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<p className="max-w-[10rem] truncate text-sm font-medium">
|
<p className="max-w-[10rem] truncate text-sm font-medium">
|
||||||
{row.display_name_zh ?? row.play_code}
|
{row.display_name ?? row.play_code}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -588,7 +624,7 @@ export function PlayConfigDocScreen() {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => openNameEditor(row.play_code)}
|
onClick={() => openNameEditor(row.play_code)}
|
||||||
>
|
>
|
||||||
{t("play.actions.displayNames", { ns: "config" })}
|
{t("play.actions.editDisplayName", { ns: "config" })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -688,31 +724,13 @@ export function PlayConfigDocScreen() {
|
|||||||
{t("play.nameDialog.description", { ns: "config", playCode: namePlayCode ?? "—" })}
|
{t("play.nameDialog.description", { ns: "config", playCode: namePlayCode ?? "—" })}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-1.5">
|
||||||
<div className="grid gap-1.5">
|
<Label htmlFor="play-display-name">{t("play.table.displayName", { ns: "config" })}</Label>
|
||||||
<Label htmlFor="name-zh">{t("play.locales.zh", { ns: "config" })}</Label>
|
<Input
|
||||||
<Input
|
id="play-display-name"
|
||||||
id="name-zh"
|
value={nameDraft}
|
||||||
value={nameDraftZh}
|
onChange={(e) => setNameDraft(e.target.value)}
|
||||||
onChange={(e) => setNameDraftZh(e.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="name-en">{t("play.locales.en", { ns: "config" })}</Label>
|
|
||||||
<Input
|
|
||||||
id="name-en"
|
|
||||||
value={nameDraftEn}
|
|
||||||
onChange={(e) => setNameDraftEn(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="name-ne">{t("play.locales.ne", { ns: "config" })}</Label>
|
|
||||||
<Input
|
|
||||||
id="name-ne"
|
|
||||||
value={nameDraftNe}
|
|
||||||
onChange={(e) => setNameDraftNe(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setNameDialogOpen(false)}>
|
<Button type="button" variant="outline" onClick={() => setNameDialogOpen(false)}>
|
||||||
@@ -774,6 +792,7 @@ export function PlayConfigDocScreen() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<ConfirmDialog />
|
||||||
</ConfigDocPage>
|
</ConfigDocPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Prize scope order, including starter and consolation. */
|
/** Prize scope order, including starter and consolation. */
|
||||||
|
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
export const PRIZE_SCOPE_ORDER = [
|
export const PRIZE_SCOPE_ORDER = [
|
||||||
"first",
|
"first",
|
||||||
"second",
|
"second",
|
||||||
@@ -10,16 +12,13 @@ export const PRIZE_SCOPE_ORDER = [
|
|||||||
|
|
||||||
export type PrizeScopeCode = (typeof PRIZE_SCOPE_ORDER)[number];
|
export type PrizeScopeCode = (typeof PRIZE_SCOPE_ORDER)[number];
|
||||||
|
|
||||||
export const PRIZE_SCOPE_LABELS: Record<PrizeScopeCode, string> = {
|
|
||||||
first: "First Prize Odds",
|
|
||||||
second: "Second Prize Odds",
|
|
||||||
third: "Third Prize Odds",
|
|
||||||
starter: "Starter Prize Odds",
|
|
||||||
consolation: "Consolation Prize Odds",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Display-only multiplier hints for starter and consolation grouped prizes. */
|
/** Display-only multiplier hints for starter and consolation grouped prizes. */
|
||||||
export const PRIZE_SCOPE_MULTIPLIER_HINT: Partial<Record<PrizeScopeCode, string>> = {
|
export const PRIZE_SCOPE_MULTIPLIER_HINT: Partial<Record<PrizeScopeCode, string>> = {
|
||||||
starter: "× 10",
|
starter: "× 10",
|
||||||
consolation: "× 10",
|
consolation: "× 10",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Localized prize-scope label for odds / rebate config screens. */
|
||||||
|
export function prizeScopeLabel(scope: PrizeScopeCode, t: TFunction): string {
|
||||||
|
return t(`prizeScopes.${scope}`, { ns: "config" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
|||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
AdminPlayTypeRow,
|
AdminPlayTypeRow,
|
||||||
@@ -54,6 +58,9 @@ type RebateConfigDocScreenProps = {
|
|||||||
|
|
||||||
export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScreenProps) {
|
export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScreenProps) {
|
||||||
const { t } = useTranslation(["config", "common"]);
|
const { t } = useTranslation(["config", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||||
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
|
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
|
||||||
@@ -162,6 +169,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
|
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
|
||||||
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
|
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
|
||||||
const isDraft = selectedStatus === "draft";
|
const isDraft = selectedStatus === "draft";
|
||||||
|
const canEditDraft = isDraft && canManage;
|
||||||
|
|
||||||
function applyDimensionPercentsToRows(rows: OddsItemRow[]): OddsItemRow[] {
|
function applyDimensionPercentsToRows(rows: OddsItemRow[]): OddsItemRow[] {
|
||||||
const r2 = Number.parseFloat(p2);
|
const r2 = Number.parseFloat(p2);
|
||||||
@@ -179,7 +187,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!detail || !isDraft) {
|
if (!detail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -211,7 +219,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublish() {
|
async function handlePublish() {
|
||||||
if (!detail || !isDraft) {
|
if (!detail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -286,6 +294,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
actions={
|
actions={
|
||||||
<ConfigVersionActions
|
<ConfigVersionActions
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
|
canManage={canManage}
|
||||||
loadingList={loading}
|
loadingList={loading}
|
||||||
loadingDetail={loadingDetail}
|
loadingDetail={loadingDetail}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
@@ -293,7 +302,15 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
onRefresh={() => void refreshList()}
|
onRefresh={() => void refreshList()}
|
||||||
onNewDraft={() => void handleNewDraft()}
|
onNewDraft={() => void handleNewDraft()}
|
||||||
onSaveDraft={() => void handleSave()}
|
onSaveDraft={() => void handleSave()}
|
||||||
onPublish={() => void handlePublish()}
|
onPublish={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("rebate.publishDialog.title", { ns: "config" }),
|
||||||
|
description: t("rebate.publishDialog.description", { ns: "config" }),
|
||||||
|
confirmLabel: t("rebate.publishDialog.confirm", { ns: "config" }),
|
||||||
|
confirmVariant: "destructive",
|
||||||
|
onConfirm: () => handlePublish(),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -326,7 +343,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
<div className="grid gap-5 sm:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
|
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -342,7 +359,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("rebate.fields.d3", { ns: "config" })}</Label>
|
<Label>{t("rebate.fields.d3", { ns: "config" })}</Label>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -358,7 +375,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("rebate.fields.d4", { ns: "config" })}</Label>
|
<Label>{t("rebate.fields.d4", { ns: "config" })}</Label>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -409,6 +426,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{contextBlock}
|
{contextBlock}
|
||||||
{fieldsBlock}
|
{fieldsBlock}
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -420,6 +438,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
context={contextBlock}
|
context={contextBlock}
|
||||||
>
|
>
|
||||||
{fieldsBlock}
|
{fieldsBlock}
|
||||||
|
<ConfirmDialog />
|
||||||
</ConfigDocPage>
|
</ConfigDocPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ import {
|
|||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
ConfigVersionSummary,
|
ConfigVersionSummary,
|
||||||
@@ -74,6 +78,9 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
|
|||||||
|
|
||||||
export function RiskCapDocScreen() {
|
export function RiskCapDocScreen() {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_RISK_CAP_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
@@ -177,6 +184,7 @@ export function RiskCapDocScreen() {
|
|||||||
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
|
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
|
||||||
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
|
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
|
||||||
const isDraft = selectedStatus === "draft";
|
const isDraft = selectedStatus === "draft";
|
||||||
|
const canEditDraft = isDraft && canManage;
|
||||||
|
|
||||||
const updateRow = (idx: number, patch: Partial<DraftRiskRow>) => {
|
const updateRow = (idx: number, patch: Partial<DraftRiskRow>) => {
|
||||||
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||||
@@ -187,7 +195,7 @@ export function RiskCapDocScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!detail || !isDraft) {
|
if (!detail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draftRows.length === 0) {
|
if (draftRows.length === 0) {
|
||||||
@@ -236,7 +244,7 @@ export function RiskCapDocScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublish() {
|
async function handlePublish() {
|
||||||
if (!detail || !isDraft) {
|
if (!detail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -347,13 +355,22 @@ export function RiskCapDocScreen() {
|
|||||||
actions={
|
actions={
|
||||||
<ConfigVersionActions
|
<ConfigVersionActions
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
|
canManage={canManage}
|
||||||
loadingList={loadingList}
|
loadingList={loadingList}
|
||||||
loadingDetail={loadingDetail}
|
loadingDetail={loadingDetail}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onRefresh={() => void refreshList()}
|
onRefresh={() => void refreshList()}
|
||||||
onNewDraft={() => void handleNewDraft()}
|
onNewDraft={() => void handleNewDraft()}
|
||||||
onSaveDraft={() => void handleSave()}
|
onSaveDraft={() => void handleSave()}
|
||||||
onPublish={() => void handlePublish()}
|
onPublish={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("riskCap.publishDialog.title", { ns: "config" }),
|
||||||
|
description: t("riskCap.publishDialog.description", { ns: "config" }),
|
||||||
|
confirmLabel: t("riskCap.publishDialog.confirm", { ns: "config" }),
|
||||||
|
confirmVariant: "destructive",
|
||||||
|
onConfirm: () => handlePublish(),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -379,7 +396,7 @@ export function RiskCapDocScreen() {
|
|||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
|
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
id="default-cap"
|
id="default-cap"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -395,7 +412,7 @@ export function RiskCapDocScreen() {
|
|||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
|
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
|
||||||
{t("riskCap.actions.update", { ns: "config" })}
|
{t("riskCap.actions.update", { ns: "config" })}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -406,7 +423,7 @@ export function RiskCapDocScreen() {
|
|||||||
<ConfigSection
|
<ConfigSection
|
||||||
title={t("riskCap.specialCaps.title", { ns: "config" })}
|
title={t("riskCap.specialCaps.title", { ns: "config" })}
|
||||||
actions={
|
actions={
|
||||||
isDraft ? (
|
canEditDraft ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -438,7 +455,7 @@ export function RiskCapDocScreen() {
|
|||||||
{specialRows.map(({ row: r, index: idx }) => (
|
{specialRows.map(({ row: r, index: idx }) => (
|
||||||
<TableRow key={r.clientKey}>
|
<TableRow key={r.clientKey}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
className="h-8 font-mono tabular-nums"
|
className="h-8 font-mono tabular-nums"
|
||||||
maxLength={4}
|
maxLength={4}
|
||||||
@@ -455,7 +472,7 @@ export function RiskCapDocScreen() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -476,7 +493,7 @@ export function RiskCapDocScreen() {
|
|||||||
<TableCell className="text-right text-muted-foreground tabular-nums text-sm">—</TableCell>
|
<TableCell className="text-right text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||||
<TableCell className="text-center text-muted-foreground text-sm">—</TableCell>
|
<TableCell className="text-center text-muted-foreground text-sm">—</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{isDraft ? (
|
{canEditDraft ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -568,6 +585,7 @@ export function RiskCapDocScreen() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<ConfirmDialog />
|
||||||
</ConfigDocPage>
|
</ConfigDocPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getAdminSettings,
|
getAdminSettings,
|
||||||
updateAdminSetting,
|
updateAdminSetting,
|
||||||
} from "@/api/admin-settings";
|
} from "@/api/admin-settings";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -47,7 +48,8 @@ type WalletConfigDocScreenProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
|
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
|
||||||
const { t } = useTranslation(["config", "adminUsers"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const [draft, setDraft] = useState<Draft>({
|
const [draft, setDraft] = useState<Draft>({
|
||||||
inMin: "",
|
inMin: "",
|
||||||
inMax: "",
|
inMax: "",
|
||||||
@@ -170,7 +172,17 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 pt-2">
|
<div className="flex items-center gap-4 pt-2">
|
||||||
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("wallet.confirmSaveTitle", { ns: "config" }),
|
||||||
|
description: t("wallet.confirmSaveDescription", { ns: "config" }),
|
||||||
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
|
onConfirm: () => handleSave(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!dirty || loading || saving}
|
||||||
|
>
|
||||||
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
||||||
</Button>
|
</Button>
|
||||||
{dirty && (
|
{dirty && (
|
||||||
@@ -185,6 +197,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
386
src/modules/dashboard/dashboard-analytics-panel.tsx
Normal file
386
src/modules/dashboard/dashboard-analytics-panel.tsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||||
|
import { format, subDays } from "date-fns";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
|
||||||
|
|
||||||
|
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
||||||
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||||
|
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { StatCard } from "@/modules/dashboard/dashboard-visuals";
|
||||||
|
import {
|
||||||
|
DailyTrendChart,
|
||||||
|
PeriodCompareStrip,
|
||||||
|
PlayBreakdownChart,
|
||||||
|
} from "@/modules/dashboard/dashboard-trend-charts";
|
||||||
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
import type {
|
||||||
|
AdminDashboardAnalyticsData,
|
||||||
|
DashboardAnalyticsMetric,
|
||||||
|
DashboardAnalyticsPeriod,
|
||||||
|
} from "@/types/api/admin-dashboard-analytics";
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: DashboardAnalyticsPeriod[] = [
|
||||||
|
"today",
|
||||||
|
"last_7_days",
|
||||||
|
"last_30_days",
|
||||||
|
"this_month",
|
||||||
|
"lifetime",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
|
const METRIC_OPTIONS: DashboardAnalyticsMetric[] = ["overview", "bet", "payout", "profit"];
|
||||||
|
|
||||||
|
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||||
|
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||||
|
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||||
|
const major = minor / 10 ** decimals;
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("zh-CN", {
|
||||||
|
style: "currency",
|
||||||
|
currency: code,
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(major);
|
||||||
|
} catch {
|
||||||
|
return formatAdminMinorUnits(minor, code, decimals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||||
|
if (minor === 0) {
|
||||||
|
return formatMoneyMinor(0, currencyCode);
|
||||||
|
}
|
||||||
|
const s = minor > 0 ? "+" : "−";
|
||||||
|
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardAnalyticsPanel({
|
||||||
|
enabled,
|
||||||
|
playOptions,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
playOptions: { code: string; label: string }[];
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation(["dashboard", "common"]);
|
||||||
|
const playLabel = useAdminPlayCodeLabel();
|
||||||
|
|
||||||
|
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
||||||
|
const [metric, setMetric] = useState<DashboardAnalyticsMetric>("overview");
|
||||||
|
const [playCode, setPlayCode] = useState<string>("");
|
||||||
|
const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
|
||||||
|
const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<AdminDashboardAnalyticsData | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!enabled) {
|
||||||
|
setLoading(false);
|
||||||
|
setData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await getAdminDashboardAnalytics({
|
||||||
|
period,
|
||||||
|
metric,
|
||||||
|
play_code: playCode !== "" ? playCode : undefined,
|
||||||
|
...(period === "custom"
|
||||||
|
? { date_from: customFrom, date_to: customTo }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
setData(payload);
|
||||||
|
} catch (e) {
|
||||||
|
setData(null);
|
||||||
|
const raw = e instanceof LotteryApiBizError ? e.message : "";
|
||||||
|
const needsAuthSync =
|
||||||
|
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
||||||
|
setError(
|
||||||
|
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [enabled, period, metric, playCode, customFrom, customTo, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void load();
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const currency = data?.currency_code ?? null;
|
||||||
|
const summary = data?.summary;
|
||||||
|
|
||||||
|
const periodRangeLabel = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.date_from === data.date_to
|
||||||
|
? data.date_from
|
||||||
|
: `${data.date_from} — ${data.date_to}`;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const metricLabel = useMemo(
|
||||||
|
() => t(`analytics.metrics.${metric}`),
|
||||||
|
[metric, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const playFilterLabel = useMemo(() => {
|
||||||
|
if (playCode === "") {
|
||||||
|
return t("analytics.allPlays");
|
||||||
|
}
|
||||||
|
return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
|
||||||
|
}, [playCode, playOptions, t]);
|
||||||
|
|
||||||
|
const resolvePlayLabel = useCallback(
|
||||||
|
(code: string, dimension: number) => {
|
||||||
|
const base = playLabel(code);
|
||||||
|
return dimension > 0 ? `${base} · ${dimension}D` : base;
|
||||||
|
},
|
||||||
|
[playLabel],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<Card className="border-border/80 shadow-sm">
|
||||||
|
<CardHeader className="space-y-4 pb-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">{t("analytics.title")}</CardTitle>
|
||||||
|
<Link
|
||||||
|
href="/admin/reports"
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 gap-1.5 text-xs")}
|
||||||
|
>
|
||||||
|
<BarChart3 className="size-3.5" aria-hidden />
|
||||||
|
{t("viewReports")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("analytics.periodLabel")}>
|
||||||
|
{PERIOD_OPTIONS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
period === p
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
>
|
||||||
|
{t(`analytics.periods.${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_auto_auto] lg:items-end">
|
||||||
|
{period === "custom" ? (
|
||||||
|
<AdminDateRangeField
|
||||||
|
id="dashboard-analytics-range"
|
||||||
|
label={t("analytics.customRange")}
|
||||||
|
from={customFrom}
|
||||||
|
to={customTo}
|
||||||
|
onRangeChange={({ from, to }) => {
|
||||||
|
setCustomFrom(from);
|
||||||
|
setCustomTo(to);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground lg:col-span-1">
|
||||||
|
{periodRangeLabel
|
||||||
|
? t("analytics.rangeHint", { range: periodRangeLabel })
|
||||||
|
: t("analytics.selectPeriod")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t("analytics.metricLabel")}</Label>
|
||||||
|
<Select value={metric} onValueChange={(v) => setMetric(v as DashboardAnalyticsMetric)}>
|
||||||
|
<SelectTrigger className="w-full min-w-[140px]">
|
||||||
|
<SelectValue>{metricLabel}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{METRIC_OPTIONS.map((m) => (
|
||||||
|
<SelectItem key={m} value={m}>
|
||||||
|
{t(`analytics.metrics.${m}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t("analytics.playLabel")}</Label>
|
||||||
|
<Select
|
||||||
|
value={playCode === "" ? "__all__" : playCode}
|
||||||
|
onValueChange={(v) => setPlayCode(v === "__all__" ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full min-w-[160px]">
|
||||||
|
<SelectValue>{playFilterLabel}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">{t("analytics.allPlays")}</SelectItem>
|
||||||
|
{playOptions.map((p) => (
|
||||||
|
<SelectItem key={p.code} value={p.code}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{error ? (
|
||||||
|
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{data?.chart_meta.truncated ? (
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
{t("analytics.chartTruncated", {
|
||||||
|
from: data.chart_meta.chart_date_from,
|
||||||
|
to: data.chart_meta.chart_date_to,
|
||||||
|
days: data.chart_meta.span_days,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : summary ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
label={t("analytics.summaryBet")}
|
||||||
|
value={formatMoneyMinor(summary.total_bet_minor, currency)}
|
||||||
|
hint={t("lifetimeActivityHint", {
|
||||||
|
draws: summary.draw_count.toLocaleString("zh-CN"),
|
||||||
|
days: summary.business_day_count.toLocaleString("zh-CN"),
|
||||||
|
})}
|
||||||
|
icon={<Wallet className="size-5" aria-hidden />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("analytics.summaryPayout")}
|
||||||
|
value={formatMoneyMinor(summary.total_payout_minor, currency)}
|
||||||
|
hint={
|
||||||
|
summary.total_bet_minor > 0
|
||||||
|
? t("payoutRateOfBet", {
|
||||||
|
rate: ((summary.total_payout_minor / summary.total_bet_minor) * 100).toFixed(1),
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={<Gift className="size-5" aria-hidden />}
|
||||||
|
accent="destructive"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("analytics.summaryProfit")}
|
||||||
|
value={formatSignedMoneyMinor(summary.approx_house_gross_minor, currency)}
|
||||||
|
hint={
|
||||||
|
summary.total_bet_minor > 0
|
||||||
|
? t("marginRate", {
|
||||||
|
rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1),
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={<TrendingUp className="size-5" aria-hidden />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||||
|
<Card className="flex h-full flex-col border-border/80 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{t("analytics.dailyTrend")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-4">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-[220px] w-full" />
|
||||||
|
) : data ? (
|
||||||
|
<DailyTrendChart
|
||||||
|
series={data.daily_series}
|
||||||
|
metric={metric}
|
||||||
|
formatMoney={formatMoneyMinor}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex h-full flex-col border-border/80 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{t("analytics.playBreakdown")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-4">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-[220px] w-full" />
|
||||||
|
) : data ? (
|
||||||
|
<div className="max-h-[280px] overflow-y-auto pr-1">
|
||||||
|
<PlayBreakdownChart
|
||||||
|
rows={data.play_breakdown}
|
||||||
|
metric={metric}
|
||||||
|
formatMoney={formatMoneyMinor}
|
||||||
|
currency={currency}
|
||||||
|
playLabel={resolvePlayLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && !loading ? (
|
||||||
|
<Card className="border-border/80 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{t("analytics.periodDistribution")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PeriodCompareStrip
|
||||||
|
series={data.daily_series}
|
||||||
|
formatMoney={formatMoneyMinor}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,24 +2,28 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { zhCN } from "date-fns/locale";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Diamond,
|
Diamond,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
Gift,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Shield,
|
Shield,
|
||||||
Ticket,
|
Ticket,
|
||||||
TrendingUp,
|
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||||
|
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||||
|
import { getAdminPlayTypes } from "@/api/admin-config";
|
||||||
|
import {
|
||||||
|
getAdminPlayTypesLoadPromise,
|
||||||
|
getCachedAdminPlayTypes,
|
||||||
|
resolveAdminPlayTypeDisplayName,
|
||||||
|
} from "@/lib/admin-play-types";
|
||||||
|
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -32,9 +36,10 @@ import {
|
|||||||
ResultBatchProgress,
|
ResultBatchProgress,
|
||||||
SettlementStatusChart,
|
SettlementStatusChart,
|
||||||
SoldOutRing,
|
SoldOutRing,
|
||||||
StatCard,
|
|
||||||
} from "@/modules/dashboard/dashboard-visuals";
|
} from "@/modules/dashboard/dashboard-visuals";
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
|
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||||
|
import { normalizeAdminLanguage } from "@/i18n";
|
||||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -69,14 +74,6 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
|
|
||||||
if (minor === 0) {
|
|
||||||
return formatMoneyMinor(0, currencyCode);
|
|
||||||
}
|
|
||||||
const s = minor > 0 ? "+" : "−";
|
|
||||||
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
|
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
|
||||||
const raw = normalizedNumber.trim();
|
const raw = normalizedNumber.trim();
|
||||||
const digits = raw.replace(/\D/g, "");
|
const digits = raw.replace(/\D/g, "");
|
||||||
@@ -109,18 +106,24 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardConsole(): ReactElement {
|
export function DashboardConsole(): ReactElement {
|
||||||
const { t } = useTranslation(["dashboard", "common"]);
|
const { t, i18n } = useTranslation(["dashboard", "common"]);
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const [todayLabel] = useState(() => format(new Date(), "yyyy-MM-dd EEEE", { locale: zhCN }));
|
useAdminPlayTypeCatalog();
|
||||||
|
const todayLabel = useMemo(() => {
|
||||||
|
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||||
|
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
|
||||||
|
|
||||||
|
return formatAdminCalendarToday(locale, weekday);
|
||||||
|
}, [i18n.language, i18n.resolvedLanguage, t]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||||
const [drawId, setDrawId] = useState<number | null>(null);
|
const [drawId, setDrawId] = useState<number | null>(null);
|
||||||
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
|
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
|
||||||
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||||
|
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
|
||||||
const [pendingReview, setPendingReview] = useState<number | null>(null);
|
const [pendingReview, setPendingReview] = useState<number | null>(null);
|
||||||
const [riskLocked, setRiskLocked] = useState(0);
|
const [riskLocked, setRiskLocked] = useState(0);
|
||||||
const [riskCap, setRiskCap] = useState(0);
|
const [riskCap, setRiskCap] = useState(0);
|
||||||
@@ -128,6 +131,26 @@ export function DashboardConsole(): ReactElement {
|
|||||||
const [soldOutBuckets, setSoldOutBuckets] = useState<SoldOutBuckets | null>(null);
|
const [soldOutBuckets, setSoldOutBuckets] = useState<SoldOutBuckets | null>(null);
|
||||||
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
||||||
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
||||||
|
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
|
||||||
|
|
||||||
|
const loadPlayOptions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
|
||||||
|
setPlayOptions(
|
||||||
|
getCachedAdminPlayTypes().map((item) => ({
|
||||||
|
code: item.play_code,
|
||||||
|
label:
|
||||||
|
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setPlayOptions([]);
|
||||||
|
}
|
||||||
|
}, [i18n.language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPlayOptions();
|
||||||
|
}, [loadPlayOptions]);
|
||||||
|
|
||||||
const load = useCallback(async (isRefresh = false) => {
|
const load = useCallback(async (isRefresh = false) => {
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
@@ -136,8 +159,8 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
setNotice(null);
|
|
||||||
setFinance(null);
|
setFinance(null);
|
||||||
|
setCapabilities(null);
|
||||||
setDrawPanel(null);
|
setDrawPanel(null);
|
||||||
setPendingReview(null);
|
setPendingReview(null);
|
||||||
setDrawId(null);
|
setDrawId(null);
|
||||||
@@ -155,6 +178,7 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setDrawId(d.resolved_draw.id);
|
setDrawId(d.resolved_draw.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCapabilities(d.capabilities);
|
||||||
if (d.finance != null) {
|
if (d.finance != null) {
|
||||||
setFinance(d.finance);
|
setFinance(d.finance);
|
||||||
}
|
}
|
||||||
@@ -169,15 +193,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setSoldOutBuckets(d.risk.sold_out_buckets);
|
setSoldOutBuckets(d.risk.sold_out_buckets);
|
||||||
}
|
}
|
||||||
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
||||||
|
|
||||||
const noticeParts: string[] = d.warnings.map((w) => w.message);
|
|
||||||
if (d.resolved_draw != null && !d.capabilities.draw_finance_risk) {
|
|
||||||
noticeParts.push(t("warnings.drawPermission"));
|
|
||||||
}
|
|
||||||
if (d.hall != null && !d.capabilities.wallet_transfer_view) {
|
|
||||||
noticeParts.push(t("warnings.walletPermission"));
|
|
||||||
}
|
|
||||||
setNotice(noticeParts.length > 0 ? noticeParts.join(" ") : null);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
|
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
|
||||||
@@ -196,6 +211,7 @@ export function DashboardConsole(): ReactElement {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const currency = finance?.currency_code ?? null;
|
const currency = finance?.currency_code ?? null;
|
||||||
|
const canFinance = capabilities?.draw_finance_risk ?? false;
|
||||||
const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0;
|
const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0;
|
||||||
|
|
||||||
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||||
@@ -218,16 +234,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
|
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const kpiSkeleton = (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="rounded-xl border border-border/80 bg-card p-5 shadow-sm">
|
|
||||||
<Skeleton className="h-20 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -242,7 +248,7 @@ export function DashboardConsole(): ReactElement {
|
|||||||
onClick={() => void load(true)}
|
onClick={() => void load(true)}
|
||||||
>
|
>
|
||||||
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
|
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
|
||||||
{t("refresh")}
|
{t("actions.refresh", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,69 +260,48 @@ export function DashboardConsole(): ReactElement {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{notice && !error ? (
|
{!loading && capabilities && !capabilities.draw_finance_risk ? (
|
||||||
<Alert className="border-sky-200 bg-sky-50 dark:border-sky-900/50 dark:bg-sky-950/30">
|
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||||
<AlertTitle>{t("notice")}</AlertTitle>
|
<AlertTitle>{t("notice")}</AlertTitle>
|
||||||
<AlertDescription>{notice}</AlertDescription>
|
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{!loading && hall ? (
|
||||||
kpiSkeleton
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/80 bg-card px-4 py-3 shadow-sm">
|
||||||
) : (
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<Ticket className="size-5 text-primary" aria-hidden />
|
||||||
<StatCard
|
<div>
|
||||||
label={t("todayBetTotal")}
|
<p className="text-xs text-muted-foreground">{t("sections.currentDraw")}</p>
|
||||||
value={finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
|
<p className="font-mono text-lg font-semibold text-foreground">{hall.draw_no}</p>
|
||||||
hint={hall?.draw_no ? t("drawNoHint", { drawNo: hall.draw_no }) : undefined}
|
</div>
|
||||||
icon={<Wallet className="size-5" aria-hidden />}
|
<span className="text-sm text-muted-foreground">
|
||||||
/>
|
{t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
|
||||||
<StatCard
|
</span>
|
||||||
label={t("currentPayout")}
|
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||||
value={finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
|
<span
|
||||||
hint={
|
className={cn(
|
||||||
finance
|
"size-1.5 rounded-full",
|
||||||
? t("orderAndTicket", {
|
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
|
||||||
orders: finance.order_count.toLocaleString("zh-CN"),
|
)}
|
||||||
tickets: finance.ticket_item_count.toLocaleString("zh-CN"),
|
/>
|
||||||
})
|
{hallStatusLabel}
|
||||||
: undefined
|
</span>
|
||||||
}
|
</div>
|
||||||
icon={<Gift className="size-5" aria-hidden />}
|
{drawId != null ? (
|
||||||
accent="destructive"
|
<Link
|
||||||
/>
|
href={`/admin/draws/${drawId}/finance`}
|
||||||
<StatCard
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "text-xs")}
|
||||||
label={t("currentProfit")}
|
>
|
||||||
value={finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
|
{t("drawFinanceDetails")}
|
||||||
hint={finance && finance.total_bet_minor > 0
|
</Link>
|
||||||
? t("marginRate", {
|
) : null}
|
||||||
rate: ((finance.approx_house_gross_minor / finance.total_bet_minor) * 100).toFixed(1),
|
|
||||||
})
|
|
||||||
: undefined}
|
|
||||||
icon={<TrendingUp className="size-5" aria-hidden />}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label={t("currentDraw")}
|
|
||||||
value={<span className="font-mono text-primary">{hall?.draw_no ?? "—"}</span>}
|
|
||||||
hint={
|
|
||||||
<span className="inline-flex flex-wrap items-center gap-2">
|
|
||||||
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"size-1.5 rounded-full",
|
|
||||||
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{hallStatusLabel}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
icon={<Ticket className="size-5" aria-hidden />}
|
|
||||||
accent="muted"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
|
<DashboardAnalyticsPanel enabled={canFinance} playOptions={playOptions} />
|
||||||
|
|
||||||
|
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground">{t("sections.operations")}</h2>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||||
|
|||||||
260
src/modules/dashboard/dashboard-trend-charts.tsx
Normal file
260
src/modules/dashboard/dashboard-trend-charts.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics";
|
||||||
|
import type { AdminReportDailyProfitRow } from "@/types/api/admin-reports";
|
||||||
|
import type { DashboardAnalyticsMetric } from "@/types/api/admin-dashboard-analytics";
|
||||||
|
|
||||||
|
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||||
|
|
||||||
|
function metricValue(row: AdminReportDailyProfitRow, metric: DashboardAnalyticsMetric): number {
|
||||||
|
switch (metric) {
|
||||||
|
case "bet":
|
||||||
|
return row.total_bet_minor;
|
||||||
|
case "payout":
|
||||||
|
return row.total_payout_minor;
|
||||||
|
case "profit":
|
||||||
|
return row.approx_house_gross_minor;
|
||||||
|
default:
|
||||||
|
return row.total_bet_minor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardAnalyticsMetric): number {
|
||||||
|
switch (metric) {
|
||||||
|
case "bet":
|
||||||
|
return row.total_bet_minor;
|
||||||
|
case "payout":
|
||||||
|
return row.total_payout_minor;
|
||||||
|
case "profit":
|
||||||
|
return row.approx_house_gross_minor;
|
||||||
|
default:
|
||||||
|
return row.total_bet_minor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyTrendChart({
|
||||||
|
series,
|
||||||
|
metric,
|
||||||
|
formatMoney,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
series: AdminReportDailyProfitRow[];
|
||||||
|
metric: DashboardAnalyticsMetric;
|
||||||
|
formatMoney: MoneyFormatter;
|
||||||
|
currency: string | null;
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
|
||||||
|
if (series.length === 0) {
|
||||||
|
return <p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBet = Math.max(...series.map((d) => d.total_bet_minor), 1);
|
||||||
|
const maxPayout = Math.max(...series.map((d) => d.total_payout_minor), 1);
|
||||||
|
const maxProfit = Math.max(...series.map((d) => Math.abs(d.approx_house_gross_minor)), 1);
|
||||||
|
const labelEvery = series.length > 14 ? Math.ceil(series.length / 7) : 1;
|
||||||
|
|
||||||
|
const plotHeight = series.length <= 7 ? 200 : series.length <= 14 ? 220 : 240;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{metric === "overview" ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="size-2.5 rounded-sm bg-primary" />
|
||||||
|
{t("chartLegend.bet")}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="size-2.5 rounded-sm bg-rose-500" />
|
||||||
|
{t("chartLegend.payout")}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="size-2.5 rounded-sm bg-emerald-500" />
|
||||||
|
{t("chartLegend.profit")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-end gap-1 overflow-x-auto rounded-md border border-border/60 bg-muted/20 px-2 pb-2 pt-3 sm:gap-1.5"
|
||||||
|
style={{ height: plotHeight }}
|
||||||
|
>
|
||||||
|
{series.map((day, idx) => {
|
||||||
|
const betH = (day.total_bet_minor / maxBet) * 100;
|
||||||
|
const payoutH = (day.total_payout_minor / maxPayout) * 100;
|
||||||
|
const profitRaw = day.approx_house_gross_minor;
|
||||||
|
const profitH = (Math.abs(profitRaw) / maxProfit) * 100;
|
||||||
|
const showLabel = idx % labelEvery === 0 || idx === series.length - 1;
|
||||||
|
const shortDate = day.business_date.slice(5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day.business_date}
|
||||||
|
className="flex min-w-[28px] flex-1 flex-col items-stretch justify-end gap-1 self-stretch"
|
||||||
|
title={`${day.business_date}\n${t("todayBetTotal")}: ${formatMoney(day.total_bet_minor, currency)}\n${t("todayPayout")}: ${formatMoney(day.total_payout_minor, currency)}\n${t("todayProfit")}: ${formatMoney(day.approx_house_gross_minor, currency)}`}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-1 items-end justify-center gap-0.5">
|
||||||
|
{metric === "overview" ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="w-[30%] min-w-[4px] rounded-t-sm bg-primary/90 transition-all"
|
||||||
|
style={{ height: `${Math.max(betH, day.total_bet_minor > 0 ? 4 : 0)}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-[30%] min-w-[4px] rounded-t-sm bg-rose-500/90 transition-all"
|
||||||
|
style={{ height: `${Math.max(payoutH, day.total_payout_minor > 0 ? 4 : 0)}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-[30%] min-w-[4px] rounded-t-sm transition-all",
|
||||||
|
profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90",
|
||||||
|
)}
|
||||||
|
style={{ height: `${Math.max(profitH, profitRaw !== 0 ? 4 : 0)}%` }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-[70%] min-w-[6px] max-w-[20px] rounded-t-md transition-all",
|
||||||
|
metric === "payout" && "bg-rose-500/90",
|
||||||
|
metric === "profit" && (profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90"),
|
||||||
|
metric === "bet" && "bg-primary/90",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
height: `${Math.max(
|
||||||
|
(metricValue(day, metric) / (metric === "bet" ? maxBet : metric === "payout" ? maxPayout : maxProfit)) * 100,
|
||||||
|
metricValue(day, metric) !== 0 ? 6 : 0,
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-center text-[10px] tabular-nums text-muted-foreground",
|
||||||
|
!showLabel && "invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{shortDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayBreakdownChart({
|
||||||
|
rows,
|
||||||
|
metric,
|
||||||
|
formatMoney,
|
||||||
|
currency,
|
||||||
|
playLabel,
|
||||||
|
}: {
|
||||||
|
rows: AdminDashboardAnalyticsPlayRow[];
|
||||||
|
metric: DashboardAnalyticsMetric;
|
||||||
|
formatMoney: MoneyFormatter;
|
||||||
|
currency: string | null;
|
||||||
|
playLabel: (code: string, dimension: number) => string;
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noPlayData")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...rows.map((r) => Math.abs(playMetricValue(r, metric === "overview" ? "bet" : metric))), 1);
|
||||||
|
const activeMetric = metric === "overview" ? "bet" : metric;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const value = playMetricValue(row, activeMetric);
|
||||||
|
const pct = (Math.abs(value) / max) * 100;
|
||||||
|
const label = playLabel(row.play_code, row.dimension);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={`${row.play_code}-${row.dimension}`}>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="truncate font-medium text-foreground">{label}</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">{formatMoney(value, currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full transition-all",
|
||||||
|
activeMetric === "payout" && "bg-rose-500",
|
||||||
|
activeMetric === "profit" && (value >= 0 ? "bg-emerald-500" : "bg-amber-500"),
|
||||||
|
activeMetric === "bet" && "bg-primary",
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{metric === "overview" ? (
|
||||||
|
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">
|
||||||
|
{t("playBreakdownHint", {
|
||||||
|
payout: formatMoney(row.total_payout_minor, currency),
|
||||||
|
profit: formatMoney(row.approx_house_gross_minor, currency),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodCompareStrip({
|
||||||
|
series,
|
||||||
|
formatMoney,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
series: AdminReportDailyProfitRow[];
|
||||||
|
formatMoney: MoneyFormatter;
|
||||||
|
currency: string | null;
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
const totalBet = series.reduce((s, d) => s + d.total_bet_minor, 0);
|
||||||
|
const totalPayout = series.reduce((s, d) => s + d.total_payout_minor, 0);
|
||||||
|
const totalProfit = series.reduce((s, d) => s + d.approx_house_gross_minor, 0);
|
||||||
|
const max = Math.max(totalBet, totalPayout, Math.abs(totalProfit), 1);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ key: "bet", label: t("chartLegend.bet"), value: totalBet, className: "bg-primary" },
|
||||||
|
{ key: "payout", label: t("chartLegend.payout"), value: totalPayout, className: "bg-rose-500" },
|
||||||
|
{
|
||||||
|
key: "profit",
|
||||||
|
label: t("chartLegend.profit"),
|
||||||
|
value: totalProfit,
|
||||||
|
className: totalProfit >= 0 ? "bg-emerald-500" : "bg-amber-500",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className={cn("size-2.5 rounded-sm", item.className)} />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded-full", item.className)}
|
||||||
|
style={{ width: `${(Math.abs(item.value) / max) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,8 +123,8 @@ export function FinanceStructureChart({
|
|||||||
const payoutRate = ((payout / bet) * 100).toFixed(1);
|
const payoutRate = ((payout / bet) * 100).toFixed(1);
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
{ key: "win", width: winW, className: "bg-chart-2", label: t("winPayout"), value: win },
|
{ key: "win", width: winW, className: "bg-emerald-500", label: t("winPayout"), value: win },
|
||||||
{ key: "jackpot", width: jpW, className: "bg-chart-4", label: t("jackpotPayout"), value: jackpot },
|
{ key: "jackpot", width: jpW, className: "bg-violet-500", label: t("jackpotPayout"), value: jackpot },
|
||||||
{ key: "gross", width: grossW, className: "bg-primary", label: t("houseGross"), value: gross },
|
{ key: "gross", width: grossW, className: "bg-primary", label: t("houseGross"), value: gross },
|
||||||
].filter((s) => s.width > 0.05);
|
].filter((s) => s.width > 0.05);
|
||||||
|
|
||||||
@@ -176,9 +176,17 @@ export function PayoutCompositionChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const winPct = (win / total) * 100;
|
const winPct = (win / total) * 100;
|
||||||
|
const winColor = "oklch(0.62 0.17 162)";
|
||||||
|
const jackpotColor = "oklch(0.56 0.22 303)";
|
||||||
const items = [
|
const items = [
|
||||||
{ label: t("winPayout"), value: win, pct: winPct, className: "bg-chart-2" },
|
{ label: t("winPayout"), value: win, pct: winPct, className: "bg-emerald-500", color: winColor },
|
||||||
{ label: t("jackpotPayout"), value: jackpot, pct: 100 - winPct, className: "bg-chart-4" },
|
{
|
||||||
|
label: t("jackpotPayout"),
|
||||||
|
value: jackpot,
|
||||||
|
pct: 100 - winPct,
|
||||||
|
className: "bg-violet-500",
|
||||||
|
color: jackpotColor,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,7 +194,7 @@ export function PayoutCompositionChart({
|
|||||||
<div
|
<div
|
||||||
className="relative mx-auto size-36 shrink-0 rounded-full"
|
className="relative mx-auto size-36 shrink-0 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: `conic-gradient(from -90deg, var(--chart-2) 0deg ${winPct * 3.6}deg, var(--chart-4) ${winPct * 3.6}deg 360deg)`,
|
background: `conic-gradient(from -90deg, ${winColor} 0deg ${winPct * 3.6}deg, ${jackpotColor} ${winPct * 3.6}deg 360deg)`,
|
||||||
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||||
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||||
}}
|
}}
|
||||||
@@ -203,7 +211,10 @@ export function PayoutCompositionChart({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</p>
|
<p className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</p>
|
||||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
<div className={cn("h-full rounded-full", item.className)} style={{ width: `${item.pct}%` }} />
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ width: `${item.pct}%`, background: item.color }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -249,12 +260,12 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme
|
|||||||
|
|
||||||
export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
|
export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [
|
const entries: { key: keyof SoldOutBuckets; label: string; color: string; swatch: string }[] = [
|
||||||
{ key: "d4", label: t("soldOutBuckets.d4"), color: "var(--chart-1)" },
|
{ key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.52 0.19 264)", swatch: "bg-blue-600" },
|
||||||
{ key: "d3", label: t("soldOutBuckets.d3"), color: "var(--chart-2)" },
|
{ key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.62 0.17 162)", swatch: "bg-emerald-500" },
|
||||||
{ key: "d2", label: t("soldOutBuckets.d2"), color: "var(--chart-3)" },
|
{ key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.72 0.16 75)", swatch: "bg-amber-500" },
|
||||||
{ key: "special", label: t("soldOutBuckets.special"), color: "var(--chart-4)" },
|
{ key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.56 0.22 303)", swatch: "bg-violet-500" },
|
||||||
{ key: "other", label: t("soldOutBuckets.other"), color: "var(--chart-5)" },
|
{ key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.58 0.2 25)", swatch: "bg-rose-500" },
|
||||||
];
|
];
|
||||||
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
|
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
|
||||||
|
|
||||||
@@ -307,7 +318,7 @@ export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElem
|
|||||||
<li key={e.key}>
|
<li key={e.key}>
|
||||||
<div className="mb-1 flex justify-between text-sm">
|
<div className="mb-1 flex justify-between text-sm">
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
<span className="size-2.5 rounded-sm" style={{ background: e.color }} />
|
<span className={cn("size-2.5 rounded-sm", e.swatch)} />
|
||||||
{e.label}
|
{e.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium tabular-nums">
|
<span className="font-medium tabular-nums">
|
||||||
@@ -381,6 +392,25 @@ export function SettlementStatusChart({
|
|||||||
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
const max = Math.max(...entries.map((e) => e[1]));
|
const max = Math.max(...entries.map((e) => e[1]));
|
||||||
|
|
||||||
|
const barTone = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending_review":
|
||||||
|
return "bg-amber-500";
|
||||||
|
case "approved":
|
||||||
|
return "bg-sky-500";
|
||||||
|
case "paid":
|
||||||
|
case "completed":
|
||||||
|
return "bg-emerald-600";
|
||||||
|
case "running":
|
||||||
|
return "bg-blue-500";
|
||||||
|
case "rejected":
|
||||||
|
case "failed":
|
||||||
|
return "bg-rose-500";
|
||||||
|
default:
|
||||||
|
return "bg-violet-500";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{entries.map(([status, count]) => (
|
{entries.map(([status, count]) => (
|
||||||
@@ -391,7 +421,7 @@ export function SettlementStatusChart({
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-primary/80"
|
className={cn("h-full rounded-full transition-all", barTone(status))}
|
||||||
style={{ width: `${max > 0 ? (count / max) * 100 : 0}%` }}
|
style={{ width: `${max > 0 ? (count / max) * 100 : 0}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
@@ -25,7 +26,11 @@ import { useAdminProfile } from "@/stores/admin-session";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { drawResultSourceLabel, drawStatusLabel } from "./draw-display";
|
import {
|
||||||
|
drawResultSourceLabel,
|
||||||
|
drawStatusLabel,
|
||||||
|
hallPreviewDiffersFromDbStatus,
|
||||||
|
} from "./draw-display";
|
||||||
import { DrawStatusBadge } from "./draw-status-badge";
|
import { DrawStatusBadge } from "./draw-status-badge";
|
||||||
import {
|
import {
|
||||||
PRD_DRAW_REOPEN_MANAGE,
|
PRD_DRAW_REOPEN_MANAGE,
|
||||||
@@ -58,6 +63,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [acting, setActing] = useState<string | null>(null);
|
const [acting, setActing] = useState<string | null>(null);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum)) {
|
||||||
@@ -120,13 +126,15 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
status={data.status}
|
status={data.status}
|
||||||
label={drawStatusLabel(data.status, t)}
|
label={drawStatusLabel(data.status, t)}
|
||||||
/>
|
/>
|
||||||
<p className="flex flex-wrap items-center justify-end gap-2 text-sm text-muted-foreground">
|
{hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? (
|
||||||
<span>{t("hallPreviewStatusLabel")}</span>
|
<p className="flex flex-wrap items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||||
<DrawStatusBadge
|
<span>{t("hallPreviewStatusLabel")}</span>
|
||||||
status={data.hall_preview_status}
|
<DrawStatusBadge
|
||||||
label={drawStatusLabel(data.hall_preview_status, t)}
|
status={data.hall_preview_status}
|
||||||
/>
|
label={drawStatusLabel(data.hall_preview_status, t)}
|
||||||
</p>
|
/>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -186,7 +194,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
|
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
|
||||||
onClick={() => void runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum))}
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.manualCloseTitle"),
|
||||||
|
description: t("confirm.manualCloseDescription"),
|
||||||
|
onConfirm: () => runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum)),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{acting === t("manualClose") ? t("processing") : t("manualClose")}
|
{acting === t("manualClose") ? t("processing") : t("manualClose")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -195,7 +209,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
|
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
|
||||||
onClick={() => void runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum))}
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.cancelDrawTitle"),
|
||||||
|
description: t("confirm.cancelDrawDescription"),
|
||||||
|
onConfirm: () => runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum)),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{acting === t("cancelDraw") ? t("processing") : t("cancelBeforeDraw")}
|
{acting === t("cancelDraw") ? t("processing") : t("cancelBeforeDraw")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -204,7 +224,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
|
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
|
||||||
onClick={() => void runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum))}
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.rngDrawTitle"),
|
||||||
|
description: t("confirm.rngDrawDescription"),
|
||||||
|
onConfirm: () => runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum)),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
|
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -214,7 +240,14 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={acting !== null || data.status !== "cooldown"}
|
disabled={acting !== null || data.status !== "cooldown"}
|
||||||
onClick={() => void runAction(t("reopen"), () => postAdminReopenDraw(idNum))}
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.reopenTitle"),
|
||||||
|
description: t("confirm.reopenDescription"),
|
||||||
|
confirmVariant: "destructive",
|
||||||
|
onConfirm: () => runAction(t("reopen"), () => postAdminReopenDraw(idNum)),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{acting === t("reopen") ? t("processing") : t("cooldownReopen")}
|
{acting === t("reopen") ? t("processing") : t("cooldownReopen")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -224,13 +257,20 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canRunSettlement || acting !== null || data.status !== "settling"}
|
disabled={!canRunSettlement || acting !== null || data.status !== "settling"}
|
||||||
onClick={() => void runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum))}
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.runSettlementTitle"),
|
||||||
|
description: t("confirm.runSettlementDescription"),
|
||||||
|
onConfirm: () => runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum)),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
|
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ type DrawTranslate = (
|
|||||||
options?: { ns?: string; index?: number },
|
options?: { ns?: string; index?: number },
|
||||||
) => string;
|
) => string;
|
||||||
|
|
||||||
|
/** 大厅展示态是否与库内期号状态不同(仅 open 等 tick 修正时可能不同) */
|
||||||
|
export function hallPreviewDiffersFromDbStatus(
|
||||||
|
dbStatus: string,
|
||||||
|
hallPreviewStatus: string,
|
||||||
|
): boolean {
|
||||||
|
return dbStatus !== hallPreviewStatus;
|
||||||
|
}
|
||||||
|
|
||||||
/** 期号状态文案(draws.statusOptions) */
|
/** 期号状态文案(draws.statusOptions) */
|
||||||
export function drawStatusLabel(status: string, t: DrawTranslate): string {
|
export function drawStatusLabel(status: string, t: DrawTranslate): string {
|
||||||
const key = `statusOptions.${status}`;
|
const key = `statusOptions.${status}`;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [settling, setSettling] = useState(false);
|
const [settling, setSettling] = useState(false);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum) || idNum < 1) {
|
if (!Number.isFinite(idNum) || idNum < 1) {
|
||||||
@@ -150,7 +152,13 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canRunSettlement || settling || data.draw_status !== "settling"}
|
disabled={!canRunSettlement || settling || data.draw_status !== "settling"}
|
||||||
onClick={() => void runSettlement()}
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.runSettlementTitle"),
|
||||||
|
description: t("confirm.runSettlementDescription"),
|
||||||
|
onConfirm: () => runSettlement(),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{settling ? t("processing") : t("runSettlement")}
|
{settling ? t("processing") : t("runSettlement")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -222,6 +230,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** 开奖结果发布权限 slug */
|
export {
|
||||||
export const PRD_DRAW_RESULT_MANAGE = "prd.draw_result.manage" as const;
|
PRD_DRAW_RESULT_MANAGE,
|
||||||
export const PRD_DRAW_REOPEN_MANAGE = "prd.draw_reopen.manage" as const;
|
PRD_DRAW_REOPEN_MANAGE,
|
||||||
export const PRD_PAYOUT_MANAGE = "prd.payout.manage" as const;
|
PRD_PAYOUT_MANAGE,
|
||||||
export const PRD_PAYOUT_REVIEW = "prd.payout.review" as const;
|
PRD_PAYOUT_REVIEW,
|
||||||
|
} from "@/lib/admin-prd";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
@@ -38,6 +39,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum)) {
|
||||||
@@ -184,12 +186,20 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canPublish || publishing}
|
disabled={!canPublish || publishing}
|
||||||
onClick={() => void publish()}
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.publishTitle"),
|
||||||
|
description: t("confirm.publishDescription"),
|
||||||
|
confirmVariant: "destructive",
|
||||||
|
onConfirm: () => publish(),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{publishing ? t("submitting") : t("confirmPublish")}
|
{publishing ? t("submitting") : t("confirmPublish")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
@@ -56,6 +57,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingManual, setSavingManual] = useState(false);
|
const [savingManual, setSavingManual] = useState(false);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const [manualNumbers, setManualNumbers] = useState<string[]>(
|
const [manualNumbers, setManualNumbers] = useState<string[]>(
|
||||||
() => RESULT_SLOTS.map(() => ""),
|
() => RESULT_SLOTS.map(() => ""),
|
||||||
);
|
);
|
||||||
@@ -172,7 +174,13 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
|
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
|
||||||
onClick={() => void saveManualDraft()}
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.saveManualDraftTitle"),
|
||||||
|
description: t("confirm.saveManualDraftDescription"),
|
||||||
|
onConfirm: () => saveManualDraft(),
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{savingManual ? t("saving") : t("saveDraft")}
|
{savingManual ? t("saving") : t("saveDraft")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -224,6 +232,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -75,6 +76,7 @@ export function DrawsIndexConsole() {
|
|||||||
const defaultCurrency = "NPR";
|
const defaultCurrency = "NPR";
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -148,11 +150,22 @@ export function DrawsIndexConsole() {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card className="admin-list-card">
|
<Card className="admin-list-card">
|
||||||
<CardHeader className="admin-list-header flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="admin-list-header flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<CardTitle className="admin-list-title">{t("statusListTitle")}</CardTitle>
|
<CardTitle className="admin-list-title">{t("statusListTitle")}</CardTitle>
|
||||||
{canManageDraw ? (
|
{canManageDraw ? (
|
||||||
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirm.generatePlanTitle"),
|
||||||
|
description: t("confirm.generatePlanDescription"),
|
||||||
|
onConfirm: () => generatePlan(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={generating}
|
||||||
|
>
|
||||||
{generating ? t("generating") : t("generatePlan")}
|
{generating ? t("generating") : t("generatePlan")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -331,5 +344,7 @@ export function DrawsIndexConsole() {
|
|||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ConfirmDialog />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||||
|
|
||||||
/** 奖池单页:池参数 + 流水记录,避免 ConfigDocPage / 内层 Card 重复套娃。 */
|
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
|
||||||
export function JackpotConfigScreen() {
|
export function JackpotConfigScreen() {
|
||||||
const { t } = useTranslation("jackpot");
|
const { t } = useTranslation("jackpot");
|
||||||
|
|
||||||
@@ -23,20 +24,14 @@ export function JackpotConfigScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex w-full max-w-none flex-col gap-6">
|
||||||
<section className="space-y-4">
|
<AdminPageCard title={t("poolsSectionTitle")}>
|
||||||
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
|
|
||||||
{t("poolsSectionTitle")}
|
|
||||||
</h2>
|
|
||||||
<JackpotPoolsConsole embedded />
|
<JackpotPoolsConsole embedded />
|
||||||
</section>
|
</AdminPageCard>
|
||||||
|
|
||||||
<section id="jackpot-records" className="scroll-mt-24 space-y-4">
|
<AdminPageCard id="jackpot-records" title={t("recordsSectionTitle")}>
|
||||||
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
|
|
||||||
{t("recordsSectionTitle")}
|
|
||||||
</h2>
|
|
||||||
<JackpotRecordsConsole embedded />
|
<JackpotRecordsConsole embedded />
|
||||||
</section>
|
</AdminPageCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
postAdminJackpotManualBurst,
|
postAdminJackpotManualBurst,
|
||||||
putAdminJackpotPool,
|
putAdminJackpotPool,
|
||||||
} from "@/api/admin-jackpot";
|
} from "@/api/admin-jackpot";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -20,7 +23,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
|
import type { AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
|
||||||
|
|
||||||
@@ -34,7 +46,6 @@ type Draft = {
|
|||||||
combo_trigger_play_codes: string;
|
combo_trigger_play_codes: string;
|
||||||
status: string;
|
status: string;
|
||||||
manual_burst_draw_id: string;
|
manual_burst_draw_id: string;
|
||||||
manual_burst_amount: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function toDraft(p: AdminJackpotPoolRow): Draft {
|
function toDraft(p: AdminJackpotPoolRow): Draft {
|
||||||
@@ -48,7 +59,6 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
|
|||||||
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
|
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
|
||||||
status: String(p.status),
|
status: String(p.status),
|
||||||
manual_burst_draw_id: "",
|
manual_burst_draw_id: "",
|
||||||
manual_burst_amount: "",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,11 +69,16 @@ type JackpotPoolsConsoleProps = {
|
|||||||
|
|
||||||
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
|
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
|
||||||
const { t } = useTranslation(["jackpot", "common"]);
|
const { t } = useTranslation(["jackpot", "common"]);
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
|
||||||
|
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog: ConfirmActionDialog } = useConfirmAction();
|
||||||
const [items, setItems] = useState<AdminJackpotPoolRow[]>([]);
|
const [items, setItems] = useState<AdminJackpotPoolRow[]>([]);
|
||||||
const [drafts, setDrafts] = useState<Record<number, Draft>>({});
|
const [drafts, setDrafts] = useState<Record<number, Draft>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingId, setSavingId] = useState<number | null>(null);
|
const [savingId, setSavingId] = useState<number | null>(null);
|
||||||
const [burstingId, setBurstingId] = useState<number | null>(null);
|
const [burstingId, setBurstingId] = useState<number | null>(null);
|
||||||
|
const [confirmBurstPoolId, setConfirmBurstPoolId] = useState<number | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -131,22 +146,18 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = d.manual_burst_amount.trim()
|
|
||||||
? Number.parseInt(d.manual_burst_amount, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
setBurstingId(p.id);
|
setBurstingId(p.id);
|
||||||
try {
|
try {
|
||||||
await postAdminJackpotManualBurst(p.id, {
|
const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawId });
|
||||||
draw_id: drawId,
|
toast.success(
|
||||||
amount: amount !== undefined && Number.isFinite(amount) ? amount : undefined,
|
`${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`,
|
||||||
});
|
);
|
||||||
toast.success(t("manualBurstSuccess"));
|
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("manualBurstFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("manualBurstFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setBurstingId(null);
|
setBurstingId(null);
|
||||||
|
setConfirmBurstPoolId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,7 +175,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
className="space-y-4 rounded-xl border border-border/60 bg-muted/10 p-4"
|
className="space-y-4 rounded-xl border border-border/60 bg-muted/10 p-4"
|
||||||
>
|
>
|
||||||
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<fieldset disabled={!canManageJackpot} className="grid gap-4 border-0 p-0 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label>
|
<Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -244,16 +255,31 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
<div className="flex justify-end border-t border-border/60 pt-3">
|
{canManageJackpot ? (
|
||||||
<Button type="button" disabled={savingId === p.id} onClick={() => void save(p)}>
|
<div className="flex justify-end border-t border-border/60 pt-3">
|
||||||
{savingId === p.id ? t("saving") : t("save")}
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
</div>
|
disabled={savingId === p.id}
|
||||||
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirmSavePoolTitle"),
|
||||||
|
description: t("confirmSavePoolDescription"),
|
||||||
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
|
onConfirm: () => save(p),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{savingId === p.id ? t("saving") : t("save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{canManualBurst ? (
|
||||||
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
||||||
<p className="mb-3 text-xs font-medium text-amber-900 dark:text-amber-200">
|
<p className="mb-1 text-xs font-medium text-amber-900 dark:text-amber-200">
|
||||||
{t("manualBurst")}
|
{t("manualBurst")}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mb-3 text-xs text-amber-800/90 dark:text-amber-300/90">{t("manualBurstHint")}</p>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||||
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
|
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
|
||||||
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
|
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
|
||||||
@@ -264,34 +290,63 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
|
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
|
|
||||||
<Label htmlFor={`burst-amount-${p.id}`}>{t("manualBurstAmount")}</Label>
|
|
||||||
<Input
|
|
||||||
id={`burst-amount-${p.id}`}
|
|
||||||
className="font-mono"
|
|
||||||
value={d.manual_burst_amount}
|
|
||||||
onChange={(e) => updateDraft(p.id, { manual_burst_amount: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="shrink-0 sm:ml-auto"
|
className="shrink-0 sm:ml-auto"
|
||||||
disabled={burstingId === p.id}
|
disabled={burstingId === p.id}
|
||||||
onClick={() => void manualBurst(p)}
|
onClick={() => setConfirmBurstPoolId(p.id)}
|
||||||
>
|
>
|
||||||
{burstingId === p.id ? t("processing") : t("manualBurst")}
|
{burstingId === p.id ? t("processing") : t("manualBurst")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const confirmPool = confirmBurstPoolId !== null ? items.find((p) => p.id === confirmBurstPoolId) : null;
|
||||||
|
const confirmDraft = confirmPool ? drafts[confirmPool.id] : null;
|
||||||
|
|
||||||
|
const confirmDialog = (
|
||||||
|
<Dialog open={confirmBurstPoolId !== null} onOpenChange={(open) => !open && setConfirmBurstPoolId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("manualBurstConfirmTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("manualBurstConfirmDescription", {
|
||||||
|
drawId: confirmDraft?.manual_burst_draw_id ?? "—",
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setConfirmBurstPoolId(null)}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={confirmPool === undefined || burstingId !== null}
|
||||||
|
onClick={() => confirmPool && void manualBurst(confirmPool)}
|
||||||
|
>
|
||||||
|
{t("manualBurstConfirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return poolList;
|
return (
|
||||||
|
<>
|
||||||
|
{poolList}
|
||||||
|
{confirmDialog}
|
||||||
|
<ConfirmActionDialog />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -302,6 +357,8 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>{poolList}</CardContent>
|
<CardContent>{poolList}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{confirmDialog}
|
||||||
|
<ConfirmActionDialog />
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filterBlock = embedded ? (
|
const filterBlock = embedded ? (
|
||||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
<div className="admin-list-toolbar mb-0 border-t-0 pt-0">
|
||||||
<div className="flex max-w-xs flex-1 flex-col gap-1.5">
|
<div className="admin-list-field max-w-xs flex-1">
|
||||||
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="jk-draw"
|
id="jk-draw"
|
||||||
@@ -166,9 +166,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
placeholder={t("optional")}
|
placeholder={t("optional")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" onClick={applyDraw}>
|
<div className="admin-list-actions">
|
||||||
{t("apply")}
|
<Button type="button" onClick={applyDraw}>
|
||||||
</Button>
|
{t("apply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -9,6 +10,8 @@ import {
|
|||||||
deleteAdminPlayer,
|
deleteAdminPlayer,
|
||||||
getAdminPlayers,
|
getAdminPlayers,
|
||||||
postAdminPlayer,
|
postAdminPlayer,
|
||||||
|
postAdminPlayerFreeze,
|
||||||
|
postAdminPlayerUnfreeze,
|
||||||
putAdminPlayer,
|
putAdminPlayer,
|
||||||
} from "@/api/admin-player";
|
} from "@/api/admin-player";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
@@ -27,6 +30,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -63,10 +67,12 @@ const PLAYER_STATUS_OPTIONS = [
|
|||||||
|
|
||||||
export function PlayersConsole(): React.ReactElement {
|
export function PlayersConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["players", "common"]);
|
const { t } = useTranslation(["players", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const exportLabels = useExportLabels("players");
|
const exportLabels = useExportLabels("players");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const canManagePlayers = adminHasAnyPermission(profile?.permissions, ["prd.users.manage"]);
|
const canManagePlayers = adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||||
|
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
@@ -91,6 +97,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<AdminPlayerRow | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<AdminPlayerRow | null>(null);
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
const [freezeBusyId, setFreezeBusyId] = useState<number | null>(null);
|
||||||
|
|
||||||
const editingPlayer = useMemo(
|
const editingPlayer = useMemo(
|
||||||
() => items.find((p) => p.id === editingAccountId) ?? null,
|
() => items.find((p) => p.id === editingAccountId) ?? null,
|
||||||
@@ -226,6 +233,28 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleFreeze(row: AdminPlayerRow, freeze: boolean): Promise<void> {
|
||||||
|
setFreezeBusyId(row.id);
|
||||||
|
try {
|
||||||
|
const updated = freeze
|
||||||
|
? await postAdminPlayerFreeze(row.id)
|
||||||
|
: await postAdminPlayerUnfreeze(row.id);
|
||||||
|
setItems((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
|
||||||
|
const name = updated.username ?? updated.site_player_id;
|
||||||
|
toast.success(freeze ? t("freezeSuccess", { name }) : t("unfreezeSuccess", { name }));
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof LotteryApiBizError
|
||||||
|
? e.message
|
||||||
|
: freeze
|
||||||
|
? t("freezeFailed")
|
||||||
|
: t("unfreezeFailed");
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setFreezeBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDelete(): Promise<void> {
|
async function confirmDelete(): Promise<void> {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
setDeleteBusy(true);
|
setDeleteBusy(true);
|
||||||
@@ -364,26 +393,66 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
: "—"}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{canManagePlayers ? (
|
{canManagePlayers || canFreezePlayers ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<Button
|
{canFreezePlayers && row.status === 0 ? (
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant={
|
size="sm"
|
||||||
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
variant="outline"
|
||||||
}
|
disabled={freezeBusyId === row.id}
|
||||||
onClick={() => openEditAccount(row)}
|
onClick={() => {
|
||||||
>
|
const name = row.username ?? row.site_player_id;
|
||||||
{t("edit")}
|
requestConfirm({
|
||||||
</Button>
|
title: t("confirmFreezeTitle"),
|
||||||
<Button
|
description: t("confirmFreezeDescription", { name }),
|
||||||
type="button"
|
onConfirm: () => toggleFreeze(row, true),
|
||||||
size="sm"
|
});
|
||||||
variant="destructive"
|
}}
|
||||||
onClick={() => setDeleteTarget(row)}
|
>
|
||||||
>
|
{freezeBusyId === row.id ? t("saving") : t("freeze")}
|
||||||
{t("delete")}
|
</Button>
|
||||||
</Button>
|
) : null}
|
||||||
|
{canFreezePlayers && row.status === 1 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={freezeBusyId === row.id}
|
||||||
|
onClick={() => {
|
||||||
|
const name = row.username ?? row.site_player_id;
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirmUnfreezeTitle"),
|
||||||
|
description: t("confirmUnfreezeDescription", { name }),
|
||||||
|
onConfirm: () => toggleFreeze(row, false),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{freezeBusyId === row.id ? t("saving") : t("unfreeze")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManagePlayers ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={
|
||||||
|
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
||||||
|
}
|
||||||
|
onClick={() => openEditAccount(row)}
|
||||||
|
>
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteTarget(row)}
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
@@ -554,6 +623,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -79,6 +80,7 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
|||||||
|
|
||||||
export function ReconcileConsole(): React.ReactElement {
|
export function ReconcileConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["reconcile", "common"]);
|
const { t } = useTranslation(["reconcile", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
@@ -240,7 +242,22 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" className="w-full lg:w-auto" onClick={() => void onCreate()} disabled={submitting}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full lg:w-auto"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("confirmCreateTitle"),
|
||||||
|
description: t("confirmCreateDescription", {
|
||||||
|
playerHint: selectedPlayer
|
||||||
|
? t("confirmCreatePlayer")
|
||||||
|
: t("confirmCreateAllPlayers"),
|
||||||
|
}),
|
||||||
|
onConfirm: () => onCreate(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
{submitting ? t("submitting") : t("createTask")}
|
{submitting ? t("submitting") : t("createTask")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,6 +549,7 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ import {
|
|||||||
import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
|
import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
|
||||||
import { getAdminUsers } from "@/api/admin-users";
|
import { getAdminUsers } from "@/api/admin-users";
|
||||||
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -365,6 +367,9 @@ function resultRowCount(result: ReportResult | null): number {
|
|||||||
|
|
||||||
export function ReportsConsole() {
|
export function ReportsConsole() {
|
||||||
const { t, i18n } = useTranslation(["reports", "common"]);
|
const { t, i18n } = useTranslation(["reports", "common"]);
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
|
||||||
|
const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]);
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
useAdminPlayTypeCatalog();
|
useAdminPlayTypeCatalog();
|
||||||
const playCodeLabel = useAdminPlayCodeLabel();
|
const playCodeLabel = useAdminPlayCodeLabel();
|
||||||
@@ -446,6 +451,9 @@ export function ReportsConsole() {
|
|||||||
}, [search.open, search.query, loadSearchOptions]);
|
}, [search.open, search.query, loadSearchOptions]);
|
||||||
|
|
||||||
const queryReport = useCallback(async () => {
|
const queryReport = useCallback(async () => {
|
||||||
|
if (!canViewReports) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -739,7 +747,7 @@ export function ReportsConsole() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filters, page, perPage, selectedReport, t]);
|
}, [canViewReports, filters, page, perPage, selectedReport, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
@@ -766,6 +774,9 @@ export function ReportsConsole() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exportReport(format: ExportFormat): void {
|
function exportReport(format: ExportFormat): void {
|
||||||
|
if (!canExportReports) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!result || result.rows.length === 0) {
|
if (!result || result.rows.length === 0) {
|
||||||
toast.info(t("empty"));
|
toast.info(t("empty"));
|
||||||
return;
|
return;
|
||||||
@@ -1173,15 +1184,6 @@ export function ReportsConsole() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h1 className="text-lg font-semibold tracking-tight text-[#13315f]">{t("title")}</h1>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="h-7 px-3">
|
|
||||||
{resultRowCount(result)} {t("preview.exportableRows")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||||
<Card className="admin-list-card self-start">
|
<Card className="admin-list-card self-start">
|
||||||
<CardHeader className="admin-list-header pb-4">
|
<CardHeader className="admin-list-header pb-4">
|
||||||
@@ -1233,7 +1235,7 @@ export function ReportsConsole() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedReport.connected || loading}
|
disabled={!canViewReports || !selectedReport.connected || loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
void queryReport();
|
void queryReport();
|
||||||
@@ -1267,11 +1269,20 @@ export function ReportsConsole() {
|
|||||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<Button type="button" variant="outline" disabled={!result || exporting !== null} onClick={() => exportReport("csv")}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canExportReports || !result || exporting !== null}
|
||||||
|
onClick={() => exportReport("csv")}
|
||||||
|
>
|
||||||
<FileDown data-icon="inline-start" />
|
<FileDown data-icon="inline-start" />
|
||||||
{t("formats.csv")}
|
{t("formats.csv")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" disabled={!result || exporting !== null} onClick={() => exportReport("excel")}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!canExportReports || !result || exporting !== null}
|
||||||
|
onClick={() => exportReport("excel")}
|
||||||
|
>
|
||||||
<FileSpreadsheet data-icon="inline-start" />
|
<FileSpreadsheet data-icon="inline-start" />
|
||||||
{t("formats.excel")}
|
{t("formats.excel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getAdminDraw } from "@/api/admin-draws";
|
import { getAdminDraw } from "@/api/admin-draws";
|
||||||
|
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display";
|
||||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||||
|
|
||||||
export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
||||||
const { t } = useTranslation("risk");
|
const { t } = useTranslation(["risk", "draws"]);
|
||||||
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
|
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -47,10 +48,19 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span>{t("databaseStatus")}</span>
|
<span>{t("databaseStatus")}</span>
|
||||||
<DrawStatusBadge status={draw.status} />
|
<DrawStatusBadge
|
||||||
<span className="text-xs opacity-80">
|
status={draw.status}
|
||||||
{t("hallPreviewStatus", { status: draw.hall_preview_status })}
|
label={drawStatusLabel(draw.status, t)}
|
||||||
</span>
|
/>
|
||||||
|
{hallPreviewDiffersFromDbStatus(draw.status, draw.hall_preview_status) ? (
|
||||||
|
<>
|
||||||
|
<span>{t("hallPreviewStatusLabel", { ns: "draws" })}</span>
|
||||||
|
<DrawStatusBadge
|
||||||
|
status={draw.hall_preview_status}
|
||||||
|
label={drawStatusLabel(draw.hall_preview_status, t)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_DRAW_RESULT_MANAGE, PRD_RISK_MANAGE } from "@/lib/admin-prd";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -77,6 +81,12 @@ export function RiskPoolsConsole({
|
|||||||
allowSortChange = false,
|
allowSortChange = false,
|
||||||
}: RiskPoolsConsoleProps) {
|
}: RiskPoolsConsoleProps) {
|
||||||
const { t } = useTranslation(["risk", "common"]);
|
const { t } = useTranslation(["risk", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [
|
||||||
|
PRD_RISK_MANAGE,
|
||||||
|
PRD_DRAW_RESULT_MANAGE,
|
||||||
|
]);
|
||||||
const pageTitle = titleKey ? t(titleKey) : (title ?? t("poolsTitle"));
|
const pageTitle = titleKey ? t(titleKey) : (title ?? t("poolsTitle"));
|
||||||
const exportLabels = useExportLabels("riskPools");
|
const exportLabels = useExportLabels("riskPools");
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
@@ -148,6 +158,7 @@ export function RiskPoolsConsole({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card className="admin-list-card">
|
<Card className="admin-list-card">
|
||||||
<CardHeader className="admin-list-header space-y-3">
|
<CardHeader className="admin-list-header space-y-3">
|
||||||
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
|
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
|
||||||
@@ -292,15 +303,28 @@ export function RiskPoolsConsole({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
{canManageRiskPools ? (
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant={row.is_sold_out ? "outline" : "destructive"}
|
size="sm"
|
||||||
disabled={acting}
|
variant={row.is_sold_out ? "outline" : "destructive"}
|
||||||
onClick={() => void handleManualStatus(row)}
|
disabled={acting}
|
||||||
>
|
onClick={() =>
|
||||||
{row.is_sold_out ? t("recover") : t("close")}
|
requestConfirm({
|
||||||
</Button>
|
title: row.is_sold_out
|
||||||
|
? t("confirm.recoverTitle")
|
||||||
|
: t("confirm.closeTitle"),
|
||||||
|
description: row.is_sold_out
|
||||||
|
? t("confirm.recoverDescription", { number: row.normalized_number })
|
||||||
|
: t("confirm.closeDescription", { number: row.normalized_number }),
|
||||||
|
confirmVariant: row.is_sold_out ? "default" : "destructive",
|
||||||
|
onConfirm: () => handleManualStatus(row),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.is_sold_out ? t("recover") : t("close")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/draws/${drawId}/risk/pools/${row.normalized_number}`}
|
href={`/admin/draws/${drawId}/risk/pools/${row.normalized_number}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -338,5 +362,7 @@ export function RiskPoolsConsole({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ConfirmDialog />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { PRD_RULES_ODDS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||||
import { ConfigSection } from "@/modules/config/config-section";
|
import { ConfigSection } from "@/modules/config/config-section";
|
||||||
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
||||||
@@ -27,14 +29,20 @@ export function RulesOddsConfigScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RulesPageShell>
|
<RulesPageShell>
|
||||||
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="space-y-10">
|
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
||||||
<ConfigSection title={t("nav.items.odds")}>
|
<ConfigDocPage
|
||||||
<OddsConfigDocScreen embedded />
|
title={t("nav.rulesOddsTitle")}
|
||||||
</ConfigSection>
|
description={t("nav.rulesOddsDescription")}
|
||||||
<ConfigSection id="rebate" title={t("nav.items.rebate")}>
|
contentClassName="space-y-10"
|
||||||
<RebateConfigDocScreen embedded />
|
>
|
||||||
</ConfigSection>
|
<ConfigSection title={t("nav.items.odds")} description={t("odds.sectionHint")}>
|
||||||
</ConfigDocPage>
|
<OddsConfigDocScreen embedded />
|
||||||
|
</ConfigSection>
|
||||||
|
<ConfigSection id="rebate" title={t("nav.items.rebate")} description={t("rebate.sectionHint")}>
|
||||||
|
<RebateConfigDocScreen embedded />
|
||||||
|
</ConfigSection>
|
||||||
|
</ConfigDocPage>
|
||||||
|
</AdminPermissionGate>
|
||||||
</RulesPageShell>
|
</RulesPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
postAdminCurrency,
|
postAdminCurrency,
|
||||||
putAdminCurrency,
|
putAdminCurrency,
|
||||||
} from "@/api/admin-currencies";
|
} from "@/api/admin-currencies";
|
||||||
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -204,16 +204,21 @@ export function CurrencySettingsPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="admin-list-card">
|
<>
|
||||||
<CardHeader className="admin-list-header flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<AdminPageCard
|
||||||
<CardTitle className="admin-list-title">{t("currencies.title", { ns: "config" })}</CardTitle>
|
title={t("currencies.title", { ns: "config" })}
|
||||||
<div className="flex items-center gap-2">
|
description={t("currencies.description", { ns: "config" })}
|
||||||
<AdminTableExportButton tableId="admin-currencies-table" filename={exportLabels.filename}
|
actions={
|
||||||
sheetName={exportLabels.sheetName} />
|
<>
|
||||||
<Button onClick={openCreate}>{t("currencies.actions.create", { ns: "config" })}</Button>
|
<AdminTableExportButton
|
||||||
</div>
|
tableId="admin-currencies-table"
|
||||||
</CardHeader>
|
filename={exportLabels.filename}
|
||||||
<CardContent className="admin-list-content">
|
sheetName={exportLabels.sheetName}
|
||||||
|
/>
|
||||||
|
<Button onClick={openCreate}>{t("currencies.actions.create", { ns: "config" })}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
<Table id="admin-currencies-table">
|
<Table id="admin-currencies-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -277,7 +282,7 @@ export function CurrencySettingsPanel() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</AdminPageCard>
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent showCloseButton className="sm:max-w-md">
|
<DialogContent showCloseButton className="sm:max-w-md">
|
||||||
@@ -385,6 +390,6 @@ export function CurrencySettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Card>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import {
|
|||||||
getAdminSettings,
|
getAdminSettings,
|
||||||
updateAdminSetting,
|
updateAdminSetting,
|
||||||
} from "@/api/admin-settings";
|
} from "@/api/admin-settings";
|
||||||
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -23,6 +26,8 @@ const DRAW_KEYS = {
|
|||||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||||
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
||||||
|
AUTO_APPROVE: "settlement.auto_approve_on_tick",
|
||||||
|
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const FRONTEND_GROUP = "frontend";
|
const FRONTEND_GROUP = "frontend";
|
||||||
@@ -37,6 +42,8 @@ interface RuntimeDraft {
|
|||||||
requireManualReview: boolean;
|
requireManualReview: boolean;
|
||||||
cooldownMinutes: string;
|
cooldownMinutes: string;
|
||||||
autoSettlement: boolean;
|
autoSettlement: boolean;
|
||||||
|
autoApprove: boolean;
|
||||||
|
autoPayout: boolean;
|
||||||
playRulesHtmlZh: string;
|
playRulesHtmlZh: string;
|
||||||
playRulesHtmlEn: string;
|
playRulesHtmlEn: string;
|
||||||
playRulesHtmlNe: string;
|
playRulesHtmlNe: string;
|
||||||
@@ -116,10 +123,13 @@ function SaveActions({
|
|||||||
|
|
||||||
export function SystemSettingsScreen() {
|
export function SystemSettingsScreen() {
|
||||||
const { t } = useTranslation(["common", "config", "adminUsers"]);
|
const { t } = useTranslation(["common", "config", "adminUsers"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const [draft, setDraft] = useState<RuntimeDraft>({
|
const [draft, setDraft] = useState<RuntimeDraft>({
|
||||||
requireManualReview: false,
|
requireManualReview: false,
|
||||||
cooldownMinutes: "15",
|
cooldownMinutes: "15",
|
||||||
autoSettlement: true,
|
autoSettlement: true,
|
||||||
|
autoApprove: true,
|
||||||
|
autoPayout: true,
|
||||||
playRulesHtmlZh: "",
|
playRulesHtmlZh: "",
|
||||||
playRulesHtmlEn: "",
|
playRulesHtmlEn: "",
|
||||||
playRulesHtmlNe: "",
|
playRulesHtmlNe: "",
|
||||||
@@ -128,6 +138,8 @@ export function SystemSettingsScreen() {
|
|||||||
requireManualReview: false,
|
requireManualReview: false,
|
||||||
cooldownMinutes: "15",
|
cooldownMinutes: "15",
|
||||||
autoSettlement: true,
|
autoSettlement: true,
|
||||||
|
autoApprove: true,
|
||||||
|
autoPayout: true,
|
||||||
playRulesHtmlZh: "",
|
playRulesHtmlZh: "",
|
||||||
playRulesHtmlEn: "",
|
playRulesHtmlEn: "",
|
||||||
playRulesHtmlNe: "",
|
playRulesHtmlNe: "",
|
||||||
@@ -155,6 +167,8 @@ export function SystemSettingsScreen() {
|
|||||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||||
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
|
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
|
||||||
|
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
|
||||||
|
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
|
||||||
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
||||||
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
||||||
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
||||||
@@ -189,6 +203,8 @@ export function SystemSettingsScreen() {
|
|||||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||||
);
|
);
|
||||||
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
|
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
|
||||||
|
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
|
||||||
|
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
|
||||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
|
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
|
||||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
|
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
|
||||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
|
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
|
||||||
@@ -210,12 +226,11 @@ export function SystemSettingsScreen() {
|
|||||||
const discardLabel = t("system.discard", { ns: "config" });
|
const discardLabel = t("system.discard", { ns: "config" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex w-full max-w-none flex-col gap-6">
|
||||||
<section className="space-y-4">
|
<AdminPageCard
|
||||||
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
|
title={t("system.title", { ns: "config" })}
|
||||||
{t("system.title", { ns: "config" })}
|
description={t("system.description", { ns: "config" })}
|
||||||
</h2>
|
>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||||
@@ -243,6 +258,32 @@ export function SystemSettingsScreen() {
|
|||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
<div className="h-px bg-border/60" />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
||||||
|
<BinaryChoice
|
||||||
|
active={draft.autoApprove}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={(value) => updateDraft("autoApprove", value)}
|
||||||
|
leftLabel={t("system.states.disabled", { ns: "config" })}
|
||||||
|
rightLabel={t("system.states.enabled", { ns: "config" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border/60" />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
||||||
|
<BinaryChoice
|
||||||
|
active={draft.autoPayout}
|
||||||
|
disabled={loading || saving}
|
||||||
|
onChange={(value) => updateDraft("autoPayout", value)}
|
||||||
|
leftLabel={t("system.states.disabled", { ns: "config" })}
|
||||||
|
rightLabel={t("system.states.enabled", { ns: "config" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-border/60" />
|
||||||
|
|
||||||
<div className="grid max-w-xs gap-2">
|
<div className="grid max-w-xs gap-2">
|
||||||
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
||||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||||
@@ -258,21 +299,16 @@ export function SystemSettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPageCard>
|
||||||
|
|
||||||
</section>
|
<AdminPageCard
|
||||||
|
title={t("wallet.title", { ns: "config" })}
|
||||||
<section className="space-y-4">
|
description={t("wallet.description", { ns: "config" })}
|
||||||
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
|
>
|
||||||
{t("wallet.title", { ns: "config" })}
|
|
||||||
</h2>
|
|
||||||
<WalletConfigDocScreen embedded />
|
<WalletConfigDocScreen embedded />
|
||||||
</section>
|
</AdminPageCard>
|
||||||
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
|
|
||||||
{t("system.frontendConfig", { ns: "config" })}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
|
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">
|
||||||
{t("system.fields.playRulesHtml", { ns: "config" })}
|
{t("system.fields.playRulesHtml", { ns: "config" })}
|
||||||
@@ -318,22 +354,33 @@ export function SystemSettingsScreen() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPageCard>
|
||||||
|
|
||||||
</section>
|
<Card className="admin-list-card">
|
||||||
|
<CardContent className="admin-list-content">
|
||||||
<SaveActions
|
<SaveActions
|
||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onSave={() => void handleSave()}
|
onSave={() =>
|
||||||
onDiscard={() => {
|
requestConfirm({
|
||||||
setDraft(saved);
|
title: t("system.confirmSaveTitle", { ns: "config" }),
|
||||||
setDirty(false);
|
description: t("system.confirmSaveDescription", { ns: "config" }),
|
||||||
}}
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
saveLabel={saveLabel}
|
onConfirm: () => handleSave(),
|
||||||
savingLabel={savingLabel}
|
})
|
||||||
discardLabel={discardLabel}
|
}
|
||||||
/>
|
onDiscard={() => {
|
||||||
|
setDraft(saved);
|
||||||
|
setDirty(false);
|
||||||
|
}}
|
||||||
|
saveLabel={saveLabel}
|
||||||
|
savingLabel={savingLabel}
|
||||||
|
discardLabel={discardLabel}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
|||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd";
|
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
@@ -86,6 +86,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
const [acting, setActing] = useState<string | null>(null);
|
const [acting, setActing] = useState<string | null>(null);
|
||||||
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
||||||
const [reviewRemark, setReviewRemark] = useState("");
|
const [reviewRemark, setReviewRemark] = useState("");
|
||||||
|
const batchCurrency = summary?.currency_code ?? "NPR";
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -277,32 +278,38 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
<span className="text-muted-foreground">{t("endedAt")}</span> {formatDt(summary.finished_at)}
|
<span className="text-muted-foreground">{t("endedAt")}</span> {formatDt(summary.finished_at)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2 sm:col-span-2">
|
<div className="flex flex-wrap gap-2 sm:col-span-2">
|
||||||
<Button
|
{canReviewSettlement ? (
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant="outline"
|
size="sm"
|
||||||
disabled={!canReviewSettlement || acting !== null || summary.status !== "pending_review"}
|
variant="outline"
|
||||||
onClick={() => openActionDialog("approve")}
|
disabled={acting !== null || summary.status !== "pending_review"}
|
||||||
>
|
onClick={() => openActionDialog("approve")}
|
||||||
{t("approve")}
|
>
|
||||||
</Button>
|
{t("approve")}
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
) : null}
|
||||||
size="sm"
|
{canReviewSettlement ? (
|
||||||
variant="outline"
|
<Button
|
||||||
disabled={!canReviewSettlement || acting !== null || summary.status !== "pending_review"}
|
type="button"
|
||||||
onClick={() => openActionDialog("reject")}
|
size="sm"
|
||||||
>
|
variant="outline"
|
||||||
{t("reject")}
|
disabled={acting !== null || summary.status !== "pending_review"}
|
||||||
</Button>
|
onClick={() => openActionDialog("reject")}
|
||||||
<Button
|
>
|
||||||
type="button"
|
{t("reject")}
|
||||||
size="sm"
|
</Button>
|
||||||
disabled={!canManagePayout || acting !== null || summary.status !== "approved"}
|
) : null}
|
||||||
onClick={() => openActionDialog("payout")}
|
{canManagePayout ? (
|
||||||
>
|
<Button
|
||||||
{t("runPayout")}
|
type="button"
|
||||||
</Button>
|
size="sm"
|
||||||
|
disabled={acting !== null || summary.status !== "approved"}
|
||||||
|
onClick={() => openActionDialog("payout")}
|
||||||
|
>
|
||||||
|
{t("runPayout")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button type="button" size="sm" variant="secondary" disabled={acting !== null} onClick={() => void exportCsv()}>
|
<Button type="button" size="sm" variant="secondary" disabled={acting !== null} onClick={() => void exportCsv()}>
|
||||||
{t("exportSettlementReport")}
|
{t("exportSettlementReport")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -341,12 +348,12 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
|
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? summary.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(
|
{formatAdminMinorUnits(
|
||||||
r.jackpot_allocation_amount,
|
r.jackpot_allocation_amount,
|
||||||
r.currency_code ?? summary.currency_code ?? "NPR",
|
r.currency_code ?? batchCurrency,
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
|||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd";
|
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminSettlementBatchListData, AdminSettlementBatchRow } from "@/types/api/admin-settlement";
|
import type { AdminSettlementBatchListData, AdminSettlementBatchRow } from "@/types/api/admin-settlement";
|
||||||
@@ -295,32 +295,38 @@ export function SettlementBatchesConsole() {
|
|||||||
>
|
>
|
||||||
{t("details")}
|
{t("details")}
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
{canReviewSettlement ? (
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant="outline"
|
size="sm"
|
||||||
disabled={!canReviewSettlement || actingId !== null || row.status !== "pending_review"}
|
variant="outline"
|
||||||
onClick={() => openActionDialog(row, "approve")}
|
disabled={actingId !== null || row.status !== "pending_review"}
|
||||||
>
|
onClick={() => openActionDialog(row, "approve")}
|
||||||
{t("pass")}
|
>
|
||||||
</Button>
|
{t("pass")}
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
) : null}
|
||||||
size="sm"
|
{canReviewSettlement ? (
|
||||||
variant="outline"
|
<Button
|
||||||
disabled={!canReviewSettlement || actingId !== null || row.status !== "pending_review"}
|
type="button"
|
||||||
onClick={() => openActionDialog(row, "reject")}
|
size="sm"
|
||||||
>
|
variant="outline"
|
||||||
{t("reject")}
|
disabled={actingId !== null || row.status !== "pending_review"}
|
||||||
</Button>
|
onClick={() => openActionDialog(row, "reject")}
|
||||||
<Button
|
>
|
||||||
type="button"
|
{t("reject")}
|
||||||
size="sm"
|
</Button>
|
||||||
disabled={!canManagePayout || actingId !== null || row.status !== "approved"}
|
) : null}
|
||||||
onClick={() => openActionDialog(row, "payout")}
|
{canManagePayout ? (
|
||||||
>
|
<Button
|
||||||
{t("payout")}
|
type="button"
|
||||||
</Button>
|
size="sm"
|
||||||
|
disabled={actingId !== null || row.status !== "approved"}
|
||||||
|
onClick={() => openActionDialog(row, "payout")}
|
||||||
|
>
|
||||||
|
{t("payout")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { ChevronDown } from "lucide-react";
|
|||||||
const TICKET_STATUS_OPTIONS = [
|
const TICKET_STATUS_OPTIONS = [
|
||||||
"pending_confirm",
|
"pending_confirm",
|
||||||
"partial_pending_confirm",
|
"partial_pending_confirm",
|
||||||
|
"pending_draw",
|
||||||
"success",
|
"success",
|
||||||
"failed",
|
"failed",
|
||||||
"pending_payout",
|
"pending_payout",
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -202,19 +206,31 @@ function walletAdminSelectDisplayedLabel(
|
|||||||
return key ? (t ? t(key) : key) : v;
|
return key ? (t ? t(key) : key) : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canReverseTransferOrder(row: { status: string; can_reverse?: boolean }): boolean {
|
function canReverseTransferOrder(
|
||||||
return row.can_reverse ?? row.status === "pending_reconcile";
|
row: { status: string; can_reverse?: boolean },
|
||||||
|
canWriteWallet: boolean,
|
||||||
|
): boolean {
|
||||||
|
return canWriteWallet && (row.can_reverse ?? row.status === "pending_reconcile");
|
||||||
}
|
}
|
||||||
|
|
||||||
function canManuallyProcessTransferOrder(row: {
|
function canManuallyProcessTransferOrder(
|
||||||
status: string;
|
row: {
|
||||||
can_manually_process?: boolean;
|
status: string;
|
||||||
}): boolean {
|
can_manually_process?: boolean;
|
||||||
return row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status);
|
},
|
||||||
|
canWriteWallet: boolean,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
canWriteWallet &&
|
||||||
|
(row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransferOrdersPanel(): React.ReactElement {
|
export function TransferOrdersPanel(): React.ReactElement {
|
||||||
const { t } = useTranslation(["wallet", "common"]);
|
const { t } = useTranslation(["wallet", "common"]);
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
|
||||||
const exportLabels = useExportLabels("walletTransferOrders");
|
const exportLabels = useExportLabels("walletTransferOrders");
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
@@ -249,10 +265,20 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReverse = (transferNo: string) =>
|
const handleReverse = (transferNo: string) =>
|
||||||
doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess"));
|
requestConfirm({
|
||||||
|
title: t("confirm.reverseTitle"),
|
||||||
|
description: t("confirm.reverseDescription", { transferNo }),
|
||||||
|
confirmVariant: "destructive",
|
||||||
|
onConfirm: () => doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess")),
|
||||||
|
});
|
||||||
|
|
||||||
const handleManuallyProcess = (transferNo: string) =>
|
const handleManuallyProcess = (transferNo: string) =>
|
||||||
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess"));
|
requestConfirm({
|
||||||
|
title: t("confirm.manualProcessTitle"),
|
||||||
|
description: t("confirm.manualProcessDescription", { transferNo }),
|
||||||
|
onConfirm: () =>
|
||||||
|
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess")),
|
||||||
|
});
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -302,6 +328,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("transferOrders")}</CardTitle>
|
<CardTitle>{t("transferOrders")}</CardTitle>
|
||||||
@@ -480,9 +507,10 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
{formatTs(row.finished_at)}
|
{formatTs(row.finished_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{canReverseTransferOrder(row) || canManuallyProcessTransferOrder(row) ? (
|
{canReverseTransferOrder(row, canWriteWallet) ||
|
||||||
|
canManuallyProcessTransferOrder(row, canWriteWallet) ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{canReverseTransferOrder(row) ? (
|
{canReverseTransferOrder(row, canWriteWallet) ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -493,7 +521,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
|
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canManuallyProcessTransferOrder(row) ? (
|
{canManuallyProcessTransferOrder(row, canWriteWallet) ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -532,6 +560,8 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<ConfirmDialog />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
|
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
|
||||||
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
|
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
|
||||||
*/
|
*/
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
import { getAdminMe } from "@/api/admin-auth";
|
import { getAdminMe } from "@/api/admin-auth";
|
||||||
@@ -12,6 +13,37 @@ import { readProfile, writeProfile } from "@/stores/admin-profile";
|
|||||||
import { readToken, writeToken } from "@/stores/admin-token";
|
import { readToken, writeToken } from "@/stores/admin-token";
|
||||||
import type { AdminProfile } from "@/types/api/admin-auth";
|
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||||
|
|
||||||
|
/** 避免 rehydrate 先用 localStorage 里的旧 navigation 渲染侧栏 */
|
||||||
|
function profileForRehydrate(profile: AdminProfile | null): AdminProfile | null {
|
||||||
|
if (!profile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
navigation: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminAuthRejected(err: unknown): boolean {
|
||||||
|
if (!isAxiosError(err)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = err.response?.status;
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = err.response?.data;
|
||||||
|
if (body && typeof body === "object" && "code" in body) {
|
||||||
|
const code = (body as { code?: unknown }).code;
|
||||||
|
return code === 401 || code === 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminSessionState = {
|
export type AdminSessionState = {
|
||||||
bearerToken: string | null;
|
bearerToken: string | null;
|
||||||
adminProfile: AdminProfile | null;
|
adminProfile: AdminProfile | null;
|
||||||
@@ -62,7 +94,7 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
|||||||
const profile = readProfile();
|
const profile = readProfile();
|
||||||
if (token) {
|
if (token) {
|
||||||
setAdminBearerToken(token);
|
setAdminBearerToken(token);
|
||||||
set({ bearerToken: token, adminProfile: profile });
|
set({ bearerToken: token, adminProfile: profileForRehydrate(profile) });
|
||||||
void get().refreshAdminProfile();
|
void get().refreshAdminProfile();
|
||||||
} else {
|
} else {
|
||||||
setAdminBearerToken(null);
|
setAdminBearerToken(null);
|
||||||
@@ -80,8 +112,18 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
|||||||
const result = await getAdminMe();
|
const result = await getAdminMe();
|
||||||
writeProfile(result.admin);
|
writeProfile(result.admin);
|
||||||
set({ adminProfile: result.admin });
|
set({ adminProfile: result.admin });
|
||||||
} catch {
|
} catch (err) {
|
||||||
// 兼容旧缓存失败时不打断页面,留给后续登录态处理。
|
if (isAdminAuthRejected(err)) {
|
||||||
|
get().clearSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = get().adminProfile ?? readProfile();
|
||||||
|
if (cached) {
|
||||||
|
const withoutNav = profileForRehydrate(cached);
|
||||||
|
writeProfile(withoutNav);
|
||||||
|
set({ adminProfile: withoutNav });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ export type AdminPlayTypeRow = {
|
|||||||
category: string;
|
category: string;
|
||||||
dimension: number | null;
|
dimension: number | null;
|
||||||
bet_mode: string | null;
|
bet_mode: string | null;
|
||||||
display_name_zh: string | null;
|
display_name: string | null;
|
||||||
display_name_en: string | null;
|
|
||||||
display_name_ne: string | null;
|
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
supports_multi_number: boolean;
|
supports_multi_number: boolean;
|
||||||
@@ -43,9 +41,7 @@ export type PlayConfigItemRow = {
|
|||||||
category: string | null;
|
category: string | null;
|
||||||
dimension: number | null;
|
dimension: number | null;
|
||||||
bet_mode: string | null;
|
bet_mode: string | null;
|
||||||
display_name_zh: string | null;
|
display_name: string | null;
|
||||||
display_name_en: string | null;
|
|
||||||
display_name_ne: string | null;
|
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
min_bet_amount: number;
|
min_bet_amount: number;
|
||||||
max_bet_amount: number;
|
max_bet_amount: number;
|
||||||
|
|||||||
56
src/types/api/admin-dashboard-analytics.ts
Normal file
56
src/types/api/admin-dashboard-analytics.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { AdminReportDailyProfitRow } from "@/types/api/admin-reports";
|
||||||
|
|
||||||
|
export type DashboardAnalyticsPeriod =
|
||||||
|
| "today"
|
||||||
|
| "last_7_days"
|
||||||
|
| "last_30_days"
|
||||||
|
| "this_month"
|
||||||
|
| "lifetime"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export type DashboardAnalyticsMetric = "overview" | "bet" | "payout" | "profit";
|
||||||
|
|
||||||
|
export type AdminDashboardAnalyticsSummary = {
|
||||||
|
total_bet_minor: number;
|
||||||
|
total_payout_minor: number;
|
||||||
|
approx_house_gross_minor: number;
|
||||||
|
draw_count: number;
|
||||||
|
business_day_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardAnalyticsPlayRow = {
|
||||||
|
play_code: string;
|
||||||
|
dimension: number;
|
||||||
|
total_bet_minor: number;
|
||||||
|
total_payout_minor: number;
|
||||||
|
approx_house_gross_minor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardAnalyticsChartMeta = {
|
||||||
|
chart_date_from: string;
|
||||||
|
chart_date_to: string;
|
||||||
|
truncated: boolean;
|
||||||
|
span_days: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `GET /api/v1/admin/dashboard/analytics` */
|
||||||
|
export type AdminDashboardAnalyticsData = {
|
||||||
|
period: DashboardAnalyticsPeriod;
|
||||||
|
metric: DashboardAnalyticsMetric;
|
||||||
|
play_code: string | null;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
currency_code: string | null;
|
||||||
|
summary: AdminDashboardAnalyticsSummary;
|
||||||
|
daily_series: AdminReportDailyProfitRow[];
|
||||||
|
chart_meta: AdminDashboardAnalyticsChartMeta;
|
||||||
|
play_breakdown: AdminDashboardAnalyticsPlayRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardAnalyticsQuery = {
|
||||||
|
period?: DashboardAnalyticsPeriod;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
metric?: DashboardAnalyticsMetric;
|
||||||
|
play_code?: string;
|
||||||
|
};
|
||||||
@@ -49,10 +49,33 @@ export type AdminDashboardCapabilities = {
|
|||||||
wallet_transfer_view: boolean;
|
wallet_transfer_view: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 按业务日汇总的今日投注/派彩/盈亏(与报表 daily-profit 口径一致) */
|
||||||
|
export type AdminDashboardTodayFinance = {
|
||||||
|
business_date: string;
|
||||||
|
currency_code: string | null;
|
||||||
|
total_bet_minor: number;
|
||||||
|
total_payout_minor: number;
|
||||||
|
approx_house_gross_minor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 全平台历史累计投注/派彩/盈亏 */
|
||||||
|
export type AdminDashboardLifetimeFinance = {
|
||||||
|
currency_code: string | null;
|
||||||
|
total_bet_minor: number;
|
||||||
|
total_payout_minor: number;
|
||||||
|
approx_house_gross_minor: number;
|
||||||
|
draw_count: number;
|
||||||
|
business_day_count: number;
|
||||||
|
date_from: string | null;
|
||||||
|
date_to: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/** `GET /api/v1/admin/dashboard` */
|
/** `GET /api/v1/admin/dashboard` */
|
||||||
export type AdminDashboardData = {
|
export type AdminDashboardData = {
|
||||||
hall: DrawCurrentSnapshot | null;
|
hall: DrawCurrentSnapshot | null;
|
||||||
resolved_draw: AdminDashboardResolvedDraw | null;
|
resolved_draw: AdminDashboardResolvedDraw | null;
|
||||||
|
today_finance: AdminDashboardTodayFinance | null;
|
||||||
|
lifetime_finance: AdminDashboardLifetimeFinance | null;
|
||||||
finance: AdminDrawFinanceSummaryData | null;
|
finance: AdminDrawFinanceSummaryData | null;
|
||||||
draw: AdminDashboardDrawPanel | null;
|
draw: AdminDashboardDrawPanel | null;
|
||||||
risk: AdminDashboardRiskSnapshot | null;
|
risk: AdminDashboardRiskSnapshot | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user