feat(admin): enhance operations guidance and session handling
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
This commit is contained in:
@@ -7,7 +7,23 @@ const allowedDevOrigins = parseAllowedDevOrigins(process.env.ALLOWED_DEV_ORIGINS
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
...(allowedDevOrigins.length > 0 ? { allowedDevOrigins } : {}),
|
||||
reactCompiler: true,
|
||||
reactCompiler: process.env.NODE_ENV !== "development",
|
||||
turbopack: {},
|
||||
experimental: {
|
||||
preloadEntriesOnStart: false,
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
onDemandEntries: {
|
||||
maxInactiveAge: 10_000,
|
||||
pagesBufferLength: 1,
|
||||
},
|
||||
webpack(config, { dev }) {
|
||||
if (dev) {
|
||||
config.cache = false;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NEXT_DISABLE_MEM_OVERRIDE=1 NODE_OPTIONS='--max-old-space-size=3072' next dev --port 3801",
|
||||
"dev": "NEXT_DISABLE_MEM_OVERRIDE=1 NODE_OPTIONS='--max-old-space-size=2048' next dev --webpack --disable-source-maps --port 3801",
|
||||
"build": "next build && cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && echo '✅ standalone 静态资源已复制完毕'",
|
||||
"start": "next start --port 3801",
|
||||
"lint": "eslint"
|
||||
|
||||
@@ -6,13 +6,10 @@ import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("draws", "drawDetail");
|
||||
|
||||
export default async function AdminDrawDetailPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
export default function AdminDrawDetailPage() {
|
||||
return (
|
||||
<AdminPermissionGate requiredAny={PRD_DRAW_ACCESS_ANY}>
|
||||
<DrawDetailConsole drawId={drawId} />
|
||||
<DrawDetailConsole />
|
||||
</AdminPermissionGate>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/app/admin/(shell)/draws/[drawId]/tickets/page.tsx
Normal file
16
src/app/admin/(shell)/draws/[drawId]/tickets/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { PRD_TICKETS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import { DrawTicketsConsole } from "@/modules/draws/draw-tickets-console";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("draws", "subnav.tickets");
|
||||
|
||||
export default function AdminDrawTicketsPage(): React.ReactElement {
|
||||
return (
|
||||
<AdminPermissionGate requiredAny={PRD_TICKETS_ACCESS_ANY}>
|
||||
<DrawTicketsConsole />
|
||||
</AdminPermissionGate>
|
||||
);
|
||||
}
|
||||
@@ -41,8 +41,8 @@ const TOP_ROUTE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const AGENT_ROUTE_LABELS: Record<string, string> = {
|
||||
list: "agents.listTitle",
|
||||
provision: "agents.subnav.provision",
|
||||
list: "listTitle",
|
||||
provision: "subnav.provision",
|
||||
"settlement-bills": "settlementCenter.title",
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AdminFieldLabelProps = {
|
||||
htmlFor: string;
|
||||
@@ -19,6 +20,64 @@ type AdminFieldLabelProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type AdminHelpIconProps = {
|
||||
helpText: string;
|
||||
helpAriaLabel: string;
|
||||
helpId: string;
|
||||
};
|
||||
|
||||
type AdminHelpTextProps = AdminHelpIconProps & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminHelpIcon({
|
||||
helpText,
|
||||
helpAriaLabel,
|
||||
helpId,
|
||||
}: AdminHelpIconProps): React.ReactElement {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
aria-label={helpAriaLabel}
|
||||
data-field-help={helpId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CircleHelp aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-80 whitespace-normal text-left leading-relaxed"
|
||||
>
|
||||
<p>{helpText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminHelpText({
|
||||
children,
|
||||
helpText,
|
||||
helpAriaLabel,
|
||||
helpId,
|
||||
className,
|
||||
}: AdminHelpTextProps): React.ReactElement {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1", className)}>
|
||||
<span>{children}</span>
|
||||
<AdminHelpIcon helpText={helpText} helpAriaLabel={helpAriaLabel} helpId={helpId} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminFieldLabel({
|
||||
htmlFor,
|
||||
children,
|
||||
@@ -39,29 +98,11 @@ export function AdminFieldLabel({
|
||||
<Label htmlFor={htmlFor} className={className}>
|
||||
{children}
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
aria-label={helpAriaLabel}
|
||||
data-field-help={htmlFor}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CircleHelp aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-80 whitespace-normal text-left leading-relaxed"
|
||||
>
|
||||
<p>{helpText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AdminHelpIcon
|
||||
helpText={helpText}
|
||||
helpAriaLabel={helpAriaLabel ?? String(children)}
|
||||
helpId={htmlFor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { cn } from "@/lib/utils";
|
||||
export function AdminLanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation("common");
|
||||
// Match SSR: do not read document/localStorage until after mount.
|
||||
const [locale, setLocale] = useState<AdminApiLocale>("en");
|
||||
const [locale, setLocale] = useState<AdminApiLocale>("zh");
|
||||
|
||||
useEffect(() => {
|
||||
const syncLocale = () => {
|
||||
@@ -44,8 +44,9 @@ export function AdminLanguageSwitcher() {
|
||||
applyAdminUiLocale(next);
|
||||
await i18n.changeLanguage(next);
|
||||
setLocale(next);
|
||||
const nextT = i18n.getFixedT(next, "common");
|
||||
toast.success(
|
||||
t("language.changed", {
|
||||
nextT("language.changed", {
|
||||
language: ADMIN_LOCALE_LABELS[next],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AdminNoResourceState({
|
||||
src={NOTDATA_IMAGE}
|
||||
alt=""
|
||||
width={compact ? 120 : 160}
|
||||
height={compact ? 120 : 160}
|
||||
height={compact ? 80 : 107}
|
||||
style={{ width: "auto", height: "auto" }}
|
||||
className={cn(
|
||||
"h-auto w-auto object-contain self-center object-center -mt-4",
|
||||
|
||||
82
src/components/admin/admin-page-guide-dialog.tsx
Normal file
82
src/components/admin/admin-page-guide-dialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpenText } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export type AdminPageGuideItem = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type AdminPageGuideSection = {
|
||||
title: string;
|
||||
description?: string;
|
||||
items?: AdminPageGuideItem[];
|
||||
};
|
||||
|
||||
type AdminPageGuideDialogProps = {
|
||||
triggerLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
sections: AdminPageGuideSection[];
|
||||
};
|
||||
|
||||
export function AdminPageGuideDialog({
|
||||
triggerLabel,
|
||||
title,
|
||||
description,
|
||||
sections,
|
||||
}: AdminPageGuideDialogProps): React.ReactElement {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
|
||||
<BookOpenText data-icon="inline-start" aria-hidden="true" />
|
||||
{triggerLabel}
|
||||
</DialogTrigger>
|
||||
<DialogContent showCloseButton className="max-h-[85vh] sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="min-h-0 max-h-[calc(85vh-8rem)] pr-4">
|
||||
<div className="flex flex-col gap-5 pb-1">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title} className="flex flex-col gap-2">
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
{section.description ? (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{section.description}
|
||||
</p>
|
||||
) : null}
|
||||
{section.items && section.items.length > 0 ? (
|
||||
<dl className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 p-3">
|
||||
{section.items.map((item) => (
|
||||
<div key={item.label} className="grid gap-0.5 sm:grid-cols-[9rem_1fr] sm:gap-3">
|
||||
<dt className="text-sm font-medium text-foreground">{item.label}</dt>
|
||||
<dd className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.description}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -31,10 +31,10 @@ function AdminSidebarSkeleton(): ReactElement {
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
width={160}
|
||||
height={40}
|
||||
width={154}
|
||||
height={56}
|
||||
priority
|
||||
className="h-auto max-h-10 w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
className="h-auto max-h-10 w-auto max-w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
@@ -50,6 +50,7 @@ function AdminSidebarSkeleton(): ReactElement {
|
||||
alt=""
|
||||
fill
|
||||
sizes="var(--sidebar-width)"
|
||||
loading="eager"
|
||||
className="h-full w-full object-cover object-bottom"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||
@@ -91,10 +92,10 @@ export function AdminAppSidebar() {
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
width={160}
|
||||
height={40}
|
||||
width={154}
|
||||
height={56}
|
||||
priority
|
||||
className="h-auto max-h-10 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
className="h-auto max-h-10 w-auto max-w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
@@ -120,6 +121,7 @@ export function AdminAppSidebar() {
|
||||
alt=""
|
||||
fill
|
||||
sizes="var(--sidebar-width)"
|
||||
loading="eager"
|
||||
className="h-full w-full object-cover object-bottom"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||
|
||||
@@ -13,12 +13,17 @@ type ShellAuthGateProps = {
|
||||
|
||||
type GateStatus = "pending" | "authed" | "guest";
|
||||
|
||||
const ADMIN_SESSION_CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Shell 路由守卫:无有效 HttpOnly Cookie 会话或 `/auth/me` 校验失败时跳转登录页。
|
||||
*/
|
||||
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||
const router = useRouter();
|
||||
const bearerToken = useAdminSessionStore((s) => s.bearerToken);
|
||||
const refreshAdminProfile = useAdminSessionStore(
|
||||
(s) => s.refreshAdminProfile,
|
||||
);
|
||||
const [status, setStatus] = useState<GateStatus>("pending");
|
||||
|
||||
const setShellAuthPending = useAdminSessionStore((s) => s.setShellAuthPending);
|
||||
@@ -63,6 +68,28 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "authed") {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(
|
||||
() => void refreshAdminProfile(),
|
||||
ADMIN_SESSION_CHECK_INTERVAL_MS,
|
||||
);
|
||||
const refreshWhenVisible = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void refreshAdminProfile();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", refreshWhenVisible);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", refreshWhenVisible);
|
||||
};
|
||||
}, [refreshAdminProfile, status]);
|
||||
|
||||
if (status === "pending") {
|
||||
return <AdminAuthCheckingScreen variant="shell" />;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,29 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const sessionReason = currentUrl.searchParams.get("session");
|
||||
if (sessionReason === "replaced") {
|
||||
toast.error(
|
||||
tRef.current("auth.sessionReplaced", {
|
||||
ns: "common",
|
||||
defaultValue:
|
||||
"该账号已在其他地方登录,当前会话已退出,请重新登录",
|
||||
}),
|
||||
);
|
||||
} else if (sessionReason === "expired") {
|
||||
toast.error(
|
||||
tRef.current("auth.sessionExpired", {
|
||||
ns: "common",
|
||||
defaultValue: "登录已失效,请重新登录",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (sessionReason) {
|
||||
currentUrl.searchParams.delete("session");
|
||||
window.history.replaceState(null, "", currentUrl);
|
||||
}
|
||||
|
||||
setCheckingSession(false);
|
||||
void loadCaptcha();
|
||||
}
|
||||
@@ -85,7 +108,7 @@ export function LoginForm() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadCaptcha, router]);
|
||||
}, [loadCaptcha, router, tRef]);
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import i18n, { ensureAdminI18nReady, normalizeAdminLanguage } from "@/i18n";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
@@ -13,9 +14,15 @@ type ProvidersProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function AdminSessionHydrator() {
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const startupListeners = new Set<() => void>();
|
||||
let startupReady = false;
|
||||
let startupPromise: Promise<void> | null = null;
|
||||
|
||||
function ensureAdminStartup(): void {
|
||||
if (startupPromise) return;
|
||||
|
||||
startupPromise = (async () => {
|
||||
try {
|
||||
await ensureAdminI18nReady();
|
||||
|
||||
const locale = hydrateAdminUiLocale();
|
||||
@@ -23,19 +30,56 @@ function AdminSessionHydrator() {
|
||||
if (locale && locale !== current) {
|
||||
await i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
} finally {
|
||||
useAdminSessionStore.getState().rehydrate();
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
})()
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
startupReady = true;
|
||||
startupListeners.forEach((listener) => listener());
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
function subscribeAdminStartup(onStoreChange: () => void): () => void {
|
||||
startupListeners.add(onStoreChange);
|
||||
ensureAdminStartup();
|
||||
return () => startupListeners.delete(onStoreChange);
|
||||
}
|
||||
|
||||
function getAdminStartupSnapshot(): boolean {
|
||||
return startupReady;
|
||||
}
|
||||
|
||||
function getAdminStartupServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function AdminStartupGate({ children }: { children: ReactNode }) {
|
||||
const ready = useSyncExternalStore(
|
||||
subscribeAdminStartup,
|
||||
getAdminStartupSnapshot,
|
||||
getAdminStartupServerSnapshot,
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-dvh flex-1 items-center justify-center bg-background"
|
||||
aria-hidden
|
||||
>
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<AdminSessionHydrator />
|
||||
{children}
|
||||
<AdminStartupGate>{children}</AdminStartupGate>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -14,13 +14,25 @@ export function useAsyncEffect(
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: void | (() => void);
|
||||
let cancelled = false;
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve(factoryRef.current()).then((result) => {
|
||||
if (cancelled) {
|
||||
result?.();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = result;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -69,7 +69,6 @@ if (!i18n.isInitialized) {
|
||||
},
|
||||
});
|
||||
|
||||
syncAdminLanguage(initialLanguage);
|
||||
i18n.on("languageChanged", (lang) => {
|
||||
syncAdminLanguage(normalizeAdminLanguage(lang));
|
||||
});
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"draws": "Draws",
|
||||
"rules_plays": "Play rules",
|
||||
"rules_odds": "Odds & rebate",
|
||||
"bet_providers": "Bet providers",
|
||||
"rules": "Betting rules",
|
||||
"risk_cap": "Risk cap rules",
|
||||
"risk": "Risk center",
|
||||
@@ -212,7 +213,8 @@
|
||||
"auth": {
|
||||
"checking": "Checking sign-in status…",
|
||||
"checkingShort": "Loading workspace…",
|
||||
"sessionExpired": "Your session has expired. Please sign in again."
|
||||
"sessionExpired": "Your session has expired. Please sign in again.",
|
||||
"sessionReplaced": "This account was signed in elsewhere. Please sign in again."
|
||||
},
|
||||
"confirm": {
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -282,6 +282,28 @@
|
||||
},
|
||||
"tabs": {
|
||||
"all": "All"
|
||||
},
|
||||
"help": {
|
||||
"openGuide": "Page guide",
|
||||
"guideTitle": "Odds and base rebate guide",
|
||||
"guideDescription": "This page controls standard payout multipliers and the platform base rebate. It does not control jackpot bonus payouts or whether a play is available for sale.",
|
||||
"aria": "View help for {{field}}",
|
||||
"fields": {
|
||||
"provider": "Selects the provider scope. GLOBAL is the default; a provider-specific value overrides it, while providers without an override inherit GLOBAL.",
|
||||
"category": "Filters the list to 4D, 3D, 2D, and other groups. Changing this filter does not change any configuration.",
|
||||
"playType": "Selects the exact play whose prize-tier odds and base rebate you are viewing or editing.",
|
||||
"multiplier": "The standard winning payout multiplier. It is roughly the valid winning amount multiplied by this value, subject to the system's amount rounding rules.",
|
||||
"scopeMultiplier": "Controls the standard payout multiplier when the {{scope}} tier wins. It is not the jackpot burst payout rate.",
|
||||
"rebateRate": "The platform base rebate rate. Player or agent additions are configured elsewhere, and actual deduction and settlement also follow the account mode."
|
||||
},
|
||||
"guide": {
|
||||
"scopeTitle": "Choose the correct scope",
|
||||
"scopeDescription": "Choose the provider, category, and play first. Category is only a filter; provider and play identify the odds set being viewed or edited.",
|
||||
"settingsTitle": "What odds and rebate control",
|
||||
"settingsDescription": "Prize-tier multipliers control standard winning payouts, while base rebate controls the platform's base betting discount. Neither is the jackpot bonus payout.",
|
||||
"workflowTitle": "When changes take effect",
|
||||
"workflowDescription": "Odds use a versioned workflow: create a draft, edit and save it, then publish. Saving alone does not affect players. New tickets use the published values, while existing tickets keep their saved odds and rebate snapshots."
|
||||
}
|
||||
}
|
||||
},
|
||||
"play": {
|
||||
@@ -389,6 +411,29 @@
|
||||
"toggleDisable": "Disable",
|
||||
"toggleEnable": "Enable",
|
||||
"toggleInstantFailed": "Failed to apply play switch. Try again later.",
|
||||
"help": {
|
||||
"openGuide": "Page guide",
|
||||
"guideTitle": "Play rules guide",
|
||||
"guideDescription": "This page controls whether a play can be sold, the player-facing name and order, and the amount allowed on each betting line. It does not control odds or jackpot eligibility.",
|
||||
"aria": "View help for {{field}}",
|
||||
"fields": {
|
||||
"status": "After publishing, this controls whether players can buy the play. Disabling it hides or blocks the play and rejects new bets; historical tickets are unchanged.",
|
||||
"displayName": "The player-facing play name. Changes stay in the current draft until it is saved and published.",
|
||||
"order": "Controls the order in player-facing lists. Lower numbers usually appear first, although fixed betting grids may keep their own layout.",
|
||||
"minBet": "The minimum amount allowed for one submitted betting line. New bets below it are rejected.",
|
||||
"maxBet": "The maximum amount allowed for one submitted betting line. Expanded combination bets are recalculated from their actual resulting lines.",
|
||||
"batchSwitches": "Enables or disables a group of plays at once. It only changes the current draft and still requires save and publish.",
|
||||
"batchGroup": "Sets all {{count}} plays in “{{group}}” to the same state in the current draft only."
|
||||
},
|
||||
"guide": {
|
||||
"settingsTitle": "What each setting controls",
|
||||
"settingsDescription": "Status controls availability; display name and order control what players see; minimum and maximum bet define the amount accepted per betting line.",
|
||||
"batchTitle": "Using batch switches",
|
||||
"batchDescription": "Batch switches update many draft rows at once. They do not bypass the save and publish workflow.",
|
||||
"workflowTitle": "When changes take effect",
|
||||
"workflowDescription": "The active version is read-only. Create a draft, edit and save it, then publish. Saving alone does not affect players; publishing affects new bets, while historical tickets keep their saved rule snapshot."
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"displayNameRequired": "Display name is required",
|
||||
"minMaxInvalid": "{{playCode}}: min bet cannot exceed max bet"
|
||||
@@ -582,7 +627,7 @@
|
||||
"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.",
|
||||
"autoSettlement": "When disabled, tick will not run settlement automatically and admins must trigger it manually.",
|
||||
"cooldownMinutes": "How long to wait after publishing before entering settling. Use 0 to settle immediately.",
|
||||
"cooldownMinutes": "How long to wait after publishing before settlement. Use 0 to settle immediately. Changes apply only to results published afterward; draws already in cooldown are not recalculated.",
|
||||
"manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin."
|
||||
},
|
||||
"loadFailed": "Failed to load system settings",
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
"sequenceNo": "Sequence no.",
|
||||
"plannedDraw": "Planned draw",
|
||||
"coolingEndTime": "Cooling ends at",
|
||||
"automaticSettlementAt": "Automatic settlement: {{time}}",
|
||||
"cooldownRemaining": "Remaining {{time}}",
|
||||
"resultSource": "Result source",
|
||||
"resultSourceOptions": {
|
||||
"rng": "RNG auto-generated",
|
||||
@@ -92,6 +94,7 @@
|
||||
"published": "Published"
|
||||
},
|
||||
"currentResultVersion": "Current result version",
|
||||
"resultVersion": "Result version",
|
||||
"settleVersion": "Settlement version",
|
||||
"isReopened": "Reopened",
|
||||
"yes": "Yes",
|
||||
@@ -109,8 +112,15 @@
|
||||
"rngDraw": "RNG draw",
|
||||
"rngAutoGenerate": "Auto draw",
|
||||
"reopen": "Reopen",
|
||||
"cooldownReopen": "Reopen in cooldown",
|
||||
"cooldownReopen": "Withdraw result and reopen",
|
||||
"reopenUsageHint": "Use this only when published numbers are wrong. It requires generating and publishing a new result and does not speed up settlement.",
|
||||
"runSettlement": "Run settlement",
|
||||
"settleEarly": "Settle early",
|
||||
"retrySettlement": "Retry settlement",
|
||||
"settleEarlySuccess": "Remaining confirmation window skipped; settlement started",
|
||||
"retrySettlementSuccess": "Settlement retry started",
|
||||
"settlementManagePermissionRequired": "Only payout managers may start or retry settlement.",
|
||||
"settlementUnavailable": "No manual settlement action is needed while the draw is {{status}}.",
|
||||
"processing": "Processing…",
|
||||
"actionSuccess": "{{name}} succeeded",
|
||||
"actionFailed": "{{name}} failed",
|
||||
@@ -121,7 +131,8 @@
|
||||
"currentPayout": "Current payout total",
|
||||
"grossProfit": "Approx. gross profit",
|
||||
"settlementBatchList": "Settlement records",
|
||||
"relatedSettlementBatches": "Settlement batches",
|
||||
"relatedSettlementBatches": "Settlement processing records for this draw",
|
||||
"settlementBatchesHint": "A record is normally created for each provider that has bets in this draw. Select a batch number to view its ticket details.",
|
||||
"noSettlementBatches": "No settlement batch records.",
|
||||
"ticketCount": "Tickets",
|
||||
"winCount": "Wins",
|
||||
@@ -153,7 +164,7 @@
|
||||
"clear": "Clear",
|
||||
"saveDraft": "Save draft",
|
||||
"saving": "Saving…",
|
||||
"pendingBatches": "Pending publish",
|
||||
"pendingBatches": "Results awaiting review (not visible to players)",
|
||||
"noPendingBatches": "None",
|
||||
"batchId": "Batch",
|
||||
"numberCount": "Numbers",
|
||||
@@ -173,6 +184,8 @@
|
||||
"checkBeforePublish": "Please review the numbers below before publishing",
|
||||
"checkBeforePublishDesc": "Confirm the numbers are correct and click Publish.",
|
||||
"publishedView": "View published result",
|
||||
"officialResultsTitle": "Official results (visible to players)",
|
||||
"officialResultsHint": "Only published, player-visible numbers appear here. Review drafts are kept separate.",
|
||||
"confirmPublish": "Confirm publish",
|
||||
"submitting": "Submitting…",
|
||||
"publishSuccess": "Published · {{drawNo}} · status {{status}}",
|
||||
@@ -181,13 +194,27 @@
|
||||
"subnav": {
|
||||
"status": "Overview",
|
||||
"results": "Results",
|
||||
"finance": "Finance",
|
||||
"tickets": "Bets",
|
||||
"finance": "Finance & settlement",
|
||||
"review": "Publish",
|
||||
"riskOccupancy": "Risk occupancy",
|
||||
"riskLockLogs": "Lock logs",
|
||||
"riskHot": "Hot numbers",
|
||||
"riskSoldOut": "Sold-out numbers",
|
||||
"riskPools": "Risk pools"
|
||||
"riskPools": "Number limits"
|
||||
},
|
||||
"lifecycle": {
|
||||
"currentStage": "Current stage: {{status}}",
|
||||
"pending": "Betting has not opened for this draw.",
|
||||
"open": "Players may place bets until the close time.",
|
||||
"closing": "Betting is closed and the draw is waiting for its scheduled result time.",
|
||||
"closed": "The draw is closed and waiting for result generation or entry.",
|
||||
"drawing": "The system is generating the result.",
|
||||
"review": "The result is under review and is not yet visible to players.",
|
||||
"cooldown": "The result is published and in its verification window. It will settle automatically or a payout manager may settle early.",
|
||||
"settling": "Settlement records are being processed. This page refreshes every 5 seconds.",
|
||||
"settled": "Settlement and payout processing is complete.",
|
||||
"cancelled": "This draw was cancelled and will not proceed to result or settlement."
|
||||
},
|
||||
"statusOptions": {
|
||||
"all": "All",
|
||||
@@ -216,10 +243,14 @@
|
||||
"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.",
|
||||
"reopenTitle": "Withdraw the result and reopen?",
|
||||
"reopenDescription": "Use only when published numbers are wrong. The draw returns to closed and a new result must be generated, reviewed, and published. This does not speed up settlement.",
|
||||
"runSettlementTitle": "Confirm run settlement?",
|
||||
"runSettlementDescription": "A settlement batch will be created from the published result.",
|
||||
"settleEarlyTitle": "Settle this draw early?",
|
||||
"settleEarlyDescription": "This skips the remaining result verification window, creates settlement records immediately, and continues automatic review and payout. Confirm the official result first.",
|
||||
"retrySettlementTitle": "Retry settlement?",
|
||||
"retrySettlementDescription": "This safely retries settlement without duplicating an existing valid batch.",
|
||||
"saveManualDraftTitle": "Confirm save manual draft?",
|
||||
"saveManualDraftDescription": "23 numbers will be saved for review.",
|
||||
"publishTitle": "Confirm publish results?",
|
||||
|
||||
@@ -92,5 +92,35 @@
|
||||
"play_combo": "Triggered by play combo",
|
||||
"threshold": "Threshold reached"
|
||||
},
|
||||
"winnerCount": "Winner count"
|
||||
"winnerCount": "Winner count",
|
||||
"help": {
|
||||
"openGuide": "Page guide",
|
||||
"guideTitle": "Jackpot page guide",
|
||||
"guideDescription": "The jackpot is an extra reward pool separate from ordinary odds payouts. This page controls how each currency pool grows, when it is released, and where its records are reviewed.",
|
||||
"aria": "View help for {{field}}",
|
||||
"fields": {
|
||||
"currentAmount": "The available balance currently accumulated for this currency. Save cannot edit it directly; qualifying contributions, jackpot payouts, or audited balance adjustments change it.",
|
||||
"status": "Controls whether this currency pool participates in future activity. When disabled, new tickets do not contribute and automatic or manual bursts cannot run; the existing balance remains.",
|
||||
"contributionRate": "After a successful ticket meets the jackpot minimum, its nominal bet amount is multiplied by this percentage and recorded into the pool. It is internal allocation, not an extra player charge.",
|
||||
"minBetAmount": "Only decides whether a successful ticket contributes to the jackpot. It does not decide whether the play may be purchased; play betting limits are configured on Betting rules.",
|
||||
"triggerThreshold": "Once the pool reaches this amount, a draw with at least one first-prize winning ticket may burst by threshold. Reaching the amount alone does not pay out without a first-prize winner.",
|
||||
"payoutRate": "The percentage of the pre-burst balance released for threshold, combo-play, or manual bursts. A forced-gap burst releases the full pool instead.",
|
||||
"forceTriggerGap": "After this many settled draws since the last burst, the next draw with a first-prize winner may force a burst. Set to 0 to disable this condition.",
|
||||
"comboTriggerPlays": "A first-prize win on any selected play may trigger a burst before the balance reaches the threshold. This controls the trigger, not which first-prize winners share the payout.",
|
||||
"balanceAdjustment": "Manually increases or decreases the current pool balance. A reason is required, every operation is logged, and a decrease cannot make the balance negative.",
|
||||
"adjustmentDirection": "Choose whether this manual operation adds to or subtracts from the pool balance.",
|
||||
"adjustmentAmount": "The amount to add or subtract in major currency units, not the desired balance after adjustment.",
|
||||
"adjustmentReason": "Explains why the balance is being changed. At least 3 characters are required for audit and reconciliation.",
|
||||
"manualBurst": "Super admin only. Select a draw with published results, a settlement batch, and first-prize winners to release the pool using the current payout rate.",
|
||||
"records": "Review read-only jackpot payout and per-ticket contribution records, filterable by draw number and exportable."
|
||||
},
|
||||
"guide": {
|
||||
"settingsTitle": "How the pool grows and triggers",
|
||||
"settingsDescription": "Successful tickets first add to the pool when eligible. During settlement, at least one first-prize winner must exist before threshold, forced-gap, or combo-play conditions are checked.",
|
||||
"actionsTitle": "Manual operations and records",
|
||||
"actionsDescription": "Balance adjustments and manual bursts change real pool funds immediately. Verify currency, amount, draw, and reason first.",
|
||||
"effectiveTitle": "When changes take effect",
|
||||
"effectiveDescription": "Jackpot settings have no draft-and-publish workflow. Save applies them immediately to later contributions and unfinished jackpot settlement. A draw already sold but not yet settled may use the new trigger settings and payout rate."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"poolsTitle": "Risk pools",
|
||||
"hotPageTitle": "Hot numbers monitor",
|
||||
"soldOutPageTitle": "Sold-out numbers",
|
||||
"allPoolsPageTitle": "All risk pools",
|
||||
"allPoolsPageTitle": "Number payout limits (risk pool)",
|
||||
"poolExplanationTitle": "This page shows how much potential payout each number can still accept",
|
||||
"poolExplanation": "Each provider and four-digit number has a maximum potential payout limit. Bets reserve capacity using their worst-case payout. The number is sold out when capacity is insufficient and capacity is released after settlement or refund. These amounts are risk reserves, not actual payouts.",
|
||||
"sourceReasonOptions": {
|
||||
"ticket_place": "Bet placement",
|
||||
"ticket_rollback": "Ticket rollback",
|
||||
@@ -36,10 +38,13 @@
|
||||
"sortRemainingAsc": "Remaining ↑",
|
||||
"sortNumberAsc": "Number ↑",
|
||||
"loadPoolsFailed": "Failed to load risk pools",
|
||||
"capAmount": "Cap",
|
||||
"lockedAmount": "Locked",
|
||||
"remainingAmount": "Remaining",
|
||||
"usageRatio": "Usage",
|
||||
"capAmount": "Maximum payout limit",
|
||||
"lockedAmount": "Reserved by current bets",
|
||||
"remainingAmount": "Remaining payout capacity",
|
||||
"usageRatio": "Usage ratio",
|
||||
"emptyPools": "No number-limit records match this filter. Numbers with no reserved bet exposure are normally omitted.",
|
||||
"emptyHighRiskPools": "No number currently has a usage ratio of 80% or more.",
|
||||
"emptySoldOutPools": "No number is currently sold out because of insufficient capacity or manual closure.",
|
||||
"poolStatus": "Status",
|
||||
"soldOut": "Sold out",
|
||||
"warning": "Warning",
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"auth": {
|
||||
"checking": "लगइन स्थिति जाँच हुँदैछ…",
|
||||
"checkingShort": "कार्यस्थान खोल्दै…",
|
||||
"sessionExpired": "लगइन समाप्त भयो। कृपया पुनः लगइन गर्नुहोस्।"
|
||||
"sessionExpired": "लगइन समाप्त भयो। कृपया पुनः लगइन गर्नुहोस्।",
|
||||
"sessionReplaced": "यो खाता अर्को स्थानमा लगइन भएको छ। कृपया पुनः लगइन गर्नुहोस्।"
|
||||
},
|
||||
"cancel": "रद्द गर्नुहोस्",
|
||||
"confirm": {
|
||||
@@ -198,6 +199,7 @@
|
||||
"rules": "खेल नियम",
|
||||
"rules_odds": "बाधा र रिबेट",
|
||||
"rules_plays": "खेल नियम",
|
||||
"bet_providers": "बेट प्रदायक",
|
||||
"settings": "सेटिङ",
|
||||
"settlement": "सेटलमेन्ट",
|
||||
"settlement_center": "क्रेडिट सेटलमेन्ट",
|
||||
|
||||
@@ -282,6 +282,28 @@
|
||||
},
|
||||
"tabs": {
|
||||
"all": "सबै"
|
||||
},
|
||||
"help": {
|
||||
"openGuide": "पृष्ठ निर्देशिका",
|
||||
"guideTitle": "अड्स र आधारभूत रिबेट निर्देशिका",
|
||||
"guideDescription": "यस पृष्ठले सामान्य पुरस्कार गुणक र प्लेटफर्मको आधारभूत रिबेट नियन्त्रण गर्छ। यसले ज्याकपोटको अतिरिक्त भुक्तानी वा खेल उपलब्धता नियन्त्रण गर्दैन।",
|
||||
"aria": "{{field}} को सहायता हेर्नुहोस्",
|
||||
"fields": {
|
||||
"provider": "अड्स लागू हुने प्रदायक दायरा छान्छ। GLOBAL पूर्वनिर्धारित हो; प्रदायक-विशेष मानले यसलाई ओभरराइड गर्छ, नभए GLOBAL नै प्रयोग हुन्छ।",
|
||||
"category": "4D, 3D, 2D आदि समूह फिल्टर गर्छ। फिल्टर बदल्दा कन्फिगरेसन बदलिँदैन।",
|
||||
"playType": "हेर्न वा सम्पादन गर्न चाहेको ठ्याक्कै खेल छान्छ। प्रत्येक खेलको पुरस्कार-स्तर अड्स र आधारभूत रिबेट फरक हुन सक्छ।",
|
||||
"multiplier": "सामान्य विजेता भुक्तानी गुणक। रकम प्रणालीको राउन्डिङ नियमअनुसार गणना हुन्छ।",
|
||||
"scopeMultiplier": "{{scope}} पुरस्कार जित्दा प्रयोग हुने सामान्य भुक्तानी गुणक नियन्त्रण गर्छ। यो ज्याकपोट burst भुक्तानी दर होइन।",
|
||||
"rebateRate": "प्लेटफर्मको आधारभूत रिबेट दर। खेलाडी वा एजेन्टको थप रिबेट अन्यत्र सेट हुन्छ; वास्तविक कटौती र सेटलमेन्ट खाता मोडमा पनि निर्भर हुन्छ।"
|
||||
},
|
||||
"guide": {
|
||||
"scopeTitle": "सही दायरा छान्नुहोस्",
|
||||
"scopeDescription": "पहिले प्रदायक, श्रेणी र खेल छान्नुहोस्। श्रेणी केवल फिल्टर हो; प्रदायक र खेलले कुन अड्स सेट हेरिँदै वा सम्पादन हुँदैछ भन्ने तय गर्छ।",
|
||||
"settingsTitle": "अड्स र रिबेटले के नियन्त्रण गर्छ",
|
||||
"settingsDescription": "पुरस्कार-स्तर गुणकले सामान्य विजेता भुक्तानी र आधारभूत रिबेटले प्लेटफर्मको आधार छुट नियन्त्रण गर्छ। यी ज्याकपोट अतिरिक्त भुक्तानी होइनन्।",
|
||||
"workflowTitle": "परिवर्तन कहिले लागू हुन्छ",
|
||||
"workflowDescription": "ड्राफ्ट बनाउनुहोस्, सम्पादन गरी सेभ गर्नुहोस्, अनि प्रकाशित गर्नुहोस्। सेभ मात्र गर्दा खेलाडीमा असर पर्दैन। नयाँ टिकटले प्रकाशित मान प्रयोग गर्छ; पुराना टिकटले सुरक्षित अड्स र रिबेट snapshot राख्छन्।"
|
||||
}
|
||||
}
|
||||
},
|
||||
"play": {
|
||||
@@ -389,6 +411,29 @@
|
||||
"toggleDisable": "निष्क्रिय",
|
||||
"toggleEnable": "सक्रिय",
|
||||
"toggleInstantFailed": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।",
|
||||
"help": {
|
||||
"openGuide": "पृष्ठ निर्देशिका",
|
||||
"guideTitle": "खेल नियम निर्देशिका",
|
||||
"guideDescription": "यस पृष्ठले खेल बेच्न मिल्ने वा नमिल्ने, खेलाडीले देख्ने नाम र क्रम, तथा प्रत्येक बेट लाइनको रकम सीमा नियन्त्रण गर्छ। यसले अड्स वा ज्याकपोट योग्यता नियन्त्रण गर्दैन।",
|
||||
"aria": "{{field}} को सहायता हेर्नुहोस्",
|
||||
"fields": {
|
||||
"status": "प्रकाशनपछि खेल किन्न मिल्ने वा नमिल्ने नियन्त्रण गर्छ। बन्द गर्दा नयाँ बेट अस्वीकार हुन्छ; पुराना टिकट बदलिँदैनन्।",
|
||||
"displayName": "खेलाडीले देख्ने खेल नाम। परिवर्तन सेभ र प्रकाशित नभएसम्म हालको ड्राफ्टमै रहन्छ।",
|
||||
"order": "खेलाडी सूचीमा देखिने क्रम नियन्त्रण गर्छ। सानो संख्या सामान्यतया पहिले देखिन्छ; स्थिर बेटिङ ग्रिडले आफ्नै लेआउट राख्न सक्छ।",
|
||||
"minBet": "एउटा बेट लाइनमा स्वीकार हुने न्यूनतम रकम। यसभन्दा कम नयाँ बेट अस्वीकार हुन्छ।",
|
||||
"maxBet": "एउटा बेट लाइनमा स्वीकार हुने अधिकतम रकम। संयोजन बेट विस्तार भएपछि वास्तविक लाइनअनुसार फेरि गणना हुन्छ।",
|
||||
"batchSwitches": "एउटै समूहका धेरै खेल एकैपटक खोल्छ वा बन्द गर्छ। यसले हालको ड्राफ्ट मात्र बदल्छ र अझै सेभ तथा प्रकाशन चाहिन्छ।",
|
||||
"batchGroup": "“{{group}}” का सबै {{count}} खेललाई हालको ड्राफ्टमा मात्र एउटै अवस्थामा राख्छ।"
|
||||
},
|
||||
"guide": {
|
||||
"settingsTitle": "प्रत्येक सेटिङले के नियन्त्रण गर्छ",
|
||||
"settingsDescription": "स्थितिले उपलब्धता, नाम र क्रमले खेलाडीले देख्ने सामग्री, तथा न्यूनतम र अधिकतम बेटले प्रत्येक लाइनको स्वीकार्य रकम नियन्त्रण गर्छ।",
|
||||
"batchTitle": "समूह स्विचको प्रयोग",
|
||||
"batchDescription": "समूह स्विचले धेरै ड्राफ्ट पङ्क्ति एकैपटक बदल्छ। यसले सेभ र प्रकाशन प्रक्रिया छोड्दैन।",
|
||||
"workflowTitle": "परिवर्तन कहिले लागू हुन्छ",
|
||||
"workflowDescription": "सक्रिय संस्करण पढ्न मात्र हो। ड्राफ्ट बनाएर सम्पादन र सेभ गर्नुहोस्, अनि प्रकाशित गर्नुहोस्। सेभ मात्र गर्दा असर पर्दैन; प्रकाशनले नयाँ बेटमा असर गर्छ, पुराना टिकटले सुरक्षित नियम snapshot राख्छन्।"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"displayNameRequired": "प्रदर्शित नाम अनिवार्य छ",
|
||||
"minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन"
|
||||
@@ -581,7 +626,7 @@
|
||||
"autoApprove": "कूलडाउन सकिएर सेटलमेन्ट पूरा भएपछि ब्याच स्वतः अनुमोदित हुने हो कि होइन।",
|
||||
"autoPayout": "ब्याच अनुमोदित भएपछि tick ले जित रकम खेलाडीको वालेटमा स्वतः जम्मा गर्ने हो कि होइन।",
|
||||
"autoSettlement": "बन्द हुँदा tick ले सेटलमेन्ट स्वतः चलाउँदैन र एडमिनले म्यानुअल रूपमा ट्रिगर गर्नुपर्छ।",
|
||||
"cooldownMinutes": "प्रकाशनपछि settling मा जानुअघि कति समय पर्खने। 0 राखे तुरुन्त सेटलमेन्ट सुरु हुन्छ।",
|
||||
"cooldownMinutes": "प्रकाशनपछि सेटलमेन्ट सुरु हुनुअघि कति समय पर्खने। 0 राखे तुरुन्त सुरु हुन्छ। परिवर्तनपछि प्रकाशित हुने ड्रमा मात्र लागू हुन्छ; पहिले नै कूलडाउनमा रहेका ड्र पुनः गणना हुँदैनन्।",
|
||||
"manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।"
|
||||
},
|
||||
"loadFailed": "प्रणाली सेटिङ लोड असफल भयो",
|
||||
|
||||
@@ -52,19 +52,26 @@
|
||||
"manualCloseTitle": "म्यानुअल बन्द पुष्टि?",
|
||||
"publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।",
|
||||
"publishTitle": "नतिजा प्रकाशन पुष्टि?",
|
||||
"reopenDescription": "नतिजा पुनः समीक्षा हुन सक्छ।",
|
||||
"reopenTitle": "कुलडाउन पुनः खोल्ने पुष्टि?",
|
||||
"reopenDescription": "प्रकाशित नम्बर गलत हुँदा मात्र प्रयोग गर्नुहोस्। ड्र फेरि बन्द अवस्थामा फर्किन्छ र नयाँ नतिजा सिर्जना, समीक्षा र प्रकाशित गर्नुपर्छ। यसले सेटलमेन्ट छिटो गर्दैन।",
|
||||
"reopenTitle": "नतिजा फिर्ता लिएर पुनः खोल्ने?",
|
||||
"rngDrawDescription": "प्रणालीले नतिजा सिर्जना गर्नेछ।",
|
||||
"rngDrawTitle": "RNG ड्रअ पुष्टि?",
|
||||
"runSettlementDescription": "प्रकाशित नतिजाबाट सेटलमेन्ट ब्याच बन्नेछ।",
|
||||
"runSettlementTitle": "सेटलमेन्ट सुरु पुष्टि?",
|
||||
"settleEarlyDescription": "बाँकी नतिजा जाँच समय छोडेर तुरुन्त सेटलमेन्ट र स्वचालित भुक्तानी प्रक्रिया सुरु हुनेछ। पहिले आधिकारिक नतिजा सही भएको पुष्टि गर्नुहोस्।",
|
||||
"settleEarlyTitle": "अगावै सेटल गर्ने?",
|
||||
"retrySettlementDescription": "अवस्थित वैध ब्याच नदोहोर्याई सेटलमेन्ट सुरक्षित रूपमा पुनः प्रयास हुनेछ।",
|
||||
"retrySettlementTitle": "सेटलमेन्ट पुनः प्रयास गर्ने?",
|
||||
"saveManualDraftDescription": "२३ नम्बर समीक्षाका लागि सुरक्षित हुनेछ।",
|
||||
"saveManualDraftTitle": "म्यानुअल ड्राफ्ट सुरक्षित पुष्टि?"
|
||||
},
|
||||
"confirmPublish": "प्रकाशन पुष्टि गर्नुहोस्",
|
||||
"confirmedAt": "पुष्टि समय {{time}}",
|
||||
"cooldownReopen": "कुलिङमा पुनःखोल्नुहोस्",
|
||||
"cooldownReopen": "नतिजा फिर्ता लिएर पुनः खोल्नुहोस्",
|
||||
"reopenUsageHint": "प्रकाशित नम्बर गलत हुँदा मात्र प्रयोग गर्नुहोस्। यसले नयाँ नतिजा पुनः प्रकाशित गर्नुपर्छ र सेटलमेन्ट छिटो गर्दैन।",
|
||||
"coolingEndTime": "कुलिङ समाप्ति",
|
||||
"automaticSettlementAt": "स्वचालित सेटलमेन्ट: {{time}}",
|
||||
"cooldownRemaining": "बाँकी {{time}}",
|
||||
"createDraw": {
|
||||
"description": "स्थानीय समयक्षेत्र {{tz}} मा मिति र समय प्रविष्ट गर्नुहोस्।",
|
||||
"drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008",
|
||||
@@ -79,6 +86,7 @@
|
||||
},
|
||||
"currentPayout": "हालको कुल भुक्तानी",
|
||||
"currentResultVersion": "हालको नतिजा संस्करण",
|
||||
"resultVersion": "नतिजा संस्करण",
|
||||
"currentStatusAndDraft": "हालको स्थिति {{status}} · सेभ गरेपछि pending batch बन्छ, सिधै प्रकाशित हुँदैन",
|
||||
"currentStatusDraftHint": "सेभ गरेपछि समीक्षा बाँकी ब्याच बन्छ, सिधै प्रकाशित हुँदैन",
|
||||
"currentStatusLabel": "हालको स्थिति",
|
||||
@@ -148,7 +156,7 @@
|
||||
"overviewTitle": "ड्र अवलोकन",
|
||||
"pageGuide": "ड्र जीवनचक्र व्यवस्थापन: योजना, बन्द, नतिजा समीक्षा र सेटलमेन्ट।",
|
||||
"payoutTotal": "कुल भुक्तानी",
|
||||
"pendingBatches": "बाँकी ब्याच",
|
||||
"pendingBatches": "समीक्षाको प्रतीक्षामा नतिजा (खेलाडीलाई नदेखिने)",
|
||||
"pendingReview": "समीक्षा बाँकी",
|
||||
"plannedDraw": "योजनाबद्ध ड्रअ",
|
||||
"prize": "पुरस्कार",
|
||||
@@ -160,8 +168,11 @@
|
||||
"publishTitle": "प्रकाशित",
|
||||
"published": "प्रकाशित",
|
||||
"publishedView": "प्रकाशित नतिजा हेर्नुहोस्",
|
||||
"officialResultsTitle": "आधिकारिक नतिजा (खेलाडीलाई देखिने)",
|
||||
"officialResultsHint": "यहाँ प्रकाशित र खेलाडीलाई लागू भएका नम्बर मात्र देखिन्छन्; समीक्षा ड्राफ्ट अलग राखिन्छ।",
|
||||
"queryDraw": "ड्रअ खोज्नुहोस्",
|
||||
"relatedSettlementBatches": "सम्बन्धित सेटलमेन्ट ब्याच",
|
||||
"relatedSettlementBatches": "यस ड्रको सेटलमेन्ट प्रक्रिया अभिलेख",
|
||||
"settlementBatchesHint": "सामान्यतया यस ड्रमा बाजी भएको प्रत्येक प्रदायकका लागि अभिलेख बन्छ। टिकट विवरण हेर्न ब्याच नम्बर छान्नुहोस्।",
|
||||
"reopen": "पुनःखोल्नुहोस्",
|
||||
"reset": "रिसेट",
|
||||
"resultBatchesTitle": "नतिजा ब्याच",
|
||||
@@ -190,6 +201,12 @@
|
||||
"rngDraw": "RNG ड्रअ",
|
||||
"rngSummary": "RNG ह्यास {{hash}}",
|
||||
"runSettlement": "सेटलमेन्ट चलाउनुहोस्",
|
||||
"settleEarly": "अगावै सेटल गर्नुहोस्",
|
||||
"retrySettlement": "सेटलमेन्ट पुनः चलाउनुहोस्",
|
||||
"settleEarlySuccess": "बाँकी पुष्टि समय छोडेर सेटलमेन्ट सुरु भयो",
|
||||
"retrySettlementSuccess": "सेटलमेन्ट पुनः प्रयास सुरु भयो",
|
||||
"settlementManagePermissionRequired": "भुक्तानी व्यवस्थापकले मात्र सेटलमेन्ट सुरु वा पुनः प्रयास गर्न सक्छ।",
|
||||
"settlementUnavailable": "ड्र {{status}} अवस्थामा हुँदा म्यानुअल सेटलमेन्ट आवश्यक छैन।",
|
||||
"saveDraft": "ड्राफ्ट सुरक्षित गर्नुहोस्",
|
||||
"saveFailed": "सेभ असफल भयो",
|
||||
"saving": "सेभ हुँदैछ…",
|
||||
@@ -218,16 +235,30 @@
|
||||
},
|
||||
"submitting": "पेश हुँदैछ…",
|
||||
"subnav": {
|
||||
"finance": "ड्रअ वित्त",
|
||||
"finance": "वित्त र सेटलमेन्ट",
|
||||
"results": "परिणाम",
|
||||
"tickets": "बाजी अवस्था",
|
||||
"review": "समीक्षा र प्रकाशन",
|
||||
"riskHot": "हट नम्बर",
|
||||
"riskLockLogs": "लक लग",
|
||||
"riskOccupancy": "जोखिम अकुपेन्सी",
|
||||
"riskPools": "जोखिम पूल",
|
||||
"riskPools": "नम्बर सीमा",
|
||||
"riskSoldOut": "बिक्री समाप्त नम्बर",
|
||||
"status": "ड्रअ स्थिति"
|
||||
},
|
||||
"lifecycle": {
|
||||
"currentStage": "हालको चरण: {{status}}",
|
||||
"pending": "यस ड्रका लागि बाजी खुलिसकेको छैन।",
|
||||
"open": "बन्द समयसम्म खेलाडीले बाजी लगाउन सक्छन्।",
|
||||
"closing": "बाजी बन्द छ र निर्धारित नतिजा समय पर्खिरहेको छ।",
|
||||
"closed": "ड्र बन्द छ र नतिजा सिर्जना वा प्रविष्टि पर्खिरहेको छ।",
|
||||
"drawing": "प्रणालीले नतिजा सिर्जना गर्दैछ।",
|
||||
"review": "नतिजा समीक्षामा छ र खेलाडीलाई अझै लागू भएको छैन।",
|
||||
"cooldown": "नतिजा प्रकाशित छ र जाँच अवधिमा छ। समय पुगेपछि स्वचालित सेटल हुनेछ वा व्यवस्थापकले अगावै सेटल गर्न सक्छ।",
|
||||
"settling": "सेटलमेन्ट अभिलेख प्रशोधन हुँदैछ। पृष्ठ प्रत्येक ५ सेकेन्डमा अद्यावधिक हुन्छ।",
|
||||
"settled": "सेटलमेन्ट र भुक्तानी प्रक्रिया पूरा भयो।",
|
||||
"cancelled": "यो ड्र रद्द भयो र नतिजा वा सेटलमेन्टमा जाने छैन।"
|
||||
},
|
||||
"tail2": "अन्तिम 2",
|
||||
"tail3": "अन्तिम 3",
|
||||
"ticketCount": "टिकट",
|
||||
|
||||
@@ -92,5 +92,35 @@
|
||||
"tabConfig": "कन्फिगरेसन",
|
||||
"tabRecords": "रेकर्ड",
|
||||
"confirmSavePoolTitle": "ज्याकपट सेटिङ बचत गर्ने?",
|
||||
"confirmSavePoolDescription": "यसले संचय दर, थ्रेसहोल्ड, भुक्तानी अनुपात र सम्बन्धित प्यारामिटरहरू अद्यावधिक गर्नेछ (पूल ब्यालेन्स होइन)।"
|
||||
"confirmSavePoolDescription": "यसले संचय दर, थ्रेसहोल्ड, भुक्तानी अनुपात र सम्बन्धित प्यारामिटरहरू अद्यावधिक गर्नेछ (पूल ब्यालेन्स होइन)।",
|
||||
"help": {
|
||||
"openGuide": "पृष्ठ जानकारी",
|
||||
"guideTitle": "ज्याकपट पृष्ठ जानकारी",
|
||||
"guideDescription": "ज्याकपट सामान्य odds भुक्तानीभन्दा अलग अतिरिक्त पुरस्कार पूल हो। यस पृष्ठले मुद्रा अनुसार पूल कसरी बढ्छ, कहिले रिलिज हुन्छ र रेकर्ड कहाँ हेर्ने भन्ने नियन्त्रण गर्छ।",
|
||||
"aria": "{{field}} को जानकारी हेर्नुहोस्",
|
||||
"fields": {
|
||||
"currentAmount": "यस मुद्राको पूलमा अहिले जम्मा भएको उपलब्ध ब्यालेन्स। साधारण Save बाट सिधै बदलिँदैन; योग्य योगदान, ज्याकपट भुक्तानी वा कारणसहितको समायोजनले बदल्छ।",
|
||||
"status": "यस मुद्राको पूल भविष्यका कारोबारमा चल्ने वा नचल्ने नियन्त्रण गर्छ। बन्द हुँदा नयाँ टिकटले योगदान गर्दैन र स्वतः वा म्यानुअल बर्स्ट चल्दैन; पुरानो ब्यालेन्स रहन्छ।",
|
||||
"contributionRate": "सफल टिकटले न्यूनतम रकम पुगेपछि नाममात्र बेट रकमलाई यो प्रतिशतले गुणा गरी पूलमा लेखिन्छ। यो आन्तरिक बाँडफाँट हो, खेलाडीबाट थप शुल्क होइन।",
|
||||
"minBetAmount": "सफल टिकटले ज्याकपटमा योगदान गर्ने वा नगर्ने मात्र निर्णय गर्छ। प्ले किन्न मिल्ने न्यूनतम वा अधिकतम रकम Betting rules मा सेट हुन्छ।",
|
||||
"triggerThreshold": "पूल यो रकममा पुगेपछि र सो ड्रअमा प्रथम पुरस्कार विजेता भए threshold बाट बर्स्ट हुन सक्छ। विजेता नभए रकम पुगेको मात्र कारणले भुक्तानी हुँदैन।",
|
||||
"payoutRate": "Threshold, combo-play वा manual burst हुँदा बर्स्टअघिको ब्यालेन्सबाट रिलिज हुने प्रतिशत। Forced-gap burst ले पूरा पूल रिलिज गर्छ।",
|
||||
"forceTriggerGap": "पछिल्लो बर्स्टपछि यति settled draws पुगेपछि, अर्को प्रथम पुरस्कार विजेता भएको ड्रअमा forced burst हुन सक्छ। 0 राख्दा यो सर्त बन्द हुन्छ।",
|
||||
"comboTriggerPlays": "छानिएको प्लेमा प्रथम पुरस्कार परे threshold नपुगे पनि बर्स्ट हुन सक्छ। यसले ट्रिगर मात्र निर्धारण गर्छ, भुक्तानी पाउने विजेता सीमित गर्दैन।",
|
||||
"balanceAdjustment": "हालको पूल ब्यालेन्स म्यानुअल रूपमा बढाउने वा घटाउने। कारण अनिवार्य हुन्छ, सबै काम रेकर्ड हुन्छ र ब्यालेन्स 0 भन्दा तल जान सक्दैन।",
|
||||
"adjustmentDirection": "यो समायोजनले पूल ब्यालेन्स बढाउने वा घटाउने छान्नुहोस्।",
|
||||
"adjustmentAmount": "मुख्य मुद्रा एकाइमा थप्ने वा घटाउने रकम; समायोजनपछि चाहिएको कुल ब्यालेन्स होइन।",
|
||||
"adjustmentReason": "ब्यालेन्स किन बदलिएको हो भन्ने विवरण। Audit र reconciliation का लागि कम्तीमा 3 अक्षर चाहिन्छ।",
|
||||
"manualBurst": "सुपर एडमिन मात्र। प्रकाशित नतिजा, settlement batch र प्रथम पुरस्कार विजेता भएको ड्रअ छानेर हालको payout rate अनुसार पूल रिलिज गर्छ।",
|
||||
"records": "पढ्न मात्र मिल्ने jackpot payout र प्रत्येक टिकटको contribution रेकर्ड draw number अनुसार फिल्टर र export गर्नुहोस्।"
|
||||
},
|
||||
"guide": {
|
||||
"settingsTitle": "पूल कसरी बढ्छ र ट्रिगर हुन्छ",
|
||||
"settingsDescription": "योग्य सफल टिकटले पहिले पूल बढाउँछ। Settlement मा threshold, forced-gap वा combo-play जाँच्नुअघि कम्तीमा एक प्रथम पुरस्कार विजेता हुनुपर्छ।",
|
||||
"actionsTitle": "म्यानुअल काम र रेकर्ड",
|
||||
"actionsDescription": "Balance adjustment र manual burst ले वास्तविक पूल रकम तुरुन्त बदल्छ। पहिले मुद्रा, रकम, ड्रअ र कारण जाँच गर्नुहोस्।",
|
||||
"effectiveTitle": "परिवर्तन कहिले लागू हुन्छ",
|
||||
"effectiveDescription": "Jackpot सेटिङमा draft र publish प्रक्रिया छैन। Save गरेपछि पछिल्ला contribution र नसकिएको jackpot settlement मा तुरुन्त लागू हुन्छ। बेचिसकिएको तर settle नभएको ड्रअले पनि नयाँ trigger र payout rate प्रयोग गर्न सक्छ।"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
"actionFilter": "कार्य",
|
||||
"actions": "कार्य",
|
||||
"all": "सबै",
|
||||
"allPoolsPageTitle": "सबै जोखिम पूल",
|
||||
"allPoolsPageTitle": "नम्बर भुक्तानी सीमा (जोखिम पूल)",
|
||||
"poolExplanationTitle": "हरेक नम्बरले अझै कति सम्भावित भुक्तानी स्वीकार्न सक्छ भन्ने यहाँ देखिन्छ",
|
||||
"poolExplanation": "हरेक प्रदायक र चार-अङ्क नम्बरको अधिकतम सम्भावित भुक्तानी सीमा हुन्छ। बाजीले सबैभन्दा खराब सम्भावित भुक्तानी अनुसार क्षमता सुरक्षित गर्छ। क्षमता अपुग हुँदा नम्बर बिक्री बन्द हुन्छ र सेटलमेन्ट वा फिर्तापछि क्षमता खुल्छ। यो जोखिम आरक्षण हो, वास्तविक भुक्तानी होइन।",
|
||||
"amount": "रकम",
|
||||
"applyFilter": "फिल्टर लागू गर्नुहोस्",
|
||||
"backToAllPools": "सबै जोखिम पूलमा फर्कनुहोस्",
|
||||
"backToList": "सूचीमा फर्कनुहोस्",
|
||||
"capAmount": "क्याप",
|
||||
"capAmount": "अधिकतम भुक्तानी सीमा",
|
||||
"center": "जोखिम केन्द्र",
|
||||
"changeDraw": "ड्रअ परिवर्तन गर्नुहोस्",
|
||||
"close": "बन्द",
|
||||
@@ -48,7 +50,7 @@
|
||||
"lockLogsGroupedHint": "पूर्वनिर्धारित रूपमा टिकट अनुसार समूह। विस्तारित प्लेको संयोजन टिकट विवरणमा हेर्नुहोस्।",
|
||||
"lockLogsTitle": "जोखिम लक लग",
|
||||
"lockReleaseSummary": "लक / रिलिज पङ्क्ति",
|
||||
"lockedAmount": "लक गरिएको",
|
||||
"lockedAmount": "हालको बाजीले सुरक्षित गरेको",
|
||||
"lockedWorstCase": "लक गरिएको (अधिकतम भुक्तानी सुरक्षित)",
|
||||
"manualCloseSuccess": "नम्बर बेटिङ म्यानुअल रूपमा बन्द गरियो",
|
||||
"no": "होइन",
|
||||
@@ -66,7 +68,10 @@
|
||||
"recoverSuccess": "नम्बर बेटिङ पुनर्स्थापित गरियो",
|
||||
"refresh": "रिफ्रेस",
|
||||
"release": "रिलिज",
|
||||
"remainingAmount": "बाँकी",
|
||||
"remainingAmount": "बाँकी भुक्तानी क्षमता",
|
||||
"emptyPools": "यो फिल्टरमा नम्बर सीमा अभिलेख छैन। बाजी जोखिम सुरक्षित नभएका नम्बर सामान्यतया देखिँदैनन्।",
|
||||
"emptyHighRiskPools": "हाल ८०% वा बढी प्रयोग भएको उच्च जोखिम नम्बर छैन।",
|
||||
"emptySoldOutPools": "क्षमता अपुग वा म्यानुअल बन्दका कारण बिक्री रोकिएको नम्बर छैन।",
|
||||
"remainingSellable": "बाँकी बिक्रीयोग्य",
|
||||
"riskFilter": "जोखिम फिल्टर",
|
||||
"provider": "प्रोभाइडर",
|
||||
@@ -109,7 +114,7 @@
|
||||
"time": "समय",
|
||||
"title": "जोखिम",
|
||||
"totalCap": "क्याप रकम",
|
||||
"usageRatio": "प्रयोग अनुपात",
|
||||
"usageRatio": "अकुपेन्सी अनुपात",
|
||||
"view": "हेर्नुहोस्",
|
||||
"viewDetail": "विवरण",
|
||||
"viewTicket": "टिकट",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"draws": "期号列表",
|
||||
"rules_plays": "投注规则",
|
||||
"rules_odds": "赔率与基础回水",
|
||||
"bet_providers": "开注商管理",
|
||||
"rules": "投注规则",
|
||||
"risk_cap": "限额版本",
|
||||
"risk": "风控中心",
|
||||
@@ -212,7 +213,8 @@
|
||||
"auth": {
|
||||
"checking": "正在校验登录状态…",
|
||||
"checkingShort": "正在进入工作台…",
|
||||
"sessionExpired": "登录已失效,请重新登录"
|
||||
"sessionExpired": "登录已失效,请重新登录",
|
||||
"sessionReplaced": "该账号已在其他地方登录,当前会话已退出,请重新登录"
|
||||
},
|
||||
"confirm": {
|
||||
"cancel": "取消",
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
},
|
||||
"hints": {
|
||||
"manualReview": "开启后,RNG 开奖结果会先进入待审核,必须由后台人工发布。",
|
||||
"cooldownMinutes": "结果发布后等待多久再进入 settling。填 0 表示发布后直接进入结算。",
|
||||
"cooldownMinutes": "结果发布后等待多久再进入结算;填 0 表示立即结算。修改仅影响之后发布开奖结果的期号,已经进入冷静期的期号不会自动重算。",
|
||||
"autoSettlement": "关闭后,tick 不会自动跑结算,只能由后台手工执行。",
|
||||
"autoApprove": "冷静期结束并跑完结算后,是否自动将批次标记为已审核。",
|
||||
"autoPayout": "批次已审核后,是否由 tick 自动把中奖金额打入玩家钱包。",
|
||||
@@ -487,6 +487,29 @@
|
||||
"title": "规则文案(多语言)",
|
||||
"description": "玩法 {{playCode}};修改内容只会暂存到草稿,保存并发布后才会生效。",
|
||||
"apply": "应用到草稿"
|
||||
},
|
||||
"help": {
|
||||
"openGuide": "页面说明",
|
||||
"guideTitle": "投注规则页面说明",
|
||||
"guideDescription": "这个页面决定玩家能不能购买某个玩法、玩家看到的玩法名称和顺序,以及每一条下注允许输入的金额范围。它不控制赔率,也不控制奖池资格。",
|
||||
"aria": "查看“{{field}}”的说明",
|
||||
"fields": {
|
||||
"status": "发布后控制玩家是否可以购买这个玩法。关闭后,前台会隐藏或禁用该玩法,后端也会拒绝新的下注;历史注单不受影响。",
|
||||
"displayName": "玩家端展示的玩法名称。修改只进入当前草稿,保存并发布后才会展示给玩家。",
|
||||
"order": "控制玩法在玩家端列表中的显示先后。数字越小通常越靠前;部分固定式投注表可能仍按自己的版面排列。",
|
||||
"minBet": "玩家一次提交的一条玩法下注,允许输入的最小金额。低于该值时,新下注会被拒绝。",
|
||||
"maxBet": "玩家一次提交的一条玩法下注,允许输入的最大金额。组合下注拆成多条后,系统会按实际展开结果重新计算。",
|
||||
"batchSwitches": "按玩法分组一次开启或关闭多项玩法。操作只修改当前草稿,仍需保存并发布后才会对玩家生效。",
|
||||
"batchGroup": "一次将“{{group}}”下 {{count}} 个玩法切换为同一状态;只修改当前草稿。"
|
||||
},
|
||||
"guide": {
|
||||
"settingsTitle": "每个设置控制什么",
|
||||
"settingsDescription": "状态控制是否可售;显示名称和排序控制玩家看到的内容;最小与最大下注控制每条下注可提交的金额范围。",
|
||||
"batchTitle": "批量开关怎么用",
|
||||
"batchDescription": "批量开关适合一次调整整组玩法。它只是帮你修改草稿里的多行数据,不会绕过保存和发布流程。",
|
||||
"workflowTitle": "什么时候真正生效",
|
||||
"workflowDescription": "线上生效版本只能查看。要修改时先创建草稿,在草稿中编辑并保存,最后发布。仅保存不会影响玩家;发布后只影响新的下注,历史注单继续使用下单时保存的规则快照。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"prizeScopes": {
|
||||
@@ -574,6 +597,28 @@
|
||||
"currentActive": "当前生效",
|
||||
"afterPublish": "发布后"
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
"openGuide": "页面说明",
|
||||
"guideTitle": "赔率与基础回水页面说明",
|
||||
"guideDescription": "这个页面设置普通中奖赔付倍数和平台基础回水。它不控制奖池额外派彩,也不决定玩法是否开放。",
|
||||
"aria": "查看“{{field}}”的说明",
|
||||
"fields": {
|
||||
"provider": "选择赔率适用的供应商范围。GLOBAL 是通用默认值;供应商专属配置会覆盖通用值,没有专属配置时则继承 GLOBAL。",
|
||||
"category": "只用来筛选 4D、3D、2D 等玩法范围,方便查找;切换分类本身不会修改配置。",
|
||||
"playType": "选择当前要查看或修改的具体玩法。每个玩法可以有不同奖级赔率和基础回水。",
|
||||
"multiplier": "普通中奖的赔付倍数。大致可理解为有效中奖金额乘以该倍数,最终金额仍按系统精度规则取整。",
|
||||
"scopeMultiplier": "控制“{{scope}}”这个奖级中奖时使用的普通赔付倍数。它不是奖池爆池的额外派彩比例。",
|
||||
"rebateRate": "平台基础回水比例。玩家或代理的额外回水会在其他配置中叠加;实际扣款和结算还会遵循当前账户模式。"
|
||||
},
|
||||
"guide": {
|
||||
"scopeTitle": "先选对配置范围",
|
||||
"scopeDescription": "先选择供应商、分类和玩法。分类只是筛选条件;供应商和玩法共同决定你正在查看或修改哪一组赔率。",
|
||||
"settingsTitle": "赔率和回水控制什么",
|
||||
"settingsDescription": "各奖级倍数控制普通中奖赔付,基础回水控制平台给下注的基础优惠。两者都不等于奖池页面中的爆池派彩。",
|
||||
"workflowTitle": "什么时候真正生效",
|
||||
"workflowDescription": "赔率使用版本化流程:先创建草稿、修改并保存,再发布。保存草稿不会影响玩家;发布后新注单使用新配置,已经成功下单的注单继续使用下单时保存的赔率和回水快照。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rebate": {
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
"sequenceNo": "流水序号",
|
||||
"plannedDraw": "计划开奖",
|
||||
"coolingEndTime": "冷静期结束",
|
||||
"automaticSettlementAt": "自动结算时间:{{time}}",
|
||||
"cooldownRemaining": "剩余 {{time}}",
|
||||
"resultSource": "结果来源",
|
||||
"resultSourceOptions": {
|
||||
"rng": "RNG 自动生成",
|
||||
@@ -92,6 +94,7 @@
|
||||
"published": "已发布"
|
||||
},
|
||||
"currentResultVersion": "当前结果版本",
|
||||
"resultVersion": "开奖结果版本",
|
||||
"settleVersion": "结算版本",
|
||||
"isReopened": "是否重开",
|
||||
"yes": "是",
|
||||
@@ -109,8 +112,15 @@
|
||||
"rngDraw": "RNG开奖",
|
||||
"rngAutoGenerate": "自动开奖",
|
||||
"reopen": "重开",
|
||||
"cooldownReopen": "冷静期重开",
|
||||
"cooldownReopen": "撤回开奖结果并重开",
|
||||
"reopenUsageHint": "“撤回开奖结果并重开”仅用于已发布号码有误,需要重新生成和审核;它不会加快结算。",
|
||||
"runSettlement": "触发结算",
|
||||
"settleEarly": "提前结算",
|
||||
"retrySettlement": "重新触发结算",
|
||||
"settleEarlySuccess": "已跳过剩余确认窗口并开始结算",
|
||||
"retrySettlementSuccess": "已重新触发本期结算",
|
||||
"settlementManagePermissionRequired": "仅派彩管理人员可提前或重新触发结算。",
|
||||
"settlementUnavailable": "当前状态为“{{status}}”,无需提供手动结算操作。",
|
||||
"processing": "处理中…",
|
||||
"actionSuccess": "{{name}}成功",
|
||||
"actionFailed": "{{name}}失败",
|
||||
@@ -121,7 +131,8 @@
|
||||
"currentPayout": "当期派彩合计",
|
||||
"grossProfit": "近似毛损益",
|
||||
"settlementBatchList": "结算记录",
|
||||
"relatedSettlementBatches": "结算批次",
|
||||
"relatedSettlementBatches": "本期结算处理记录",
|
||||
"settlementBatchesHint": "通常按本期有下注的开注商生成处理记录;点击批次编号可查看对应注单明细。",
|
||||
"noSettlementBatches": "暂无结算批次记录。",
|
||||
"ticketCount": "票数",
|
||||
"winCount": "中奖数",
|
||||
@@ -153,7 +164,7 @@
|
||||
"clear": "清空",
|
||||
"saveDraft": "保存草稿",
|
||||
"saving": "保存中…",
|
||||
"pendingBatches": "待发布",
|
||||
"pendingBatches": "待审核结果(尚未对玩家生效)",
|
||||
"noPendingBatches": "暂无",
|
||||
"batchId": "批次",
|
||||
"numberCount": "号码条数",
|
||||
@@ -173,6 +184,8 @@
|
||||
"checkBeforePublish": "请核对以下号码后再发布",
|
||||
"checkBeforePublishDesc": "确认无误后点击发布。",
|
||||
"publishedView": "查看已发布展示",
|
||||
"officialResultsTitle": "正式开奖结果(已对玩家显示)",
|
||||
"officialResultsHint": "这里只展示已经发布并对玩家生效的号码;待审核草稿不会出现在这里。",
|
||||
"confirmPublish": "确认发布",
|
||||
"submitting": "提交中…",
|
||||
"publishSuccess": "已发布 · {{drawNo}} · 状态 {{status}}",
|
||||
@@ -181,13 +194,27 @@
|
||||
"subnav": {
|
||||
"status": "概览",
|
||||
"results": "开奖结果",
|
||||
"finance": "收支",
|
||||
"tickets": "下注情况",
|
||||
"finance": "收支结算",
|
||||
"review": "开奖发布",
|
||||
"riskOccupancy": "风控占用",
|
||||
"riskLockLogs": "占用流水",
|
||||
"riskHot": "热门号码",
|
||||
"riskSoldOut": "售罄号码",
|
||||
"riskPools": "风险池"
|
||||
"riskPools": "号码限额"
|
||||
},
|
||||
"lifecycle": {
|
||||
"currentStage": "当前阶段:{{status}}",
|
||||
"pending": "本期尚未开放下注。",
|
||||
"open": "玩家可以下注;到封盘时间后系统会停止接单。",
|
||||
"closing": "本期已停止接单,等待计划开奖时间。",
|
||||
"closed": "已封盘待开奖,系统将生成或等待录入开奖结果。",
|
||||
"drawing": "系统正在生成开奖结果。",
|
||||
"review": "开奖结果尚在审核,发布前不会对玩家生效。",
|
||||
"cooldown": "结果已发布并进入核错窗口;到期会自动结算,也可由派彩管理人员提前结算。",
|
||||
"settling": "正在生成或处理本期结算记录,页面每 5 秒自动更新。",
|
||||
"settled": "本期结算与派彩流程已完成。",
|
||||
"cancelled": "本期已取消,不再进入开奖与结算流程。"
|
||||
},
|
||||
"statusOptions": {
|
||||
"all": "不限",
|
||||
@@ -216,10 +243,14 @@
|
||||
"cancelDrawDescription": "取消后该期将不再开奖,请确认无未处理注单风险。",
|
||||
"rngDrawTitle": "确认 RNG 自动生成开奖?",
|
||||
"rngDrawDescription": "将按系统规则生成本期开奖号码并进入后续流程。",
|
||||
"reopenTitle": "确认冷静期重开?",
|
||||
"reopenDescription": "重开后需重新审核/发布结果,可能影响已展示的开奖信息。",
|
||||
"reopenTitle": "确认撤回开奖结果并重开?",
|
||||
"reopenDescription": "仅在已发布号码有误时使用。操作后期号回到已封盘待开奖,需要重新生成、审核并发布结果;这不会加快结算。",
|
||||
"runSettlementTitle": "确认触发结算?",
|
||||
"runSettlementDescription": "将按已发布开奖结果生成本期结算批次。",
|
||||
"settleEarlyTitle": "确认提前结算?",
|
||||
"settleEarlyDescription": "将跳过本期剩余的开奖结果核错窗口,立即生成结算处理记录,并继续自动审核与派彩。请先确认正式开奖结果无误。",
|
||||
"retrySettlementTitle": "确认重新触发结算?",
|
||||
"retrySettlementDescription": "将幂等重试本期结算处理,不会重复生成已存在的有效批次。",
|
||||
"saveManualDraftTitle": "确认保存人工开奖草稿?",
|
||||
"saveManualDraftDescription": "将写入 23 个开奖号码草稿,提交后进入审核流程。",
|
||||
"publishTitle": "确认发布开奖结果?",
|
||||
|
||||
@@ -93,5 +93,35 @@
|
||||
"time": "时间",
|
||||
"ticketNo": "注单",
|
||||
"player": "玩家",
|
||||
"contributionAmount": "蓄水额"
|
||||
"contributionAmount": "蓄水额",
|
||||
"help": {
|
||||
"openGuide": "页面说明",
|
||||
"guideTitle": "奖池页面说明",
|
||||
"guideDescription": "奖池是普通赔率奖金之外的额外奖励资金。本页按币种控制奖池如何累积、何时释放,以及如何查看相关流水。",
|
||||
"aria": "查看“{{field}}”说明",
|
||||
"fields": {
|
||||
"currentAmount": "该币种奖池当前累计的可用余额。它不能通过普通保存直接修改,只能由合格注单蓄水、爆池派彩或带原因的余额调整改变。",
|
||||
"status": "控制该币种奖池是否参与后续业务。关闭后新成功注单不再蓄水,自动爆池与手动爆池也不会执行;现有余额会保留。",
|
||||
"contributionRate": "成功下注且达到奖池最低下注额后,系统按名义下注额乘以该百分比计入奖池。这是内部计提,不会在下注额之外再向玩家多扣一笔。",
|
||||
"minBetAmount": "只决定一笔成功注单是否参与奖池蓄水,不决定该玩法能否下注。真正的玩法下注上下限在“投注规则”页面配置。",
|
||||
"triggerThreshold": "奖池余额达到此金额后,如果当期存在头奖中奖注单,可按“达到阈值”触发爆池。只有余额达到阈值但没有头奖得主时不会派发。",
|
||||
"payoutRate": "达到阈值、组合玩法或手动爆池时,从爆池前余额中释放的百分比。连续未爆强制触发走全池释放,不使用此比例。",
|
||||
"forceTriggerGap": "从上次爆池后累计的已结算期数达到此值后,下一次出现头奖得主时可强制爆池。设为 0 表示不启用此条件。",
|
||||
"comboTriggerPlays": "勾选的玩法只要在某期产生头奖得主,就可在未达到余额阈值时触发爆池。它只决定触发条件,不限定最终哪些头奖得主参与分配。",
|
||||
"balanceAdjustment": "人工增加或减少当前奖池余额。必须填写原因,每次操作都会写入调整流水;减少后余额不能小于 0。",
|
||||
"adjustmentDirection": "选择本次人工调整是增加奖池余额还是减少奖池余额。",
|
||||
"adjustmentAmount": "本次人工增加或减少的主币金额,不是调整后的目标余额。",
|
||||
"adjustmentReason": "记录为什么要人工调整奖池,至少填写 3 个字符,后续可用于审计和对账。",
|
||||
"manualBurst": "仅超级管理员可用。指定已发布结果且已有结算批次、存在头奖得主的期号,按当前派彩比例人工释放奖池。",
|
||||
"records": "查看只读的奖池派彩记录和逐笔蓄水记录,可按期号筛选并导出。"
|
||||
},
|
||||
"guide": {
|
||||
"settingsTitle": "奖池如何累积与触发",
|
||||
"settingsDescription": "成功注单先按蓄水条件增加奖池;开奖结算时必须先有头奖得主,再判断阈值、连续未爆或组合玩法条件。",
|
||||
"actionsTitle": "人工操作与流水",
|
||||
"actionsDescription": "余额调整和手动爆池都会直接改变真实奖池资金,请先确认币种、金额、期号和原因。",
|
||||
"effectiveTitle": "生效方式",
|
||||
"effectiveDescription": "奖池配置没有草稿和发布流程,点击保存后立即用于后续蓄水与尚未完成的奖池结算。已经卖票但尚未结算的期号,也可能受到新触发参数和派彩比例影响。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"poolsTitle": "风险池",
|
||||
"hotPageTitle": "热门号码监控",
|
||||
"soldOutPageTitle": "售罄号码列表",
|
||||
"allPoolsPageTitle": "全部风险池",
|
||||
"allPoolsPageTitle": "号码赔付限额(风险池)",
|
||||
"poolExplanationTitle": "这里控制每个号码还能承接多少潜在赔付",
|
||||
"poolExplanation": "每个开注商、每个四位号码都有最高潜在赔付额度。下注会按最坏赔付预留额度;额度不足时该号码停售,结算或退款后释放。这里的金额是风险预留,不是实际派彩金额。",
|
||||
"sourceReasonOptions": {
|
||||
"ticket_place": "下注占用",
|
||||
"ticket_rollback": "注单回滚",
|
||||
@@ -36,10 +38,13 @@
|
||||
"sortRemainingAsc": "剩余额 ↑(紧俏)",
|
||||
"sortNumberAsc": "号码 ↑",
|
||||
"loadPoolsFailed": "加载风险池失败",
|
||||
"capAmount": "封顶",
|
||||
"lockedAmount": "已占用",
|
||||
"remainingAmount": "剩余",
|
||||
"usageRatio": "占用比",
|
||||
"capAmount": "最高赔付额度",
|
||||
"lockedAmount": "当前下注预留",
|
||||
"remainingAmount": "剩余可承接赔付",
|
||||
"usageRatio": "占用比例",
|
||||
"emptyPools": "当前筛选下没有号码额度记录;未产生下注预留的号码通常不会列出。",
|
||||
"emptyHighRiskPools": "当前没有占用比例达到 80% 的高风险号码。",
|
||||
"emptySoldOutPools": "当前没有因额度不足或人工关闭而停售的号码。",
|
||||
"poolStatus": "状态",
|
||||
"soldOut": "售罄",
|
||||
"warning": "预警",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { isAxiosError } from "axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import { getAdminSessionState } from "@/stores/admin-session";
|
||||
import { readToken } from "@/stores/admin-token";
|
||||
import { isApiEnvelope } from "@/types/api/envelope";
|
||||
@@ -13,6 +11,9 @@ export const ADMIN_UNAUTHENTICATED_CODE = 8110;
|
||||
/** 与后端 {@see ErrorCode::AdminAccountDisabled} 一致 */
|
||||
export const ADMIN_ACCOUNT_DISABLED_CODE = 8113;
|
||||
|
||||
/** 与后端 ErrorCode::AdminSessionReplaced 一致 */
|
||||
export const ADMIN_SESSION_REPLACED_CODE = 8115;
|
||||
|
||||
/** 登录验证码错误等,HTTP 可能为 401/422,不应踢出会话 */
|
||||
const LOGIN_FORM_ERROR_CODES = new Set([8111, 8112]);
|
||||
|
||||
@@ -31,7 +32,8 @@ export function isAdminAuthRejected(err: unknown): boolean {
|
||||
if (err instanceof LotteryApiBizError) {
|
||||
return (
|
||||
err.code === ADMIN_UNAUTHENTICATED_CODE ||
|
||||
err.code === ADMIN_ACCOUNT_DISABLED_CODE
|
||||
err.code === ADMIN_ACCOUNT_DISABLED_CODE ||
|
||||
err.code === ADMIN_SESSION_REPLACED_CODE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +56,8 @@ export function isAdminAuthRejected(err: unknown): boolean {
|
||||
|
||||
if (
|
||||
envelopeCode === ADMIN_UNAUTHENTICATED_CODE ||
|
||||
envelopeCode === ADMIN_ACCOUNT_DISABLED_CODE
|
||||
envelopeCode === ADMIN_ACCOUNT_DISABLED_CODE ||
|
||||
envelopeCode === ADMIN_SESSION_REPLACED_CODE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -62,7 +65,8 @@ export function isAdminAuthRejected(err: unknown): boolean {
|
||||
if (isApiEnvelope(body) && body.code !== 0) {
|
||||
return (
|
||||
body.code === ADMIN_UNAUTHENTICATED_CODE ||
|
||||
body.code === ADMIN_ACCOUNT_DISABLED_CODE
|
||||
body.code === ADMIN_ACCOUNT_DISABLED_CODE ||
|
||||
body.code === ADMIN_SESSION_REPLACED_CODE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,12 +82,32 @@ export function isAdminAuthRejected(err: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function redirectToAdminLogin(): void {
|
||||
function adminAuthErrorCode(err: unknown): number | null {
|
||||
if (err instanceof LotteryApiBizError) {
|
||||
return err.code;
|
||||
}
|
||||
|
||||
if (!isAxiosError(err)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = err.response?.data;
|
||||
if (!body || typeof body !== "object" || !("code" in body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const code = (body as { code?: unknown }).code;
|
||||
return typeof code === "number" ? code : null;
|
||||
}
|
||||
|
||||
export function redirectToAdminLogin(
|
||||
reason: "expired" | "replaced" = "expired",
|
||||
): void {
|
||||
if (typeof window === "undefined" || isAdminLoginPath()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginPath = "/admin/login";
|
||||
const loginPath = "/admin/login?session=" + reason;
|
||||
if (window.location.pathname !== loginPath) {
|
||||
window.location.replace(loginPath);
|
||||
}
|
||||
@@ -92,7 +116,7 @@ export function redirectToAdminLogin(): void {
|
||||
/**
|
||||
* 清除本地会话并跳转登录页。可在 axios 拦截器、/auth/me 刷新等任意上下文调用。
|
||||
*/
|
||||
export function handleAdminAuthRejected(): void {
|
||||
export function handleAdminAuthRejected(err?: unknown): void {
|
||||
if (typeof window === "undefined" || isAdminLoginPath()) {
|
||||
return;
|
||||
}
|
||||
@@ -104,21 +128,19 @@ export function handleAdminAuthRejected(): void {
|
||||
|
||||
const session = getAdminSessionState();
|
||||
const hadSession = session.bearerToken != null || readToken() != null;
|
||||
void fetch("/api/v1/admin/auth/logout", { method: "POST" }).catch(() => {
|
||||
void fetch("/api/v1/admin/auth/logout", {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
// Best-effort HttpOnly cookie cleanup; the local session is cleared below.
|
||||
});
|
||||
session.clearSession();
|
||||
|
||||
if (hadSession) {
|
||||
toast.error(
|
||||
i18n.t("auth.sessionExpired", {
|
||||
ns: "common",
|
||||
defaultValue: "Sign-in expired. Please log in again.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
redirectToAdminLogin();
|
||||
const reason =
|
||||
hadSession && adminAuthErrorCode(err) === ADMIN_SESSION_REPLACED_CODE
|
||||
? "replaced"
|
||||
: "expired";
|
||||
redirectToAdminLogin(reason);
|
||||
|
||||
queueMicrotask(() => {
|
||||
authRejectHandling = false;
|
||||
|
||||
@@ -26,7 +26,7 @@ adminHttp.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: unknown) => {
|
||||
if (isAdminAuthRejected(error)) {
|
||||
handleAdminAuthRejected();
|
||||
handleAdminAuthRejected(error);
|
||||
}
|
||||
if (isAxiosError(error) && typeof window !== "undefined") {
|
||||
const status = error.response?.status;
|
||||
@@ -43,7 +43,7 @@ adminHttp.interceptors.response.use(
|
||||
|
||||
function rejectAfterAuthCheck(err: unknown): never {
|
||||
if (isAdminAuthRejected(err)) {
|
||||
handleAdminAuthRejected();
|
||||
handleAdminAuthRejected(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
||||
draws: "draws",
|
||||
rules_plays: "rules_plays",
|
||||
rules_odds: "rules_odds",
|
||||
bet_providers: "bet_providers",
|
||||
jackpot: "jackpot",
|
||||
risk_cap: "risk_cap",
|
||||
risk: "risk",
|
||||
|
||||
@@ -94,6 +94,12 @@ export async function proxyLotteryApi(
|
||||
"",
|
||||
expiredAdminTokenCookieOptions(isSecureRequest(request)),
|
||||
);
|
||||
} else if (path.startsWith("v1/admin/") && upstream.status === 401) {
|
||||
response.cookies.set(
|
||||
ADMIN_TOKEN_STORAGE_KEY,
|
||||
"",
|
||||
expiredAdminTokenCookieOptions(isSecureRequest(request)),
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -141,7 +141,9 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
</Label>
|
||||
<Select value={moduleCode} onValueChange={(value) => setModuleCode(value ?? "all")}>
|
||||
<SelectTrigger id="aud-module" className="w-full sm:w-44">
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{moduleCode === "all" ? t("filterModuleAll") : t(`moduleOptions.${moduleCode}`)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filterModuleAll")}</SelectItem>
|
||||
@@ -159,7 +161,9 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
</Label>
|
||||
<Select value={operatorType} onValueChange={(value) => setOperatorType(value ?? "all")}>
|
||||
<SelectTrigger id="aud-op-type" className="w-full sm:w-36">
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{operatorType === "all" ? t("filterOperatorTypeAll") : t(`operatorTypes.${operatorType}`)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filterOperatorTypeAll")}</SelectItem>
|
||||
@@ -304,4 +308,4 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConfigChipGroupProps = {
|
||||
label?: string;
|
||||
label?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
@@ -14,7 +14,7 @@ type ConfigChipGroupProps = {
|
||||
export function ConfigChipGroup({ label, children, className }: ConfigChipGroupProps) {
|
||||
return (
|
||||
<div className={cn("space-y-2.5", className)}>
|
||||
{label ? <p className="text-sm font-medium text-foreground">{label}</p> : null}
|
||||
{label ? <div className="text-sm font-medium text-foreground">{label}</div> : null}
|
||||
<div className="flex flex-wrap gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
putOddsItems,
|
||||
} from "@/api/admin-config";
|
||||
import { getAdminBetProviders } from "@/api/admin-bet-providers";
|
||||
import { AdminHelpText } from "@/components/admin/admin-field-label";
|
||||
import { AdminPageGuideDialog } from "@/components/admin/admin-page-guide-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
@@ -28,7 +30,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
@@ -602,7 +603,20 @@ export function OddsConfigDocScreen({
|
||||
|
||||
const filtersInner = (
|
||||
<>
|
||||
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
|
||||
<ConfigChipGroup
|
||||
label={
|
||||
<AdminHelpText
|
||||
helpId="odds-provider"
|
||||
helpText={t("odds.help.fields.provider", { ns: "config" })}
|
||||
helpAriaLabel={t("odds.help.aria", {
|
||||
ns: "config",
|
||||
field: t("betProviders.title", { ns: "config", defaultValue: "开注商" }),
|
||||
})}
|
||||
>
|
||||
{t("betProviders.title", { ns: "config", defaultValue: "开注商" })}
|
||||
</AdminHelpText>
|
||||
}
|
||||
>
|
||||
<ConfigChip
|
||||
active={providerCode === GLOBAL_PROVIDER_CODE}
|
||||
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
||||
@@ -619,7 +633,20 @@ export function OddsConfigDocScreen({
|
||||
</ConfigChip>
|
||||
))}
|
||||
</ConfigChipGroup>
|
||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||
<ConfigChipGroup
|
||||
label={
|
||||
<AdminHelpText
|
||||
helpId="odds-category"
|
||||
helpText={t("odds.help.fields.category", { ns: "config" })}
|
||||
helpAriaLabel={t("odds.help.aria", {
|
||||
ns: "config",
|
||||
field: t("odds.category", { ns: "config" }),
|
||||
})}
|
||||
>
|
||||
{t("odds.category", { ns: "config" })}
|
||||
</AdminHelpText>
|
||||
}
|
||||
>
|
||||
{catTabs.map((tab) => (
|
||||
<ConfigChip
|
||||
key={tab.id}
|
||||
@@ -654,7 +681,20 @@ export function OddsConfigDocScreen({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
|
||||
<ConfigChipGroup
|
||||
label={
|
||||
<AdminHelpText
|
||||
helpId="odds-play-type"
|
||||
helpText={t("odds.help.fields.playType", { ns: "config" })}
|
||||
helpAriaLabel={t("odds.help.aria", {
|
||||
ns: "config",
|
||||
field: t("odds.playType", { ns: "config" }),
|
||||
})}
|
||||
>
|
||||
{t("odds.playType", { ns: "config" })}
|
||||
</AdminHelpText>
|
||||
}
|
||||
>
|
||||
{filteredTypes.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
||||
) : (
|
||||
@@ -705,18 +745,63 @@ export function OddsConfigDocScreen({
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<ConfigVersionActions
|
||||
isDraft={isDraft}
|
||||
canManage={canManage}
|
||||
loadingList={resolvedLoadingList}
|
||||
loadingDetail={resolvedLoadingDetail}
|
||||
saving={saving}
|
||||
suppressDraftActions={mergedLayout && isDraft}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
onSaveDraft={() => void handleSave()}
|
||||
onPublish={() => void requestPublishConfirm()}
|
||||
/>
|
||||
<>
|
||||
<AdminPageGuideDialog
|
||||
triggerLabel={t("odds.help.openGuide", { ns: "config" })}
|
||||
title={t("odds.help.guideTitle", { ns: "config" })}
|
||||
description={t("odds.help.guideDescription", { ns: "config" })}
|
||||
sections={[
|
||||
{
|
||||
title: t("odds.help.guide.scopeTitle", { ns: "config" }),
|
||||
description: t("odds.help.guide.scopeDescription", { ns: "config" }),
|
||||
items: [
|
||||
{
|
||||
label: t("betProviders.title", { ns: "config" }),
|
||||
description: t("odds.help.fields.provider", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
label: t("odds.category", { ns: "config" }),
|
||||
description: t("odds.help.fields.category", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
label: t("odds.playType", { ns: "config" }),
|
||||
description: t("odds.help.fields.playType", { ns: "config" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("odds.help.guide.settingsTitle", { ns: "config" }),
|
||||
description: t("odds.help.guide.settingsDescription", { ns: "config" }),
|
||||
items: [
|
||||
{
|
||||
label: t("odds.table.multiplier", { ns: "config" }),
|
||||
description: t("odds.help.fields.multiplier", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
label: t("odds.rebateRate", { ns: "config" }),
|
||||
description: t("odds.help.fields.rebateRate", { ns: "config" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("odds.help.guide.workflowTitle", { ns: "config" }),
|
||||
description: t("odds.help.guide.workflowDescription", { ns: "config" }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ConfigVersionActions
|
||||
isDraft={isDraft}
|
||||
canManage={canManage}
|
||||
loadingList={resolvedLoadingList}
|
||||
loadingDetail={resolvedLoadingDetail}
|
||||
saving={saving}
|
||||
suppressDraftActions={mergedLayout && isDraft}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
onSaveDraft={() => void handleSave()}
|
||||
onPublish={() => void requestPublishConfirm()}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -743,10 +828,21 @@ export function OddsConfigDocScreen({
|
||||
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
|
||||
<div key={scope} className="grid min-w-0 gap-2 border border-border/60 bg-muted/20 p-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
<AdminHelpText
|
||||
helpId={`odds-multiplier-${providerCode}-${resolvedPlayCode}-${scope}`}
|
||||
helpText={t("odds.help.fields.scopeMultiplier", {
|
||||
ns: "config",
|
||||
scope: prizeScopeLabel(scope, t),
|
||||
})}
|
||||
helpAriaLabel={t("odds.help.aria", {
|
||||
ns: "config",
|
||||
field: prizeScopeLabel(scope, t),
|
||||
})}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{prizeScopeLabel(scope, t)}
|
||||
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
|
||||
</Label>
|
||||
</AdminHelpText>
|
||||
{row ? (
|
||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
|
||||
{sourceLabel}
|
||||
@@ -779,9 +875,17 @@ export function OddsConfigDocScreen({
|
||||
</div>
|
||||
))}
|
||||
<div className="grid min-w-0 gap-2 border border-border/60 bg-muted/20 p-3">
|
||||
<Label className="truncate text-xs font-medium text-muted-foreground">
|
||||
<AdminHelpText
|
||||
helpId={`odds-rebate-${providerCode}-${resolvedPlayCode}`}
|
||||
helpText={t("odds.help.fields.rebateRate", { ns: "config" })}
|
||||
helpAriaLabel={t("odds.help.aria", {
|
||||
ns: "config",
|
||||
field: t("odds.rebateRate", { ns: "config" }),
|
||||
})}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t("odds.rebateRate", { ns: "config" })}
|
||||
</Label>
|
||||
</AdminHelpText>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
@@ -895,7 +999,20 @@ export function OddsConfigDocScreen({
|
||||
{toolbarBlock}
|
||||
<div className="grid gap-0 overflow-hidden rounded-lg border border-border/60 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
||||
<aside className="space-y-4 border-b border-border/50 bg-muted/20 px-4 py-4 lg:border-r lg:border-b-0">
|
||||
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
|
||||
<ConfigChipGroup
|
||||
label={
|
||||
<AdminHelpText
|
||||
helpId="odds-provider-merged"
|
||||
helpText={t("odds.help.fields.provider", { ns: "config" })}
|
||||
helpAriaLabel={t("odds.help.aria", {
|
||||
ns: "config",
|
||||
field: t("betProviders.title", { ns: "config", defaultValue: "开注商" }),
|
||||
})}
|
||||
>
|
||||
{t("betProviders.title", { ns: "config", defaultValue: "开注商" })}
|
||||
</AdminHelpText>
|
||||
}
|
||||
>
|
||||
<ConfigChip
|
||||
active={providerCode === GLOBAL_PROVIDER_CODE}
|
||||
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminHelpText } from "@/components/admin/admin-field-label";
|
||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||
import {
|
||||
Select,
|
||||
@@ -60,7 +61,17 @@ export function OddsConfigPlayNav({
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<ConfigChipGroup label={t("odds.category")}>
|
||||
<ConfigChipGroup
|
||||
label={
|
||||
<AdminHelpText
|
||||
helpId="odds-nav-category"
|
||||
helpText={t("odds.help.fields.category")}
|
||||
helpAriaLabel={t("odds.help.aria", { field: t("odds.category") })}
|
||||
>
|
||||
{t("odds.category")}
|
||||
</AdminHelpText>
|
||||
}
|
||||
>
|
||||
{catTabs.map((tab) => (
|
||||
<ConfigChip
|
||||
key={tab.id}
|
||||
@@ -73,7 +84,14 @@ export function OddsConfigPlayNav({
|
||||
</ConfigChipGroup>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">{t("odds.playType")}</p>
|
||||
<AdminHelpText
|
||||
helpId="odds-nav-play-type"
|
||||
helpText={t("odds.help.fields.playType")}
|
||||
helpAriaLabel={t("odds.help.aria", { field: t("odds.playType") })}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("odds.playType")}
|
||||
</AdminHelpText>
|
||||
<Select
|
||||
modal={false}
|
||||
value={playSelectValue}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
|
||||
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
||||
import { AdminHelpText } from "@/components/admin/admin-field-label";
|
||||
import { AdminPageGuideDialog } from "@/components/admin/admin-page-guide-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -489,25 +491,68 @@ export function PlayConfigDocScreen() {
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<ConfigVersionActions
|
||||
isDraft={isDraft}
|
||||
canManage={canManage}
|
||||
loadingList={loadingList}
|
||||
loadingDetail={loadingDetail}
|
||||
saving={saving}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
onSaveDraft={() => void handleSaveDraft()}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<AdminPageGuideDialog
|
||||
triggerLabel={t("play.help.openGuide", { ns: "config" })}
|
||||
title={t("play.help.guideTitle", { ns: "config" })}
|
||||
description={t("play.help.guideDescription", { ns: "config" })}
|
||||
sections={[
|
||||
{
|
||||
title: t("play.help.guide.settingsTitle", { ns: "config" }),
|
||||
description: t("play.help.guide.settingsDescription", { ns: "config" }),
|
||||
items: [
|
||||
{
|
||||
label: t("play.table.status", { ns: "config" }),
|
||||
description: t("play.help.fields.status", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
label: t("play.table.displayName", { ns: "config" }),
|
||||
description: t("play.help.fields.displayName", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
label: t("play.table.order", { ns: "config" }),
|
||||
description: t("play.help.fields.order", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
label: t("play.table.minBet", { ns: "config" }),
|
||||
description: t("play.help.fields.minBet", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
label: t("play.table.maxBet", { ns: "config" }),
|
||||
description: t("play.help.fields.maxBet", { ns: "config" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("play.help.guide.batchTitle", { ns: "config" }),
|
||||
description: t("play.help.guide.batchDescription", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
title: t("play.help.guide.workflowTitle", { ns: "config" }),
|
||||
description: t("play.help.guide.workflowDescription", { ns: "config" }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ConfigVersionActions
|
||||
isDraft={isDraft}
|
||||
canManage={canManage}
|
||||
loadingList={loadingList}
|
||||
loadingDetail={loadingDetail}
|
||||
saving={saving}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
onSaveDraft={() => void handleSaveDraft()}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
@@ -578,17 +623,43 @@ export function PlayConfigDocScreen() {
|
||||
</div>
|
||||
{isDraft ? (
|
||||
<div className="space-y-1.5">
|
||||
<ConfigChipGroup label={t("play.batchSwitchesTitle", { ns: "config" })}>
|
||||
<ConfigChipGroup
|
||||
label={
|
||||
<AdminHelpText
|
||||
helpId="play-batch-switches"
|
||||
helpText={t("play.help.fields.batchSwitches", { ns: "config" })}
|
||||
helpAriaLabel={t("play.help.aria", {
|
||||
ns: "config",
|
||||
field: t("play.batchSwitchesTitle", { ns: "config" }),
|
||||
})}
|
||||
>
|
||||
{t("play.batchSwitchesTitle", { ns: "config" })}
|
||||
</AdminHelpText>
|
||||
}
|
||||
>
|
||||
{batchSwitchStates.map((group) => {
|
||||
const groupOn = group.allEnabled;
|
||||
const isPartial =
|
||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||
return (
|
||||
<label
|
||||
<div
|
||||
key={group.key}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm"
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
<AdminHelpText
|
||||
helpId={`play-batch-${group.key}`}
|
||||
helpText={t("play.help.fields.batchGroup", {
|
||||
ns: "config",
|
||||
group: group.label,
|
||||
count: group.total,
|
||||
})}
|
||||
helpAriaLabel={t("play.help.aria", {
|
||||
ns: "config",
|
||||
field: group.label,
|
||||
})}
|
||||
>
|
||||
{group.label}
|
||||
</AdminHelpText>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
@@ -622,7 +693,7 @@ export function PlayConfigDocScreen() {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ConfigChipGroup>
|
||||
@@ -644,11 +715,71 @@ export function PlayConfigDocScreen() {
|
||||
<TableRow>
|
||||
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[88px] text-center">
|
||||
<AdminHelpText
|
||||
helpId="play-status"
|
||||
helpText={t("play.help.fields.status", { ns: "config" })}
|
||||
helpAriaLabel={t("play.help.aria", {
|
||||
ns: "config",
|
||||
field: t("play.table.status", { ns: "config" }),
|
||||
})}
|
||||
className="justify-center"
|
||||
>
|
||||
{t("play.table.status", { ns: "config" })}
|
||||
</AdminHelpText>
|
||||
</TableHead>
|
||||
<TableHead className="w-36 text-center">
|
||||
<AdminHelpText
|
||||
helpId="play-display-name"
|
||||
helpText={t("play.help.fields.displayName", { ns: "config" })}
|
||||
helpAriaLabel={t("play.help.aria", {
|
||||
ns: "config",
|
||||
field: t("play.table.displayName", { ns: "config" }),
|
||||
})}
|
||||
className="justify-center"
|
||||
>
|
||||
{t("play.table.displayName", { ns: "config" })}
|
||||
</AdminHelpText>
|
||||
</TableHead>
|
||||
<TableHead className="w-24 text-center">
|
||||
<AdminHelpText
|
||||
helpId="play-display-order"
|
||||
helpText={t("play.help.fields.order", { ns: "config" })}
|
||||
helpAriaLabel={t("play.help.aria", {
|
||||
ns: "config",
|
||||
field: t("play.table.order", { ns: "config" }),
|
||||
})}
|
||||
className="justify-center"
|
||||
>
|
||||
{t("play.table.order", { ns: "config" })}
|
||||
</AdminHelpText>
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-center">
|
||||
<AdminHelpText
|
||||
helpId="play-min-bet"
|
||||
helpText={t("play.help.fields.minBet", { ns: "config" })}
|
||||
helpAriaLabel={t("play.help.aria", {
|
||||
ns: "config",
|
||||
field: t("play.table.minBet", { ns: "config" }),
|
||||
})}
|
||||
className="justify-center"
|
||||
>
|
||||
{t("play.table.minBet", { ns: "config" })}
|
||||
</AdminHelpText>
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-center">
|
||||
<AdminHelpText
|
||||
helpId="play-max-bet"
|
||||
helpText={t("play.help.fields.maxBet", { ns: "config" })}
|
||||
helpAriaLabel={t("play.help.aria", {
|
||||
ns: "config",
|
||||
field: t("play.table.maxBet", { ns: "config" }),
|
||||
})}
|
||||
className="justify-center"
|
||||
>
|
||||
{t("play.table.maxBet", { ns: "config" })}
|
||||
</AdminHelpText>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { toast } from "sonner";
|
||||
@@ -32,7 +31,6 @@ import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||
import {
|
||||
PRD_DRAW_REOPEN_MANAGE,
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
} from "./draw-prd";
|
||||
|
||||
type ScheduleStep = {
|
||||
@@ -56,17 +54,23 @@ function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
function formatRemaining(target: string | null | undefined, now: number): string {
|
||||
const remainingSeconds = Math.max(0, Math.ceil((Date.parse(target ?? "") - now) / 1000));
|
||||
const minutes = Math.floor(remainingSeconds / 60);
|
||||
const seconds = remainingSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function DrawDetailConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const { draw: data, loading, error, refresh, drawId: idNum } = useDrawDetail();
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
||||
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
]);
|
||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
@@ -90,6 +94,23 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
void loadFinance();
|
||||
}, [loadFinance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !["cooldown", "settling"].includes(data.status)) return;
|
||||
|
||||
const pollId = window.setInterval(() => {
|
||||
void refresh();
|
||||
void loadFinance();
|
||||
}, 5_000);
|
||||
|
||||
return () => window.clearInterval(pollId);
|
||||
}, [data, loadFinance, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.status !== "cooldown") return;
|
||||
const timerId = window.setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [data?.status]);
|
||||
|
||||
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
||||
if (!Number.isFinite(idNum)) return;
|
||||
setActing(name);
|
||||
@@ -127,7 +148,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
type ActionDef = {
|
||||
key: string;
|
||||
label: string;
|
||||
variant: "outline" | "destructive";
|
||||
variant: "default" | "outline" | "destructive";
|
||||
enabled: boolean;
|
||||
onConfirm: () => Promise<void>;
|
||||
confirmTitle: string;
|
||||
@@ -169,6 +190,22 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
);
|
||||
}
|
||||
|
||||
if (canRunSettlement) {
|
||||
defs.push({
|
||||
key: "settlement",
|
||||
label: data.status === "cooldown" ? t("settleEarly") : t("retrySettlement"),
|
||||
variant: "default",
|
||||
enabled: ["cooldown", "settling"].includes(data.status),
|
||||
onConfirm: async () => { await postAdminRunDrawSettlement(idNum); },
|
||||
confirmTitle: data.status === "cooldown"
|
||||
? t("confirm.settleEarlyTitle")
|
||||
: t("confirm.retrySettlementTitle"),
|
||||
confirmDescription: data.status === "cooldown"
|
||||
? t("confirm.settleEarlyDescription")
|
||||
: t("confirm.retrySettlementDescription"),
|
||||
});
|
||||
}
|
||||
|
||||
if (canReopenDraw) {
|
||||
defs.push({
|
||||
key: "reopen",
|
||||
@@ -182,18 +219,6 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
});
|
||||
}
|
||||
|
||||
if (canRunSettlement) {
|
||||
defs.push({
|
||||
key: "settlement",
|
||||
label: t("runSettlement"),
|
||||
variant: "outline",
|
||||
enabled: data.status === "settling",
|
||||
onConfirm: async () => { await postAdminRunDrawSettlement(idNum); },
|
||||
confirmTitle: t("confirm.runSettlementTitle"),
|
||||
confirmDescription: t("confirm.runSettlementDescription"),
|
||||
});
|
||||
}
|
||||
|
||||
return defs.filter((d) => d.enabled);
|
||||
}, [canManageDraw, canReopenDraw, canRunSettlement, data, idNum, t]);
|
||||
|
||||
@@ -208,11 +233,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
const batch = data.result_batch_counts;
|
||||
const pendingReview = batch.pending_review ?? 0;
|
||||
const totalBatches = batch.total ?? batch.published;
|
||||
const financeCurrency = financeSummary?.currency_code ?? "NPR";
|
||||
const hasResultActivity = totalBatches > 0 || pendingReview > 0 || batch.published > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -256,68 +277,45 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
|
||||
<ScheduleTimeline steps={scheduleSteps} />
|
||||
|
||||
{hasResultActivity ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{canManageDraw && totalBatches > 0 ? (
|
||||
<span className="rounded-md bg-muted px-2 py-1 tabular-nums">
|
||||
{t("batchSummaryTotal", { count: totalBatches })}
|
||||
<section className="rounded-lg border border-blue-200 bg-blue-50/70 px-3 py-3 text-sm dark:border-blue-900 dark:bg-blue-950/20">
|
||||
<p className="font-semibold">{t("lifecycle.currentStage", { status: t(`statusOptions.${data.status}`) })}</p>
|
||||
<p className="mt-1 text-muted-foreground">{t(`lifecycle.${data.status}`)}</p>
|
||||
{data.status === "cooldown" ? (
|
||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 font-medium">
|
||||
<span>{t("automaticSettlementAt", { time: formatDt(data.cooling_end_time) })}</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{t("cooldownRemaining", { time: formatRemaining(data.cooling_end_time, now) })}
|
||||
</span>
|
||||
) : null}
|
||||
{canManageDraw && pendingReview > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
className="rounded-md bg-amber-500/15 px-2 py-1 font-medium text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t("batchSummaryPending", { count: pendingReview })}
|
||||
</Link>
|
||||
) : null}
|
||||
{batch.published > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
className="rounded-md bg-emerald-500/15 px-2 py-1 font-medium text-emerald-800 dark:text-emerald-200"
|
||||
>
|
||||
{t("batchSummaryPublished", { count: batch.published })}
|
||||
</Link>
|
||||
) : null}
|
||||
{data.capabilities?.can_view_draw_finance !== false ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/finance`}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("viewFinance")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : canManageDraw ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("goToReviewTab")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{availableActions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 border-t border-border/60 pt-4">
|
||||
{availableActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={action.variant}
|
||||
disabled={acting !== null}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: action.confirmTitle,
|
||||
description: action.confirmDescription,
|
||||
confirmVariant: action.confirmVariant,
|
||||
onConfirm: () => runAction(action.label, action.onConfirm),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === action.label ? t("processing") : action.label}
|
||||
</Button>
|
||||
))}
|
||||
<div className="space-y-2 border-t border-border/60 pt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={action.variant}
|
||||
disabled={acting !== null}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: action.confirmTitle,
|
||||
description: action.confirmDescription,
|
||||
confirmVariant: action.confirmVariant,
|
||||
onConfirm: () => runAction(action.label, action.onConfirm),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === action.label ? t("processing") : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{data.status === "cooldown" && canReopenDraw ? (
|
||||
<p className="text-xs text-muted-foreground">{t("reopenUsageHint")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
|
||||
@@ -37,18 +37,17 @@ import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
|
||||
import { settlementBatchStatusLabel } from "./draw-display";
|
||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
||||
import { useDrawDetail } from "./draw-detail-context";
|
||||
import { PRD_PAYOUT_MANAGE } from "./draw-prd";
|
||||
|
||||
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "settlement", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "settlement", "common"]);
|
||||
useAdminCurrencyCatalog();
|
||||
const idNum = Number(drawId);
|
||||
const { draw, refresh: refreshDraw } = useDrawDetail();
|
||||
const profile = useAdminProfile();
|
||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
]);
|
||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
||||
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const exportLabels = useExportLabels("drawFinance", { drawNo: data?.draw_no ?? drawId });
|
||||
@@ -80,8 +79,8 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
setSettling(true);
|
||||
try {
|
||||
const res = await postAdminRunDrawSettlement(idNum);
|
||||
toast.success(res.ran ? t("runSettlement") : t("status"));
|
||||
await load();
|
||||
toast.success(res.cooldown_skipped ? t("settleEarlySuccess") : t("retrySettlementSuccess"));
|
||||
await Promise.all([load(), refreshDraw()]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: t("runSettlement") }));
|
||||
} finally {
|
||||
@@ -93,6 +92,18 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const status = draw?.status ?? data?.draw_status;
|
||||
if (!status || !["cooldown", "settling"].includes(status)) return;
|
||||
|
||||
const pollId = window.setInterval(() => {
|
||||
void load();
|
||||
void refreshDraw();
|
||||
}, 5_000);
|
||||
|
||||
return () => window.clearInterval(pollId);
|
||||
}, [data?.draw_status, draw?.status, load, refreshDraw]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
@@ -106,6 +117,8 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
|
||||
const currencyCode = data.currency_code ?? "NPR";
|
||||
const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
|
||||
const drawStatus = draw?.status ?? data.draw_status;
|
||||
const settlementActionLabel = drawStatus === "cooldown" ? t("settleEarly") : t("retrySettlement");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -132,31 +145,38 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canRunSettlement || settling || data.draw_status !== "settling"}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.runSettlementTitle"),
|
||||
description: t("confirm.runSettlementDescription"),
|
||||
onConfirm: () => runSettlement(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{settling ? t("processing") : t("runSettlement")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/admin/settlement-batches"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("settlementBatchList")}
|
||||
</Link>
|
||||
{canRunSettlement && ["cooldown", "settling"].includes(drawStatus) ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={settling}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: drawStatus === "cooldown"
|
||||
? t("confirm.settleEarlyTitle")
|
||||
: t("confirm.retrySettlementTitle"),
|
||||
description: drawStatus === "cooldown"
|
||||
? t("confirm.settleEarlyDescription")
|
||||
: t("confirm.retrySettlementDescription"),
|
||||
onConfirm: () => runSettlement(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{settling ? t("processing") : settlementActionLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="self-center text-xs text-muted-foreground">
|
||||
{canRunSettlement
|
||||
? t("settlementUnavailable", { status: t(`statusOptions.${drawStatus}`) })
|
||||
: t("settlementManagePermissionRequired")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<div className="border-b border-border/60 px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold">{t("relatedSettlementBatches")}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("settlementBatchesHint")}</p>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{data.settlement_batches.length === 0 ? (
|
||||
@@ -174,6 +194,9 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("provider")}</TableHead>
|
||||
<TableHead className="text-center">{t("resultVersion")}</TableHead>
|
||||
<TableHead className="text-center">{t("settleVersion")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-center">{t("ticketCount")}</TableHead>
|
||||
<TableHead className="text-center">{t("winCount")}</TableHead>
|
||||
@@ -185,7 +208,24 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
<TableBody>
|
||||
{data.settlement_batches.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="font-mono text-xs">{b.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link
|
||||
href={`/admin/settlement-batches/${b.id}/details`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{b.id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{b.provider_name || b.provider_code || "—"}
|
||||
{b.provider_code ? (
|
||||
<span className="ml-1 font-mono text-muted-foreground">({b.provider_code})</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs">
|
||||
{b.result_version == null ? "—" : `v${b.result_version}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs">v{b.settle_version}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={b.status}>
|
||||
{settlementBatchStatusLabel(b.status, t)}
|
||||
|
||||
@@ -58,6 +58,10 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t("officialResultsTitle")}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("officialResultsHint")}</p>
|
||||
</div>
|
||||
{published.length === 0 ? (
|
||||
<AdminNoResourceState message={t("noPublishedBatch")} />
|
||||
) : (
|
||||
|
||||
@@ -181,8 +181,12 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
const canEnterManualResults =
|
||||
canManageDraw && ["closed", "review"].includes(data.draw_status);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pending.length > 0 || canEnterManualResults ? (
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
|
||||
@@ -244,8 +248,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManageDraw ? (
|
||||
{canEnterManualResults ? (
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
@@ -330,7 +335,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={savingManual || !manualProviderCode || !["closed", "review"].includes(data.draw_status)}
|
||||
disabled={savingManual || !manualProviderCode}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.saveManualDraftTitle"),
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { PRD_RISK_ACCESS_ANY, PRD_TICKETS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
const segments = [
|
||||
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false },
|
||||
{ suffix: "/results", key: "results", label: "subnav.results", requiresManage: false },
|
||||
{ suffix: "/tickets", key: "tickets", label: "subnav.tickets", requiresManage: false },
|
||||
{ suffix: "/finance", key: "finance", label: "subnav.finance", requiresManage: false },
|
||||
{
|
||||
suffix: "/risk/pools",
|
||||
@@ -49,6 +50,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const canViewDraw = canViewDrawResults(perms);
|
||||
const canViewFinance = canViewDrawFinance(perms);
|
||||
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
|
||||
const canViewTickets = adminHasAnyPermission(perms, [...PRD_TICKETS_ACCESS_ANY]);
|
||||
|
||||
const visibleSegments = useMemo(
|
||||
() =>
|
||||
@@ -59,13 +61,16 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
||||
if (segment.key === "finance" && !canViewFinance) {
|
||||
return false;
|
||||
}
|
||||
if (segment.key === "tickets" && !canViewTickets) {
|
||||
return false;
|
||||
}
|
||||
if ("requiresRisk" in segment && segment.requiresRisk && !canViewRisk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[canViewDraw, canViewFinance, canViewRisk],
|
||||
[canViewDraw, canViewFinance, canViewRisk, canViewTickets],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
22
src/modules/draws/draw-tickets-console.tsx
Normal file
22
src/modules/draws/draw-tickets-console.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||
import { PlayerTicketsConsole } from "@/modules/tickets/player-tickets-console";
|
||||
|
||||
export function DrawTicketsConsole(): React.ReactElement {
|
||||
const { draw, loading, error } = useDrawDetail();
|
||||
|
||||
if (loading && !draw) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
if (!draw) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return <PlayerTicketsConsole fixedDrawNo={draw.draw_no} />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageGuideDialog } from "@/components/admin/admin-page-guide-dialog";
|
||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||
@@ -37,16 +38,52 @@ export function JackpotConfigScreen() {
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-4">
|
||||
<AdminSubnav aria-label={t("pageTabs")}>
|
||||
<AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
|
||||
{t("tabConfig")}
|
||||
</AdminSubnavButton>
|
||||
<AdminSubnavButton active={tab === "records"} onClick={() => switchTab("records")}>
|
||||
{t("tabRecords")}
|
||||
</AdminSubnavButton>
|
||||
</AdminSubnav>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<AdminSubnav aria-label={t("pageTabs")}>
|
||||
<AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
|
||||
{t("tabConfig")}
|
||||
</AdminSubnavButton>
|
||||
<AdminSubnavButton active={tab === "records"} onClick={() => switchTab("records")}>
|
||||
{t("tabRecords")}
|
||||
</AdminSubnavButton>
|
||||
</AdminSubnav>
|
||||
<AdminPageGuideDialog
|
||||
triggerLabel={t("help.openGuide")}
|
||||
title={t("help.guideTitle")}
|
||||
description={t("help.guideDescription")}
|
||||
sections={[
|
||||
{
|
||||
title: t("help.guide.settingsTitle"),
|
||||
description: t("help.guide.settingsDescription"),
|
||||
items: [
|
||||
{ label: t("currentAmount"), description: t("help.fields.currentAmount") },
|
||||
{ label: t("status"), description: t("help.fields.status") },
|
||||
{ label: t("contributionRate"), description: t("help.fields.contributionRate") },
|
||||
{ label: t("minBetAmount"), description: t("help.fields.minBetAmount") },
|
||||
{ label: t("triggerThreshold"), description: t("help.fields.triggerThreshold") },
|
||||
{ label: t("payoutRate"), description: t("help.fields.payoutRate") },
|
||||
{ label: t("forceTriggerGap"), description: t("help.fields.forceTriggerGap") },
|
||||
{ label: t("comboTriggerPlays"), description: t("help.fields.comboTriggerPlays") },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("help.guide.actionsTitle"),
|
||||
description: t("help.guide.actionsDescription"),
|
||||
items: [
|
||||
{ label: t("balanceAdjustmentTitle"), description: t("help.fields.balanceAdjustment") },
|
||||
{ label: t("manualBurst"), description: t("help.fields.manualBurst") },
|
||||
{ label: t("tabRecords"), description: t("help.fields.records") },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("help.guide.effectiveTitle"),
|
||||
description: t("help.guide.effectiveDescription"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === "config" ? <JackpotPoolsConsole embedded /> : <JackpotRecordsConsole embedded />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,17 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
|
||||
import {
|
||||
AdminFieldLabel,
|
||||
AdminHelpIcon,
|
||||
AdminHelpText,
|
||||
} from "@/components/admin/admin-field-label";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -273,9 +277,14 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`status-${p.id}`} className="text-sm text-muted-foreground">
|
||||
<AdminFieldLabel
|
||||
htmlFor={`status-${p.id}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
helpText={t("help.fields.status")}
|
||||
helpAriaLabel={t("help.aria", { field: t("status") })}
|
||||
>
|
||||
{t("status")}
|
||||
</Label>
|
||||
</AdminFieldLabel>
|
||||
<Switch
|
||||
id={`status-${p.id}`}
|
||||
checked={statusOn}
|
||||
@@ -304,11 +313,27 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="mb-4 text-2xl font-semibold tabular-nums tracking-tight">{currentAmount}</p>
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<AdminHelpText
|
||||
helpId={`current-amount-${p.id}`}
|
||||
helpText={t("help.fields.currentAmount")}
|
||||
helpAriaLabel={t("help.aria", { field: t("currentAmount") })}
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{t("currentAmount")}
|
||||
</AdminHelpText>
|
||||
<p className="text-2xl font-semibold tabular-nums tracking-tight">{currentAmount}</p>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!canManageJackpot} className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<fieldset className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`cr-${p.id}`}
|
||||
helpText={t("help.fields.contributionRate")}
|
||||
helpAriaLabel={t("help.aria", { field: t("contributionRate") })}
|
||||
>
|
||||
{t("contributionRate")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id={`cr-${p.id}`}
|
||||
type="number"
|
||||
@@ -316,21 +341,35 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="font-mono"
|
||||
disabled={!canManageJackpot}
|
||||
value={d.contribution_rate}
|
||||
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`th-${p.id}`}
|
||||
helpText={t("help.fields.triggerThreshold")}
|
||||
helpAriaLabel={t("help.aria", { field: t("triggerThreshold") })}
|
||||
>
|
||||
{t("triggerThreshold")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id={`th-${p.id}`}
|
||||
className="font-mono"
|
||||
disabled={!canManageJackpot}
|
||||
value={d.trigger_threshold}
|
||||
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`pr-${p.id}`}
|
||||
helpText={t("help.fields.payoutRate")}
|
||||
helpAriaLabel={t("help.aria", { field: t("payoutRate") })}
|
||||
>
|
||||
{t("payoutRate")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id={`pr-${p.id}`}
|
||||
type="number"
|
||||
@@ -338,31 +377,53 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="font-mono"
|
||||
disabled={!canManageJackpot}
|
||||
value={d.payout_rate}
|
||||
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`min-${p.id}`}
|
||||
helpText={t("help.fields.minBetAmount")}
|
||||
helpAriaLabel={t("help.aria", { field: t("minBetAmount") })}
|
||||
>
|
||||
{t("minBetAmount")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id={`min-${p.id}`}
|
||||
className="font-mono"
|
||||
disabled={!canManageJackpot}
|
||||
value={d.min_bet_amount}
|
||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2 lg:col-span-2">
|
||||
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`gap-${p.id}`}
|
||||
helpText={t("help.fields.forceTriggerGap")}
|
||||
helpAriaLabel={t("help.aria", { field: t("forceTriggerGap") })}
|
||||
>
|
||||
{t("forceTriggerGap")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id={`gap-${p.id}`}
|
||||
className="font-mono"
|
||||
disabled={!canManageJackpot}
|
||||
value={d.force_trigger_draw_gap}
|
||||
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2 lg:col-span-3">
|
||||
<div>
|
||||
<Label id={`combo-label-${p.id}`}>{t("comboTriggerPlays")}</Label>
|
||||
<AdminHelpText
|
||||
helpId={`combo-label-${p.id}`}
|
||||
helpText={t("help.fields.comboTriggerPlays")}
|
||||
helpAriaLabel={t("help.aria", { field: t("comboTriggerPlays") })}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
<span id={`combo-label-${p.id}`}>{t("comboTriggerPlays")}</span>
|
||||
</AdminHelpText>
|
||||
<p className="text-xs text-muted-foreground">{t("comboTriggerPlaysHint")}</p>
|
||||
</div>
|
||||
{playOptions.length === 0 ? (
|
||||
@@ -417,17 +478,30 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
|
||||
{canManageJackpot ? (
|
||||
<div className="mt-4 border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
onClick={() => toggleAdjustment(p.id, !adjustmentOpen)}
|
||||
>
|
||||
{t("balanceAdjustmentTitle")}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
onClick={() => toggleAdjustment(p.id, !adjustmentOpen)}
|
||||
>
|
||||
{t("balanceAdjustmentTitle")}
|
||||
</button>
|
||||
<AdminHelpIcon
|
||||
helpId={`balance-adjustment-${p.id}`}
|
||||
helpText={t("help.fields.balanceAdjustment")}
|
||||
helpAriaLabel={t("help.aria", { field: t("balanceAdjustmentTitle") })}
|
||||
/>
|
||||
</div>
|
||||
{adjustmentOpen ? (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("adjustmentDirection")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`adj-direction-${p.id}`}
|
||||
helpText={t("help.fields.adjustmentDirection")}
|
||||
helpAriaLabel={t("help.aria", { field: t("adjustmentDirection") })}
|
||||
>
|
||||
{t("adjustmentDirection")}
|
||||
</AdminFieldLabel>
|
||||
<Select
|
||||
value={adj.direction}
|
||||
onValueChange={(value: "increase" | "decrease" | null) => {
|
||||
@@ -435,7 +509,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
updateAdjustmentDraft(p.id, { direction: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0">
|
||||
<SelectTrigger id={`adj-direction-${p.id}`} className="w-full min-w-0">
|
||||
<SelectValue>
|
||||
{(value) =>
|
||||
value === "increase"
|
||||
@@ -453,7 +527,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`adj-amt-${p.id}`}
|
||||
helpText={t("help.fields.adjustmentAmount")}
|
||||
helpAriaLabel={t("help.aria", { field: t("adjustmentAmount") })}
|
||||
>
|
||||
{t("adjustmentAmount")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id={`adj-amt-${p.id}`}
|
||||
className="font-mono"
|
||||
@@ -462,7 +542,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`adj-reason-${p.id}`}
|
||||
helpText={t("help.fields.adjustmentReason")}
|
||||
helpAriaLabel={t("help.aria", { field: t("adjustmentReason") })}
|
||||
>
|
||||
{t("adjustmentReason")}
|
||||
</AdminFieldLabel>
|
||||
<Textarea
|
||||
id={`adj-reason-${p.id}`}
|
||||
rows={2}
|
||||
@@ -512,7 +598,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
{canManualBurst ? (
|
||||
<div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
|
||||
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
|
||||
<AdminFieldLabel
|
||||
htmlFor={`burst-draw-${p.id}`}
|
||||
helpText={t("help.fields.manualBurst")}
|
||||
helpAriaLabel={t("help.aria", { field: t("manualBurstDrawId") })}
|
||||
>
|
||||
{t("manualBurstDrawId")}
|
||||
</AdminFieldLabel>
|
||||
<Input
|
||||
id={`burst-draw-${p.id}`}
|
||||
className="font-mono"
|
||||
|
||||
@@ -256,7 +256,7 @@ export function ReportsConsole({ initialCategory = "profit" }: ReportsConsolePro
|
||||
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
|
||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||
setDisplayCurrency(currencyCode);
|
||||
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
|
||||
const houseGross = payload.items.reduce((sum, item) => sum + item.net_win_loss_minor, 0);
|
||||
setResult({
|
||||
key: "player_win_loss",
|
||||
raw: payload.items,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
postAdminRiskPoolRecover,
|
||||
} from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -189,6 +190,12 @@ export function RiskPoolsConsole({
|
||||
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50/70 px-3 py-2.5 text-sm text-blue-950 dark:border-blue-900 dark:bg-blue-950/20 dark:text-blue-100">
|
||||
<p className="font-medium">{t("poolExplanationTitle")}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-blue-900/80 dark:text-blue-100/75">
|
||||
{t("poolExplanation")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-number" className="sm:w-20 sm:shrink-0">
|
||||
@@ -291,6 +298,16 @@ export function RiskPoolsConsole({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={8} /> : null}
|
||||
{!loading && data?.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow
|
||||
colSpan={8}
|
||||
message={filter === "sold_out"
|
||||
? t("emptySoldOutPools")
|
||||
: filter === "high_risk"
|
||||
? t("emptyHighRiskPools")
|
||||
: t("emptyPools")}
|
||||
/>
|
||||
) : null}
|
||||
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
|
||||
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
|
||||
const acting = actingKey === `${row.provider_code}:${row.normalized_number}`;
|
||||
|
||||
@@ -139,7 +139,11 @@ function TicketFilterField({
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
export function PlayerTicketsConsole({
|
||||
fixedDrawNo,
|
||||
}: {
|
||||
fixedDrawNo?: string;
|
||||
} = {}): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const tRef = useTranslationRef(["tickets", "common"]);
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
@@ -147,7 +151,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const searchParams = useSearchParams();
|
||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
||||
const drawNoFromUrl = fixedDrawNo ?? (searchParams.get("draw_no") ?? "").trim();
|
||||
const numberKeywordFromUrl = (searchParams.get("number") ?? "").trim();
|
||||
const providerCodeFromUrl = (searchParams.get("provider_code") ?? "").trim().toUpperCase();
|
||||
const initialFilters: TicketFilters = {
|
||||
@@ -184,7 +188,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
page,
|
||||
per_page: perPage,
|
||||
...query,
|
||||
draw_no: applied.drawNo.trim() || undefined,
|
||||
draw_no: fixedDrawNo ?? (applied.drawNo.trim() || undefined),
|
||||
provider_code: applied.providerCode.trim().toUpperCase() || undefined,
|
||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||
number: applied.numberKeyword.trim() || undefined,
|
||||
@@ -198,7 +202,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applied, page, perPage, tRef]);
|
||||
}, [applied, fixedDrawNo, page, perPage, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
@@ -221,7 +225,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
setApplied({
|
||||
...draft,
|
||||
playerQuery: draft.playerQuery.trim(),
|
||||
drawNo: draft.drawNo.trim(),
|
||||
drawNo: fixedDrawNo ?? draft.drawNo.trim(),
|
||||
providerCode: draft.providerCode.trim().toUpperCase(),
|
||||
numberKeyword: draft.numberKeyword.trim(),
|
||||
});
|
||||
@@ -229,8 +233,9 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyTicketFilters);
|
||||
setApplied(emptyTicketFilters);
|
||||
const reset = { ...emptyTicketFilters, drawNo: fixedDrawNo ?? "" };
|
||||
setDraft(reset);
|
||||
setApplied(reset);
|
||||
setErr(null);
|
||||
setPage(1);
|
||||
};
|
||||
@@ -268,7 +273,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-draw" label={t("drawNoOptional")}>
|
||||
{!fixedDrawNo ? <TicketFilterField id="pt-draw" label={t("drawNoOptional")}>
|
||||
<Input
|
||||
id="pt-draw"
|
||||
className="h-8 w-full font-mono"
|
||||
@@ -281,7 +286,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
</TicketFilterField> : null}
|
||||
<TicketFilterField id="pt-number" label={t("numberKeyword")}>
|
||||
<Input
|
||||
id="pt-number"
|
||||
@@ -328,7 +333,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-date-range" label={t("placedDateRange")}>
|
||||
{!fixedDrawNo ? <TicketFilterField id="pt-date-range" label={t("placedDateRange")}>
|
||||
<AdminDateRangeField
|
||||
id="pt-date-range"
|
||||
from={draft.startDate}
|
||||
@@ -341,7 +346,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
</TicketFilterField> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
|
||||
|
||||
@@ -39,7 +39,7 @@ export function WalletCellMonoId({
|
||||
type="button"
|
||||
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
|
||||
title={value}
|
||||
aria-label={copyHint ?? t("copyTxnNo")}
|
||||
aria-label={`${copyHint ?? t("copyTxnNo")} ${value}`}
|
||||
onClick={(e) => void copy(e)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{value}</span>
|
||||
@@ -49,4 +49,4 @@ export function WalletCellMonoId({
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +408,13 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell>{row.direction}</TableCell>
|
||||
<TableCell>
|
||||
{row.direction === "in"
|
||||
? t("in")
|
||||
: row.direction === "out"
|
||||
? t("out")
|
||||
: row.direction}
|
||||
</TableCell>
|
||||
<TableCell className={adminMoneyCellClassName("text-right")}>
|
||||
<AdminTableMoney>
|
||||
{formatAdminMinorUnits(row.amount, row.currency_code)}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
||||
set({ adminProfile: result.admin });
|
||||
} catch (err) {
|
||||
if (isAdminAuthRejected(err)) {
|
||||
handleAdminAuthRejected();
|
||||
handleAdminAuthRejected(err);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export type AdminDrawFinanceSettlementBatchRow = {
|
||||
id: number;
|
||||
provider_code: string | null;
|
||||
provider_name: string | null;
|
||||
result_version: number | null;
|
||||
settle_version: number;
|
||||
status: string;
|
||||
total_ticket_count: number;
|
||||
total_win_count: number;
|
||||
|
||||
@@ -117,6 +117,8 @@ export type AdminSettlementRunResponse = {
|
||||
draw_no: string;
|
||||
status: string;
|
||||
settle_version: number;
|
||||
cooldown_skipped: boolean;
|
||||
cooling_end_time: string | null;
|
||||
};
|
||||
|
||||
export type AdminSettlementWorkflowResponse = {
|
||||
|
||||
Reference in New Issue
Block a user