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 = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
...(allowedDevOrigins.length > 0 ? { allowedDevOrigins } : {}),
|
...(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: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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 静态资源已复制完毕'",
|
"build": "next build && cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && echo '✅ standalone 静态资源已复制完毕'",
|
||||||
"start": "next start --port 3801",
|
"start": "next start --port 3801",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ import type { Metadata } from "next";
|
|||||||
|
|
||||||
export const metadata: Metadata = buildPageMetadata("draws", "drawDetail");
|
export const metadata: Metadata = buildPageMetadata("draws", "drawDetail");
|
||||||
|
|
||||||
export default async function AdminDrawDetailPage(props: {
|
export default function AdminDrawDetailPage() {
|
||||||
params: Promise<{ drawId: string }>;
|
|
||||||
}) {
|
|
||||||
const { drawId } = await props.params;
|
|
||||||
return (
|
return (
|
||||||
<AdminPermissionGate requiredAny={PRD_DRAW_ACCESS_ANY}>
|
<AdminPermissionGate requiredAny={PRD_DRAW_ACCESS_ANY}>
|
||||||
<DrawDetailConsole drawId={drawId} />
|
<DrawDetailConsole />
|
||||||
</AdminPermissionGate>
|
</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> = {
|
const AGENT_ROUTE_LABELS: Record<string, string> = {
|
||||||
list: "agents.listTitle",
|
list: "listTitle",
|
||||||
provision: "agents.subnav.provision",
|
provision: "subnav.provision",
|
||||||
"settlement-bills": "settlementCenter.title",
|
"settlement-bills": "settlementCenter.title",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type AdminFieldLabelProps = {
|
type AdminFieldLabelProps = {
|
||||||
htmlFor: string;
|
htmlFor: string;
|
||||||
@@ -19,6 +20,64 @@ type AdminFieldLabelProps = {
|
|||||||
className?: string;
|
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({
|
export function AdminFieldLabel({
|
||||||
htmlFor,
|
htmlFor,
|
||||||
children,
|
children,
|
||||||
@@ -39,29 +98,11 @@ export function AdminFieldLabel({
|
|||||||
<Label htmlFor={htmlFor} className={className}>
|
<Label htmlFor={htmlFor} className={className}>
|
||||||
{children}
|
{children}
|
||||||
</Label>
|
</Label>
|
||||||
<Tooltip>
|
<AdminHelpIcon
|
||||||
<TooltipTrigger
|
helpText={helpText}
|
||||||
render={
|
helpAriaLabel={helpAriaLabel ?? String(children)}
|
||||||
<Button
|
helpId={htmlFor}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { cn } from "@/lib/utils";
|
|||||||
export function AdminLanguageSwitcher() {
|
export function AdminLanguageSwitcher() {
|
||||||
const { i18n, t } = useTranslation("common");
|
const { i18n, t } = useTranslation("common");
|
||||||
// Match SSR: do not read document/localStorage until after mount.
|
// Match SSR: do not read document/localStorage until after mount.
|
||||||
const [locale, setLocale] = useState<AdminApiLocale>("en");
|
const [locale, setLocale] = useState<AdminApiLocale>("zh");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncLocale = () => {
|
const syncLocale = () => {
|
||||||
@@ -44,8 +44,9 @@ export function AdminLanguageSwitcher() {
|
|||||||
applyAdminUiLocale(next);
|
applyAdminUiLocale(next);
|
||||||
await i18n.changeLanguage(next);
|
await i18n.changeLanguage(next);
|
||||||
setLocale(next);
|
setLocale(next);
|
||||||
|
const nextT = i18n.getFixedT(next, "common");
|
||||||
toast.success(
|
toast.success(
|
||||||
t("language.changed", {
|
nextT("language.changed", {
|
||||||
language: ADMIN_LOCALE_LABELS[next],
|
language: ADMIN_LOCALE_LABELS[next],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function AdminNoResourceState({
|
|||||||
src={NOTDATA_IMAGE}
|
src={NOTDATA_IMAGE}
|
||||||
alt=""
|
alt=""
|
||||||
width={compact ? 120 : 160}
|
width={compact ? 120 : 160}
|
||||||
height={compact ? 120 : 160}
|
height={compact ? 80 : 107}
|
||||||
style={{ width: "auto", height: "auto" }}
|
style={{ width: "auto", height: "auto" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-auto w-auto object-contain self-center object-center -mt-4",
|
"h-auto w-auto object-contain self-center object-center -mt-4",
|
||||||
|
|||||||
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
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="N lotto"
|
alt="N lotto"
|
||||||
width={160}
|
width={154}
|
||||||
height={40}
|
height={56}
|
||||||
priority
|
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>
|
</div>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
@@ -50,6 +50,7 @@ function AdminSidebarSkeleton(): ReactElement {
|
|||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes="var(--sidebar-width)"
|
sizes="var(--sidebar-width)"
|
||||||
|
loading="eager"
|
||||||
className="h-full w-full object-cover object-bottom"
|
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" />
|
<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
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="N lotto"
|
alt="N lotto"
|
||||||
width={160}
|
width={154}
|
||||||
height={40}
|
height={56}
|
||||||
priority
|
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>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -120,6 +121,7 @@ export function AdminAppSidebar() {
|
|||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes="var(--sidebar-width)"
|
sizes="var(--sidebar-width)"
|
||||||
|
loading="eager"
|
||||||
className="h-full w-full object-cover object-bottom"
|
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" />
|
<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";
|
type GateStatus = "pending" | "authed" | "guest";
|
||||||
|
|
||||||
|
const ADMIN_SESSION_CHECK_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell 路由守卫:无有效 HttpOnly Cookie 会话或 `/auth/me` 校验失败时跳转登录页。
|
* Shell 路由守卫:无有效 HttpOnly Cookie 会话或 `/auth/me` 校验失败时跳转登录页。
|
||||||
*/
|
*/
|
||||||
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bearerToken = useAdminSessionStore((s) => s.bearerToken);
|
const bearerToken = useAdminSessionStore((s) => s.bearerToken);
|
||||||
|
const refreshAdminProfile = useAdminSessionStore(
|
||||||
|
(s) => s.refreshAdminProfile,
|
||||||
|
);
|
||||||
const [status, setStatus] = useState<GateStatus>("pending");
|
const [status, setStatus] = useState<GateStatus>("pending");
|
||||||
|
|
||||||
const setShellAuthPending = useAdminSessionStore((s) => s.setShellAuthPending);
|
const setShellAuthPending = useAdminSessionStore((s) => s.setShellAuthPending);
|
||||||
@@ -63,6 +68,28 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
|||||||
}
|
}
|
||||||
}, [status, router]);
|
}, [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") {
|
if (status === "pending") {
|
||||||
return <AdminAuthCheckingScreen variant="shell" />;
|
return <AdminAuthCheckingScreen variant="shell" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,29 @@ export function LoginForm() {
|
|||||||
return;
|
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);
|
setCheckingSession(false);
|
||||||
void loadCaptcha();
|
void loadCaptcha();
|
||||||
}
|
}
|
||||||
@@ -85,7 +108,7 @@ export function LoginForm() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [loadCaptcha, router]);
|
}, [loadCaptcha, router, tRef]);
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import i18n, { ensureAdminI18nReady, normalizeAdminLanguage } from "@/i18n";
|
import i18n, { ensureAdminI18nReady, normalizeAdminLanguage } from "@/i18n";
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
@@ -13,9 +14,15 @@ type ProvidersProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AdminSessionHydrator() {
|
const startupListeners = new Set<() => void>();
|
||||||
useEffect(() => {
|
let startupReady = false;
|
||||||
void (async () => {
|
let startupPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function ensureAdminStartup(): void {
|
||||||
|
if (startupPromise) return;
|
||||||
|
|
||||||
|
startupPromise = (async () => {
|
||||||
|
try {
|
||||||
await ensureAdminI18nReady();
|
await ensureAdminI18nReady();
|
||||||
|
|
||||||
const locale = hydrateAdminUiLocale();
|
const locale = hydrateAdminUiLocale();
|
||||||
@@ -23,19 +30,56 @@ function AdminSessionHydrator() {
|
|||||||
if (locale && locale !== current) {
|
if (locale && locale !== current) {
|
||||||
await i18n.changeLanguage(locale);
|
await i18n.changeLanguage(locale);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
useAdminSessionStore.getState().rehydrate();
|
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) {
|
export function Providers({ children }: ProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<AdminSessionHydrator />
|
<AdminStartupGate>{children}</AdminStartupGate>
|
||||||
{children}
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,13 +14,25 @@ export function useAsyncEffect(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cleanup: void | (() => void);
|
let cleanup: void | (() => void);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void Promise.resolve(factoryRef.current()).then((result) => {
|
void Promise.resolve(factoryRef.current()).then((result) => {
|
||||||
|
if (cancelled) {
|
||||||
|
result?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
cleanup = result;
|
cleanup = result;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
cleanup?.();
|
cleanup?.();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ if (!i18n.isInitialized) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
syncAdminLanguage(initialLanguage);
|
|
||||||
i18n.on("languageChanged", (lang) => {
|
i18n.on("languageChanged", (lang) => {
|
||||||
syncAdminLanguage(normalizeAdminLanguage(lang));
|
syncAdminLanguage(normalizeAdminLanguage(lang));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
"draws": "Draws",
|
"draws": "Draws",
|
||||||
"rules_plays": "Play rules",
|
"rules_plays": "Play rules",
|
||||||
"rules_odds": "Odds & rebate",
|
"rules_odds": "Odds & rebate",
|
||||||
|
"bet_providers": "Bet providers",
|
||||||
"rules": "Betting rules",
|
"rules": "Betting rules",
|
||||||
"risk_cap": "Risk cap rules",
|
"risk_cap": "Risk cap rules",
|
||||||
"risk": "Risk center",
|
"risk": "Risk center",
|
||||||
@@ -212,7 +213,8 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"checking": "Checking sign-in status…",
|
"checking": "Checking sign-in status…",
|
||||||
"checkingShort": "Loading workspace…",
|
"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": {
|
"confirm": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@@ -282,6 +282,28 @@
|
|||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"all": "All"
|
"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": {
|
"play": {
|
||||||
@@ -389,6 +411,29 @@
|
|||||||
"toggleDisable": "Disable",
|
"toggleDisable": "Disable",
|
||||||
"toggleEnable": "Enable",
|
"toggleEnable": "Enable",
|
||||||
"toggleInstantFailed": "Failed to apply play switch. Try again later.",
|
"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": {
|
"validation": {
|
||||||
"displayNameRequired": "Display name is required",
|
"displayNameRequired": "Display name is required",
|
||||||
"minMaxInvalid": "{{playCode}}: min bet cannot exceed max bet"
|
"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.",
|
"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.",
|
"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.",
|
"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."
|
"manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin."
|
||||||
},
|
},
|
||||||
"loadFailed": "Failed to load system settings",
|
"loadFailed": "Failed to load system settings",
|
||||||
|
|||||||
@@ -78,6 +78,8 @@
|
|||||||
"sequenceNo": "Sequence no.",
|
"sequenceNo": "Sequence no.",
|
||||||
"plannedDraw": "Planned draw",
|
"plannedDraw": "Planned draw",
|
||||||
"coolingEndTime": "Cooling ends at",
|
"coolingEndTime": "Cooling ends at",
|
||||||
|
"automaticSettlementAt": "Automatic settlement: {{time}}",
|
||||||
|
"cooldownRemaining": "Remaining {{time}}",
|
||||||
"resultSource": "Result source",
|
"resultSource": "Result source",
|
||||||
"resultSourceOptions": {
|
"resultSourceOptions": {
|
||||||
"rng": "RNG auto-generated",
|
"rng": "RNG auto-generated",
|
||||||
@@ -92,6 +94,7 @@
|
|||||||
"published": "Published"
|
"published": "Published"
|
||||||
},
|
},
|
||||||
"currentResultVersion": "Current result version",
|
"currentResultVersion": "Current result version",
|
||||||
|
"resultVersion": "Result version",
|
||||||
"settleVersion": "Settlement version",
|
"settleVersion": "Settlement version",
|
||||||
"isReopened": "Reopened",
|
"isReopened": "Reopened",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
@@ -109,8 +112,15 @@
|
|||||||
"rngDraw": "RNG draw",
|
"rngDraw": "RNG draw",
|
||||||
"rngAutoGenerate": "Auto draw",
|
"rngAutoGenerate": "Auto draw",
|
||||||
"reopen": "Reopen",
|
"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",
|
"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…",
|
"processing": "Processing…",
|
||||||
"actionSuccess": "{{name}} succeeded",
|
"actionSuccess": "{{name}} succeeded",
|
||||||
"actionFailed": "{{name}} failed",
|
"actionFailed": "{{name}} failed",
|
||||||
@@ -121,7 +131,8 @@
|
|||||||
"currentPayout": "Current payout total",
|
"currentPayout": "Current payout total",
|
||||||
"grossProfit": "Approx. gross profit",
|
"grossProfit": "Approx. gross profit",
|
||||||
"settlementBatchList": "Settlement records",
|
"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.",
|
"noSettlementBatches": "No settlement batch records.",
|
||||||
"ticketCount": "Tickets",
|
"ticketCount": "Tickets",
|
||||||
"winCount": "Wins",
|
"winCount": "Wins",
|
||||||
@@ -153,7 +164,7 @@
|
|||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"saveDraft": "Save draft",
|
"saveDraft": "Save draft",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"pendingBatches": "Pending publish",
|
"pendingBatches": "Results awaiting review (not visible to players)",
|
||||||
"noPendingBatches": "None",
|
"noPendingBatches": "None",
|
||||||
"batchId": "Batch",
|
"batchId": "Batch",
|
||||||
"numberCount": "Numbers",
|
"numberCount": "Numbers",
|
||||||
@@ -173,6 +184,8 @@
|
|||||||
"checkBeforePublish": "Please review the numbers below before publishing",
|
"checkBeforePublish": "Please review the numbers below before publishing",
|
||||||
"checkBeforePublishDesc": "Confirm the numbers are correct and click Publish.",
|
"checkBeforePublishDesc": "Confirm the numbers are correct and click Publish.",
|
||||||
"publishedView": "View published result",
|
"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",
|
"confirmPublish": "Confirm publish",
|
||||||
"submitting": "Submitting…",
|
"submitting": "Submitting…",
|
||||||
"publishSuccess": "Published · {{drawNo}} · status {{status}}",
|
"publishSuccess": "Published · {{drawNo}} · status {{status}}",
|
||||||
@@ -181,13 +194,27 @@
|
|||||||
"subnav": {
|
"subnav": {
|
||||||
"status": "Overview",
|
"status": "Overview",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
"finance": "Finance",
|
"tickets": "Bets",
|
||||||
|
"finance": "Finance & settlement",
|
||||||
"review": "Publish",
|
"review": "Publish",
|
||||||
"riskOccupancy": "Risk occupancy",
|
"riskOccupancy": "Risk occupancy",
|
||||||
"riskLockLogs": "Lock logs",
|
"riskLockLogs": "Lock logs",
|
||||||
"riskHot": "Hot numbers",
|
"riskHot": "Hot numbers",
|
||||||
"riskSoldOut": "Sold-out 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": {
|
"statusOptions": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
@@ -216,10 +243,14 @@
|
|||||||
"cancelDrawDescription": "This draw will not be drawn. Ensure there is no outstanding bet risk.",
|
"cancelDrawDescription": "This draw will not be drawn. Ensure there is no outstanding bet risk.",
|
||||||
"rngDrawTitle": "Confirm RNG draw?",
|
"rngDrawTitle": "Confirm RNG draw?",
|
||||||
"rngDrawDescription": "The system will generate draw numbers and continue the pipeline.",
|
"rngDrawDescription": "The system will generate draw numbers and continue the pipeline.",
|
||||||
"reopenTitle": "Confirm cooldown reopen?",
|
"reopenTitle": "Withdraw the result and reopen?",
|
||||||
"reopenDescription": "Results may need re-review; displayed numbers may change.",
|
"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?",
|
"runSettlementTitle": "Confirm run settlement?",
|
||||||
"runSettlementDescription": "A settlement batch will be created from the published result.",
|
"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?",
|
"saveManualDraftTitle": "Confirm save manual draft?",
|
||||||
"saveManualDraftDescription": "23 numbers will be saved for review.",
|
"saveManualDraftDescription": "23 numbers will be saved for review.",
|
||||||
"publishTitle": "Confirm publish results?",
|
"publishTitle": "Confirm publish results?",
|
||||||
|
|||||||
@@ -92,5 +92,35 @@
|
|||||||
"play_combo": "Triggered by play combo",
|
"play_combo": "Triggered by play combo",
|
||||||
"threshold": "Threshold reached"
|
"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",
|
"poolsTitle": "Risk pools",
|
||||||
"hotPageTitle": "Hot numbers monitor",
|
"hotPageTitle": "Hot numbers monitor",
|
||||||
"soldOutPageTitle": "Sold-out numbers",
|
"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": {
|
"sourceReasonOptions": {
|
||||||
"ticket_place": "Bet placement",
|
"ticket_place": "Bet placement",
|
||||||
"ticket_rollback": "Ticket rollback",
|
"ticket_rollback": "Ticket rollback",
|
||||||
@@ -36,10 +38,13 @@
|
|||||||
"sortRemainingAsc": "Remaining ↑",
|
"sortRemainingAsc": "Remaining ↑",
|
||||||
"sortNumberAsc": "Number ↑",
|
"sortNumberAsc": "Number ↑",
|
||||||
"loadPoolsFailed": "Failed to load risk pools",
|
"loadPoolsFailed": "Failed to load risk pools",
|
||||||
"capAmount": "Cap",
|
"capAmount": "Maximum payout limit",
|
||||||
"lockedAmount": "Locked",
|
"lockedAmount": "Reserved by current bets",
|
||||||
"remainingAmount": "Remaining",
|
"remainingAmount": "Remaining payout capacity",
|
||||||
"usageRatio": "Usage",
|
"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",
|
"poolStatus": "Status",
|
||||||
"soldOut": "Sold out",
|
"soldOut": "Sold out",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"checking": "लगइन स्थिति जाँच हुँदैछ…",
|
"checking": "लगइन स्थिति जाँच हुँदैछ…",
|
||||||
"checkingShort": "कार्यस्थान खोल्दै…",
|
"checkingShort": "कार्यस्थान खोल्दै…",
|
||||||
"sessionExpired": "लगइन समाप्त भयो। कृपया पुनः लगइन गर्नुहोस्।"
|
"sessionExpired": "लगइन समाप्त भयो। कृपया पुनः लगइन गर्नुहोस्।",
|
||||||
|
"sessionReplaced": "यो खाता अर्को स्थानमा लगइन भएको छ। कृपया पुनः लगइन गर्नुहोस्।"
|
||||||
},
|
},
|
||||||
"cancel": "रद्द गर्नुहोस्",
|
"cancel": "रद्द गर्नुहोस्",
|
||||||
"confirm": {
|
"confirm": {
|
||||||
@@ -198,6 +199,7 @@
|
|||||||
"rules": "खेल नियम",
|
"rules": "खेल नियम",
|
||||||
"rules_odds": "बाधा र रिबेट",
|
"rules_odds": "बाधा र रिबेट",
|
||||||
"rules_plays": "खेल नियम",
|
"rules_plays": "खेल नियम",
|
||||||
|
"bet_providers": "बेट प्रदायक",
|
||||||
"settings": "सेटिङ",
|
"settings": "सेटिङ",
|
||||||
"settlement": "सेटलमेन्ट",
|
"settlement": "सेटलमेन्ट",
|
||||||
"settlement_center": "क्रेडिट सेटलमेन्ट",
|
"settlement_center": "क्रेडिट सेटलमेन्ट",
|
||||||
|
|||||||
@@ -282,6 +282,28 @@
|
|||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"all": "सबै"
|
"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": {
|
"play": {
|
||||||
@@ -389,6 +411,29 @@
|
|||||||
"toggleDisable": "निष्क्रिय",
|
"toggleDisable": "निष्क्रिय",
|
||||||
"toggleEnable": "सक्रिय",
|
"toggleEnable": "सक्रिय",
|
||||||
"toggleInstantFailed": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।",
|
"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": {
|
"validation": {
|
||||||
"displayNameRequired": "प्रदर्शित नाम अनिवार्य छ",
|
"displayNameRequired": "प्रदर्शित नाम अनिवार्य छ",
|
||||||
"minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन"
|
"minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन"
|
||||||
@@ -581,7 +626,7 @@
|
|||||||
"autoApprove": "कूलडाउन सकिएर सेटलमेन्ट पूरा भएपछि ब्याच स्वतः अनुमोदित हुने हो कि होइन।",
|
"autoApprove": "कूलडाउन सकिएर सेटलमेन्ट पूरा भएपछि ब्याच स्वतः अनुमोदित हुने हो कि होइन।",
|
||||||
"autoPayout": "ब्याच अनुमोदित भएपछि tick ले जित रकम खेलाडीको वालेटमा स्वतः जम्मा गर्ने हो कि होइन।",
|
"autoPayout": "ब्याच अनुमोदित भएपछि tick ले जित रकम खेलाडीको वालेटमा स्वतः जम्मा गर्ने हो कि होइन।",
|
||||||
"autoSettlement": "बन्द हुँदा tick ले सेटलमेन्ट स्वतः चलाउँदैन र एडमिनले म्यानुअल रूपमा ट्रिगर गर्नुपर्छ।",
|
"autoSettlement": "बन्द हुँदा tick ले सेटलमेन्ट स्वतः चलाउँदैन र एडमिनले म्यानुअल रूपमा ट्रिगर गर्नुपर्छ।",
|
||||||
"cooldownMinutes": "प्रकाशनपछि settling मा जानुअघि कति समय पर्खने। 0 राखे तुरुन्त सेटलमेन्ट सुरु हुन्छ।",
|
"cooldownMinutes": "प्रकाशनपछि सेटलमेन्ट सुरु हुनुअघि कति समय पर्खने। 0 राखे तुरुन्त सुरु हुन्छ। परिवर्तनपछि प्रकाशित हुने ड्रमा मात्र लागू हुन्छ; पहिले नै कूलडाउनमा रहेका ड्र पुनः गणना हुँदैनन्।",
|
||||||
"manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।"
|
"manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।"
|
||||||
},
|
},
|
||||||
"loadFailed": "प्रणाली सेटिङ लोड असफल भयो",
|
"loadFailed": "प्रणाली सेटिङ लोड असफल भयो",
|
||||||
|
|||||||
@@ -52,19 +52,26 @@
|
|||||||
"manualCloseTitle": "म्यानुअल बन्द पुष्टि?",
|
"manualCloseTitle": "म्यानुअल बन्द पुष्टि?",
|
||||||
"publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।",
|
"publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।",
|
||||||
"publishTitle": "नतिजा प्रकाशन पुष्टि?",
|
"publishTitle": "नतिजा प्रकाशन पुष्टि?",
|
||||||
"reopenDescription": "नतिजा पुनः समीक्षा हुन सक्छ।",
|
"reopenDescription": "प्रकाशित नम्बर गलत हुँदा मात्र प्रयोग गर्नुहोस्। ड्र फेरि बन्द अवस्थामा फर्किन्छ र नयाँ नतिजा सिर्जना, समीक्षा र प्रकाशित गर्नुपर्छ। यसले सेटलमेन्ट छिटो गर्दैन।",
|
||||||
"reopenTitle": "कुलडाउन पुनः खोल्ने पुष्टि?",
|
"reopenTitle": "नतिजा फिर्ता लिएर पुनः खोल्ने?",
|
||||||
"rngDrawDescription": "प्रणालीले नतिजा सिर्जना गर्नेछ।",
|
"rngDrawDescription": "प्रणालीले नतिजा सिर्जना गर्नेछ।",
|
||||||
"rngDrawTitle": "RNG ड्रअ पुष्टि?",
|
"rngDrawTitle": "RNG ड्रअ पुष्टि?",
|
||||||
"runSettlementDescription": "प्रकाशित नतिजाबाट सेटलमेन्ट ब्याच बन्नेछ।",
|
"runSettlementDescription": "प्रकाशित नतिजाबाट सेटलमेन्ट ब्याच बन्नेछ।",
|
||||||
"runSettlementTitle": "सेटलमेन्ट सुरु पुष्टि?",
|
"runSettlementTitle": "सेटलमेन्ट सुरु पुष्टि?",
|
||||||
|
"settleEarlyDescription": "बाँकी नतिजा जाँच समय छोडेर तुरुन्त सेटलमेन्ट र स्वचालित भुक्तानी प्रक्रिया सुरु हुनेछ। पहिले आधिकारिक नतिजा सही भएको पुष्टि गर्नुहोस्।",
|
||||||
|
"settleEarlyTitle": "अगावै सेटल गर्ने?",
|
||||||
|
"retrySettlementDescription": "अवस्थित वैध ब्याच नदोहोर्याई सेटलमेन्ट सुरक्षित रूपमा पुनः प्रयास हुनेछ।",
|
||||||
|
"retrySettlementTitle": "सेटलमेन्ट पुनः प्रयास गर्ने?",
|
||||||
"saveManualDraftDescription": "२३ नम्बर समीक्षाका लागि सुरक्षित हुनेछ।",
|
"saveManualDraftDescription": "२३ नम्बर समीक्षाका लागि सुरक्षित हुनेछ।",
|
||||||
"saveManualDraftTitle": "म्यानुअल ड्राफ्ट सुरक्षित पुष्टि?"
|
"saveManualDraftTitle": "म्यानुअल ड्राफ्ट सुरक्षित पुष्टि?"
|
||||||
},
|
},
|
||||||
"confirmPublish": "प्रकाशन पुष्टि गर्नुहोस्",
|
"confirmPublish": "प्रकाशन पुष्टि गर्नुहोस्",
|
||||||
"confirmedAt": "पुष्टि समय {{time}}",
|
"confirmedAt": "पुष्टि समय {{time}}",
|
||||||
"cooldownReopen": "कुलिङमा पुनःखोल्नुहोस्",
|
"cooldownReopen": "नतिजा फिर्ता लिएर पुनः खोल्नुहोस्",
|
||||||
|
"reopenUsageHint": "प्रकाशित नम्बर गलत हुँदा मात्र प्रयोग गर्नुहोस्। यसले नयाँ नतिजा पुनः प्रकाशित गर्नुपर्छ र सेटलमेन्ट छिटो गर्दैन।",
|
||||||
"coolingEndTime": "कुलिङ समाप्ति",
|
"coolingEndTime": "कुलिङ समाप्ति",
|
||||||
|
"automaticSettlementAt": "स्वचालित सेटलमेन्ट: {{time}}",
|
||||||
|
"cooldownRemaining": "बाँकी {{time}}",
|
||||||
"createDraw": {
|
"createDraw": {
|
||||||
"description": "स्थानीय समयक्षेत्र {{tz}} मा मिति र समय प्रविष्ट गर्नुहोस्।",
|
"description": "स्थानीय समयक्षेत्र {{tz}} मा मिति र समय प्रविष्ट गर्नुहोस्।",
|
||||||
"drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008",
|
"drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008",
|
||||||
@@ -79,6 +86,7 @@
|
|||||||
},
|
},
|
||||||
"currentPayout": "हालको कुल भुक्तानी",
|
"currentPayout": "हालको कुल भुक्तानी",
|
||||||
"currentResultVersion": "हालको नतिजा संस्करण",
|
"currentResultVersion": "हालको नतिजा संस्करण",
|
||||||
|
"resultVersion": "नतिजा संस्करण",
|
||||||
"currentStatusAndDraft": "हालको स्थिति {{status}} · सेभ गरेपछि pending batch बन्छ, सिधै प्रकाशित हुँदैन",
|
"currentStatusAndDraft": "हालको स्थिति {{status}} · सेभ गरेपछि pending batch बन्छ, सिधै प्रकाशित हुँदैन",
|
||||||
"currentStatusDraftHint": "सेभ गरेपछि समीक्षा बाँकी ब्याच बन्छ, सिधै प्रकाशित हुँदैन",
|
"currentStatusDraftHint": "सेभ गरेपछि समीक्षा बाँकी ब्याच बन्छ, सिधै प्रकाशित हुँदैन",
|
||||||
"currentStatusLabel": "हालको स्थिति",
|
"currentStatusLabel": "हालको स्थिति",
|
||||||
@@ -148,7 +156,7 @@
|
|||||||
"overviewTitle": "ड्र अवलोकन",
|
"overviewTitle": "ड्र अवलोकन",
|
||||||
"pageGuide": "ड्र जीवनचक्र व्यवस्थापन: योजना, बन्द, नतिजा समीक्षा र सेटलमेन्ट।",
|
"pageGuide": "ड्र जीवनचक्र व्यवस्थापन: योजना, बन्द, नतिजा समीक्षा र सेटलमेन्ट।",
|
||||||
"payoutTotal": "कुल भुक्तानी",
|
"payoutTotal": "कुल भुक्तानी",
|
||||||
"pendingBatches": "बाँकी ब्याच",
|
"pendingBatches": "समीक्षाको प्रतीक्षामा नतिजा (खेलाडीलाई नदेखिने)",
|
||||||
"pendingReview": "समीक्षा बाँकी",
|
"pendingReview": "समीक्षा बाँकी",
|
||||||
"plannedDraw": "योजनाबद्ध ड्रअ",
|
"plannedDraw": "योजनाबद्ध ड्रअ",
|
||||||
"prize": "पुरस्कार",
|
"prize": "पुरस्कार",
|
||||||
@@ -160,8 +168,11 @@
|
|||||||
"publishTitle": "प्रकाशित",
|
"publishTitle": "प्रकाशित",
|
||||||
"published": "प्रकाशित",
|
"published": "प्रकाशित",
|
||||||
"publishedView": "प्रकाशित नतिजा हेर्नुहोस्",
|
"publishedView": "प्रकाशित नतिजा हेर्नुहोस्",
|
||||||
|
"officialResultsTitle": "आधिकारिक नतिजा (खेलाडीलाई देखिने)",
|
||||||
|
"officialResultsHint": "यहाँ प्रकाशित र खेलाडीलाई लागू भएका नम्बर मात्र देखिन्छन्; समीक्षा ड्राफ्ट अलग राखिन्छ।",
|
||||||
"queryDraw": "ड्रअ खोज्नुहोस्",
|
"queryDraw": "ड्रअ खोज्नुहोस्",
|
||||||
"relatedSettlementBatches": "सम्बन्धित सेटलमेन्ट ब्याच",
|
"relatedSettlementBatches": "यस ड्रको सेटलमेन्ट प्रक्रिया अभिलेख",
|
||||||
|
"settlementBatchesHint": "सामान्यतया यस ड्रमा बाजी भएको प्रत्येक प्रदायकका लागि अभिलेख बन्छ। टिकट विवरण हेर्न ब्याच नम्बर छान्नुहोस्।",
|
||||||
"reopen": "पुनःखोल्नुहोस्",
|
"reopen": "पुनःखोल्नुहोस्",
|
||||||
"reset": "रिसेट",
|
"reset": "रिसेट",
|
||||||
"resultBatchesTitle": "नतिजा ब्याच",
|
"resultBatchesTitle": "नतिजा ब्याच",
|
||||||
@@ -190,6 +201,12 @@
|
|||||||
"rngDraw": "RNG ड्रअ",
|
"rngDraw": "RNG ड्रअ",
|
||||||
"rngSummary": "RNG ह्यास {{hash}}",
|
"rngSummary": "RNG ह्यास {{hash}}",
|
||||||
"runSettlement": "सेटलमेन्ट चलाउनुहोस्",
|
"runSettlement": "सेटलमेन्ट चलाउनुहोस्",
|
||||||
|
"settleEarly": "अगावै सेटल गर्नुहोस्",
|
||||||
|
"retrySettlement": "सेटलमेन्ट पुनः चलाउनुहोस्",
|
||||||
|
"settleEarlySuccess": "बाँकी पुष्टि समय छोडेर सेटलमेन्ट सुरु भयो",
|
||||||
|
"retrySettlementSuccess": "सेटलमेन्ट पुनः प्रयास सुरु भयो",
|
||||||
|
"settlementManagePermissionRequired": "भुक्तानी व्यवस्थापकले मात्र सेटलमेन्ट सुरु वा पुनः प्रयास गर्न सक्छ।",
|
||||||
|
"settlementUnavailable": "ड्र {{status}} अवस्थामा हुँदा म्यानुअल सेटलमेन्ट आवश्यक छैन।",
|
||||||
"saveDraft": "ड्राफ्ट सुरक्षित गर्नुहोस्",
|
"saveDraft": "ड्राफ्ट सुरक्षित गर्नुहोस्",
|
||||||
"saveFailed": "सेभ असफल भयो",
|
"saveFailed": "सेभ असफल भयो",
|
||||||
"saving": "सेभ हुँदैछ…",
|
"saving": "सेभ हुँदैछ…",
|
||||||
@@ -218,16 +235,30 @@
|
|||||||
},
|
},
|
||||||
"submitting": "पेश हुँदैछ…",
|
"submitting": "पेश हुँदैछ…",
|
||||||
"subnav": {
|
"subnav": {
|
||||||
"finance": "ड्रअ वित्त",
|
"finance": "वित्त र सेटलमेन्ट",
|
||||||
"results": "परिणाम",
|
"results": "परिणाम",
|
||||||
|
"tickets": "बाजी अवस्था",
|
||||||
"review": "समीक्षा र प्रकाशन",
|
"review": "समीक्षा र प्रकाशन",
|
||||||
"riskHot": "हट नम्बर",
|
"riskHot": "हट नम्बर",
|
||||||
"riskLockLogs": "लक लग",
|
"riskLockLogs": "लक लग",
|
||||||
"riskOccupancy": "जोखिम अकुपेन्सी",
|
"riskOccupancy": "जोखिम अकुपेन्सी",
|
||||||
"riskPools": "जोखिम पूल",
|
"riskPools": "नम्बर सीमा",
|
||||||
"riskSoldOut": "बिक्री समाप्त नम्बर",
|
"riskSoldOut": "बिक्री समाप्त नम्बर",
|
||||||
"status": "ड्रअ स्थिति"
|
"status": "ड्रअ स्थिति"
|
||||||
},
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"currentStage": "हालको चरण: {{status}}",
|
||||||
|
"pending": "यस ड्रका लागि बाजी खुलिसकेको छैन।",
|
||||||
|
"open": "बन्द समयसम्म खेलाडीले बाजी लगाउन सक्छन्।",
|
||||||
|
"closing": "बाजी बन्द छ र निर्धारित नतिजा समय पर्खिरहेको छ।",
|
||||||
|
"closed": "ड्र बन्द छ र नतिजा सिर्जना वा प्रविष्टि पर्खिरहेको छ।",
|
||||||
|
"drawing": "प्रणालीले नतिजा सिर्जना गर्दैछ।",
|
||||||
|
"review": "नतिजा समीक्षामा छ र खेलाडीलाई अझै लागू भएको छैन।",
|
||||||
|
"cooldown": "नतिजा प्रकाशित छ र जाँच अवधिमा छ। समय पुगेपछि स्वचालित सेटल हुनेछ वा व्यवस्थापकले अगावै सेटल गर्न सक्छ।",
|
||||||
|
"settling": "सेटलमेन्ट अभिलेख प्रशोधन हुँदैछ। पृष्ठ प्रत्येक ५ सेकेन्डमा अद्यावधिक हुन्छ।",
|
||||||
|
"settled": "सेटलमेन्ट र भुक्तानी प्रक्रिया पूरा भयो।",
|
||||||
|
"cancelled": "यो ड्र रद्द भयो र नतिजा वा सेटलमेन्टमा जाने छैन।"
|
||||||
|
},
|
||||||
"tail2": "अन्तिम 2",
|
"tail2": "अन्तिम 2",
|
||||||
"tail3": "अन्तिम 3",
|
"tail3": "अन्तिम 3",
|
||||||
"ticketCount": "टिकट",
|
"ticketCount": "टिकट",
|
||||||
|
|||||||
@@ -92,5 +92,35 @@
|
|||||||
"tabConfig": "कन्फिगरेसन",
|
"tabConfig": "कन्फिगरेसन",
|
||||||
"tabRecords": "रेकर्ड",
|
"tabRecords": "रेकर्ड",
|
||||||
"confirmSavePoolTitle": "ज्याकपट सेटिङ बचत गर्ने?",
|
"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": "कार्य",
|
"actionFilter": "कार्य",
|
||||||
"actions": "कार्य",
|
"actions": "कार्य",
|
||||||
"all": "सबै",
|
"all": "सबै",
|
||||||
"allPoolsPageTitle": "सबै जोखिम पूल",
|
"allPoolsPageTitle": "नम्बर भुक्तानी सीमा (जोखिम पूल)",
|
||||||
|
"poolExplanationTitle": "हरेक नम्बरले अझै कति सम्भावित भुक्तानी स्वीकार्न सक्छ भन्ने यहाँ देखिन्छ",
|
||||||
|
"poolExplanation": "हरेक प्रदायक र चार-अङ्क नम्बरको अधिकतम सम्भावित भुक्तानी सीमा हुन्छ। बाजीले सबैभन्दा खराब सम्भावित भुक्तानी अनुसार क्षमता सुरक्षित गर्छ। क्षमता अपुग हुँदा नम्बर बिक्री बन्द हुन्छ र सेटलमेन्ट वा फिर्तापछि क्षमता खुल्छ। यो जोखिम आरक्षण हो, वास्तविक भुक्तानी होइन।",
|
||||||
"amount": "रकम",
|
"amount": "रकम",
|
||||||
"applyFilter": "फिल्टर लागू गर्नुहोस्",
|
"applyFilter": "फिल्टर लागू गर्नुहोस्",
|
||||||
"backToAllPools": "सबै जोखिम पूलमा फर्कनुहोस्",
|
"backToAllPools": "सबै जोखिम पूलमा फर्कनुहोस्",
|
||||||
"backToList": "सूचीमा फर्कनुहोस्",
|
"backToList": "सूचीमा फर्कनुहोस्",
|
||||||
"capAmount": "क्याप",
|
"capAmount": "अधिकतम भुक्तानी सीमा",
|
||||||
"center": "जोखिम केन्द्र",
|
"center": "जोखिम केन्द्र",
|
||||||
"changeDraw": "ड्रअ परिवर्तन गर्नुहोस्",
|
"changeDraw": "ड्रअ परिवर्तन गर्नुहोस्",
|
||||||
"close": "बन्द",
|
"close": "बन्द",
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
"lockLogsGroupedHint": "पूर्वनिर्धारित रूपमा टिकट अनुसार समूह। विस्तारित प्लेको संयोजन टिकट विवरणमा हेर्नुहोस्।",
|
"lockLogsGroupedHint": "पूर्वनिर्धारित रूपमा टिकट अनुसार समूह। विस्तारित प्लेको संयोजन टिकट विवरणमा हेर्नुहोस्।",
|
||||||
"lockLogsTitle": "जोखिम लक लग",
|
"lockLogsTitle": "जोखिम लक लग",
|
||||||
"lockReleaseSummary": "लक / रिलिज पङ्क्ति",
|
"lockReleaseSummary": "लक / रिलिज पङ्क्ति",
|
||||||
"lockedAmount": "लक गरिएको",
|
"lockedAmount": "हालको बाजीले सुरक्षित गरेको",
|
||||||
"lockedWorstCase": "लक गरिएको (अधिकतम भुक्तानी सुरक्षित)",
|
"lockedWorstCase": "लक गरिएको (अधिकतम भुक्तानी सुरक्षित)",
|
||||||
"manualCloseSuccess": "नम्बर बेटिङ म्यानुअल रूपमा बन्द गरियो",
|
"manualCloseSuccess": "नम्बर बेटिङ म्यानुअल रूपमा बन्द गरियो",
|
||||||
"no": "होइन",
|
"no": "होइन",
|
||||||
@@ -66,7 +68,10 @@
|
|||||||
"recoverSuccess": "नम्बर बेटिङ पुनर्स्थापित गरियो",
|
"recoverSuccess": "नम्बर बेटिङ पुनर्स्थापित गरियो",
|
||||||
"refresh": "रिफ्रेस",
|
"refresh": "रिफ्रेस",
|
||||||
"release": "रिलिज",
|
"release": "रिलिज",
|
||||||
"remainingAmount": "बाँकी",
|
"remainingAmount": "बाँकी भुक्तानी क्षमता",
|
||||||
|
"emptyPools": "यो फिल्टरमा नम्बर सीमा अभिलेख छैन। बाजी जोखिम सुरक्षित नभएका नम्बर सामान्यतया देखिँदैनन्।",
|
||||||
|
"emptyHighRiskPools": "हाल ८०% वा बढी प्रयोग भएको उच्च जोखिम नम्बर छैन।",
|
||||||
|
"emptySoldOutPools": "क्षमता अपुग वा म्यानुअल बन्दका कारण बिक्री रोकिएको नम्बर छैन।",
|
||||||
"remainingSellable": "बाँकी बिक्रीयोग्य",
|
"remainingSellable": "बाँकी बिक्रीयोग्य",
|
||||||
"riskFilter": "जोखिम फिल्टर",
|
"riskFilter": "जोखिम फिल्टर",
|
||||||
"provider": "प्रोभाइडर",
|
"provider": "प्रोभाइडर",
|
||||||
@@ -109,7 +114,7 @@
|
|||||||
"time": "समय",
|
"time": "समय",
|
||||||
"title": "जोखिम",
|
"title": "जोखिम",
|
||||||
"totalCap": "क्याप रकम",
|
"totalCap": "क्याप रकम",
|
||||||
"usageRatio": "प्रयोग अनुपात",
|
"usageRatio": "अकुपेन्सी अनुपात",
|
||||||
"view": "हेर्नुहोस्",
|
"view": "हेर्नुहोस्",
|
||||||
"viewDetail": "विवरण",
|
"viewDetail": "विवरण",
|
||||||
"viewTicket": "टिकट",
|
"viewTicket": "टिकट",
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
"draws": "期号列表",
|
"draws": "期号列表",
|
||||||
"rules_plays": "投注规则",
|
"rules_plays": "投注规则",
|
||||||
"rules_odds": "赔率与基础回水",
|
"rules_odds": "赔率与基础回水",
|
||||||
|
"bet_providers": "开注商管理",
|
||||||
"rules": "投注规则",
|
"rules": "投注规则",
|
||||||
"risk_cap": "限额版本",
|
"risk_cap": "限额版本",
|
||||||
"risk": "风控中心",
|
"risk": "风控中心",
|
||||||
@@ -212,7 +213,8 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"checking": "正在校验登录状态…",
|
"checking": "正在校验登录状态…",
|
||||||
"checkingShort": "正在进入工作台…",
|
"checkingShort": "正在进入工作台…",
|
||||||
"sessionExpired": "登录已失效,请重新登录"
|
"sessionExpired": "登录已失效,请重新登录",
|
||||||
|
"sessionReplaced": "该账号已在其他地方登录,当前会话已退出,请重新登录"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
|||||||
@@ -304,7 +304,7 @@
|
|||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"manualReview": "开启后,RNG 开奖结果会先进入待审核,必须由后台人工发布。",
|
"manualReview": "开启后,RNG 开奖结果会先进入待审核,必须由后台人工发布。",
|
||||||
"cooldownMinutes": "结果发布后等待多久再进入 settling。填 0 表示发布后直接进入结算。",
|
"cooldownMinutes": "结果发布后等待多久再进入结算;填 0 表示立即结算。修改仅影响之后发布开奖结果的期号,已经进入冷静期的期号不会自动重算。",
|
||||||
"autoSettlement": "关闭后,tick 不会自动跑结算,只能由后台手工执行。",
|
"autoSettlement": "关闭后,tick 不会自动跑结算,只能由后台手工执行。",
|
||||||
"autoApprove": "冷静期结束并跑完结算后,是否自动将批次标记为已审核。",
|
"autoApprove": "冷静期结束并跑完结算后,是否自动将批次标记为已审核。",
|
||||||
"autoPayout": "批次已审核后,是否由 tick 自动把中奖金额打入玩家钱包。",
|
"autoPayout": "批次已审核后,是否由 tick 自动把中奖金额打入玩家钱包。",
|
||||||
@@ -487,6 +487,29 @@
|
|||||||
"title": "规则文案(多语言)",
|
"title": "规则文案(多语言)",
|
||||||
"description": "玩法 {{playCode}};修改内容只会暂存到草稿,保存并发布后才会生效。",
|
"description": "玩法 {{playCode}};修改内容只会暂存到草稿,保存并发布后才会生效。",
|
||||||
"apply": "应用到草稿"
|
"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": {
|
"prizeScopes": {
|
||||||
@@ -574,6 +597,28 @@
|
|||||||
"currentActive": "当前生效",
|
"currentActive": "当前生效",
|
||||||
"afterPublish": "发布后"
|
"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": {
|
"rebate": {
|
||||||
|
|||||||
@@ -78,6 +78,8 @@
|
|||||||
"sequenceNo": "流水序号",
|
"sequenceNo": "流水序号",
|
||||||
"plannedDraw": "计划开奖",
|
"plannedDraw": "计划开奖",
|
||||||
"coolingEndTime": "冷静期结束",
|
"coolingEndTime": "冷静期结束",
|
||||||
|
"automaticSettlementAt": "自动结算时间:{{time}}",
|
||||||
|
"cooldownRemaining": "剩余 {{time}}",
|
||||||
"resultSource": "结果来源",
|
"resultSource": "结果来源",
|
||||||
"resultSourceOptions": {
|
"resultSourceOptions": {
|
||||||
"rng": "RNG 自动生成",
|
"rng": "RNG 自动生成",
|
||||||
@@ -92,6 +94,7 @@
|
|||||||
"published": "已发布"
|
"published": "已发布"
|
||||||
},
|
},
|
||||||
"currentResultVersion": "当前结果版本",
|
"currentResultVersion": "当前结果版本",
|
||||||
|
"resultVersion": "开奖结果版本",
|
||||||
"settleVersion": "结算版本",
|
"settleVersion": "结算版本",
|
||||||
"isReopened": "是否重开",
|
"isReopened": "是否重开",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
@@ -109,8 +112,15 @@
|
|||||||
"rngDraw": "RNG开奖",
|
"rngDraw": "RNG开奖",
|
||||||
"rngAutoGenerate": "自动开奖",
|
"rngAutoGenerate": "自动开奖",
|
||||||
"reopen": "重开",
|
"reopen": "重开",
|
||||||
"cooldownReopen": "冷静期重开",
|
"cooldownReopen": "撤回开奖结果并重开",
|
||||||
|
"reopenUsageHint": "“撤回开奖结果并重开”仅用于已发布号码有误,需要重新生成和审核;它不会加快结算。",
|
||||||
"runSettlement": "触发结算",
|
"runSettlement": "触发结算",
|
||||||
|
"settleEarly": "提前结算",
|
||||||
|
"retrySettlement": "重新触发结算",
|
||||||
|
"settleEarlySuccess": "已跳过剩余确认窗口并开始结算",
|
||||||
|
"retrySettlementSuccess": "已重新触发本期结算",
|
||||||
|
"settlementManagePermissionRequired": "仅派彩管理人员可提前或重新触发结算。",
|
||||||
|
"settlementUnavailable": "当前状态为“{{status}}”,无需提供手动结算操作。",
|
||||||
"processing": "处理中…",
|
"processing": "处理中…",
|
||||||
"actionSuccess": "{{name}}成功",
|
"actionSuccess": "{{name}}成功",
|
||||||
"actionFailed": "{{name}}失败",
|
"actionFailed": "{{name}}失败",
|
||||||
@@ -121,7 +131,8 @@
|
|||||||
"currentPayout": "当期派彩合计",
|
"currentPayout": "当期派彩合计",
|
||||||
"grossProfit": "近似毛损益",
|
"grossProfit": "近似毛损益",
|
||||||
"settlementBatchList": "结算记录",
|
"settlementBatchList": "结算记录",
|
||||||
"relatedSettlementBatches": "结算批次",
|
"relatedSettlementBatches": "本期结算处理记录",
|
||||||
|
"settlementBatchesHint": "通常按本期有下注的开注商生成处理记录;点击批次编号可查看对应注单明细。",
|
||||||
"noSettlementBatches": "暂无结算批次记录。",
|
"noSettlementBatches": "暂无结算批次记录。",
|
||||||
"ticketCount": "票数",
|
"ticketCount": "票数",
|
||||||
"winCount": "中奖数",
|
"winCount": "中奖数",
|
||||||
@@ -153,7 +164,7 @@
|
|||||||
"clear": "清空",
|
"clear": "清空",
|
||||||
"saveDraft": "保存草稿",
|
"saveDraft": "保存草稿",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"pendingBatches": "待发布",
|
"pendingBatches": "待审核结果(尚未对玩家生效)",
|
||||||
"noPendingBatches": "暂无",
|
"noPendingBatches": "暂无",
|
||||||
"batchId": "批次",
|
"batchId": "批次",
|
||||||
"numberCount": "号码条数",
|
"numberCount": "号码条数",
|
||||||
@@ -173,6 +184,8 @@
|
|||||||
"checkBeforePublish": "请核对以下号码后再发布",
|
"checkBeforePublish": "请核对以下号码后再发布",
|
||||||
"checkBeforePublishDesc": "确认无误后点击发布。",
|
"checkBeforePublishDesc": "确认无误后点击发布。",
|
||||||
"publishedView": "查看已发布展示",
|
"publishedView": "查看已发布展示",
|
||||||
|
"officialResultsTitle": "正式开奖结果(已对玩家显示)",
|
||||||
|
"officialResultsHint": "这里只展示已经发布并对玩家生效的号码;待审核草稿不会出现在这里。",
|
||||||
"confirmPublish": "确认发布",
|
"confirmPublish": "确认发布",
|
||||||
"submitting": "提交中…",
|
"submitting": "提交中…",
|
||||||
"publishSuccess": "已发布 · {{drawNo}} · 状态 {{status}}",
|
"publishSuccess": "已发布 · {{drawNo}} · 状态 {{status}}",
|
||||||
@@ -181,13 +194,27 @@
|
|||||||
"subnav": {
|
"subnav": {
|
||||||
"status": "概览",
|
"status": "概览",
|
||||||
"results": "开奖结果",
|
"results": "开奖结果",
|
||||||
"finance": "收支",
|
"tickets": "下注情况",
|
||||||
|
"finance": "收支结算",
|
||||||
"review": "开奖发布",
|
"review": "开奖发布",
|
||||||
"riskOccupancy": "风控占用",
|
"riskOccupancy": "风控占用",
|
||||||
"riskLockLogs": "占用流水",
|
"riskLockLogs": "占用流水",
|
||||||
"riskHot": "热门号码",
|
"riskHot": "热门号码",
|
||||||
"riskSoldOut": "售罄号码",
|
"riskSoldOut": "售罄号码",
|
||||||
"riskPools": "风险池"
|
"riskPools": "号码限额"
|
||||||
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"currentStage": "当前阶段:{{status}}",
|
||||||
|
"pending": "本期尚未开放下注。",
|
||||||
|
"open": "玩家可以下注;到封盘时间后系统会停止接单。",
|
||||||
|
"closing": "本期已停止接单,等待计划开奖时间。",
|
||||||
|
"closed": "已封盘待开奖,系统将生成或等待录入开奖结果。",
|
||||||
|
"drawing": "系统正在生成开奖结果。",
|
||||||
|
"review": "开奖结果尚在审核,发布前不会对玩家生效。",
|
||||||
|
"cooldown": "结果已发布并进入核错窗口;到期会自动结算,也可由派彩管理人员提前结算。",
|
||||||
|
"settling": "正在生成或处理本期结算记录,页面每 5 秒自动更新。",
|
||||||
|
"settled": "本期结算与派彩流程已完成。",
|
||||||
|
"cancelled": "本期已取消,不再进入开奖与结算流程。"
|
||||||
},
|
},
|
||||||
"statusOptions": {
|
"statusOptions": {
|
||||||
"all": "不限",
|
"all": "不限",
|
||||||
@@ -216,10 +243,14 @@
|
|||||||
"cancelDrawDescription": "取消后该期将不再开奖,请确认无未处理注单风险。",
|
"cancelDrawDescription": "取消后该期将不再开奖,请确认无未处理注单风险。",
|
||||||
"rngDrawTitle": "确认 RNG 自动生成开奖?",
|
"rngDrawTitle": "确认 RNG 自动生成开奖?",
|
||||||
"rngDrawDescription": "将按系统规则生成本期开奖号码并进入后续流程。",
|
"rngDrawDescription": "将按系统规则生成本期开奖号码并进入后续流程。",
|
||||||
"reopenTitle": "确认冷静期重开?",
|
"reopenTitle": "确认撤回开奖结果并重开?",
|
||||||
"reopenDescription": "重开后需重新审核/发布结果,可能影响已展示的开奖信息。",
|
"reopenDescription": "仅在已发布号码有误时使用。操作后期号回到已封盘待开奖,需要重新生成、审核并发布结果;这不会加快结算。",
|
||||||
"runSettlementTitle": "确认触发结算?",
|
"runSettlementTitle": "确认触发结算?",
|
||||||
"runSettlementDescription": "将按已发布开奖结果生成本期结算批次。",
|
"runSettlementDescription": "将按已发布开奖结果生成本期结算批次。",
|
||||||
|
"settleEarlyTitle": "确认提前结算?",
|
||||||
|
"settleEarlyDescription": "将跳过本期剩余的开奖结果核错窗口,立即生成结算处理记录,并继续自动审核与派彩。请先确认正式开奖结果无误。",
|
||||||
|
"retrySettlementTitle": "确认重新触发结算?",
|
||||||
|
"retrySettlementDescription": "将幂等重试本期结算处理,不会重复生成已存在的有效批次。",
|
||||||
"saveManualDraftTitle": "确认保存人工开奖草稿?",
|
"saveManualDraftTitle": "确认保存人工开奖草稿?",
|
||||||
"saveManualDraftDescription": "将写入 23 个开奖号码草稿,提交后进入审核流程。",
|
"saveManualDraftDescription": "将写入 23 个开奖号码草稿,提交后进入审核流程。",
|
||||||
"publishTitle": "确认发布开奖结果?",
|
"publishTitle": "确认发布开奖结果?",
|
||||||
|
|||||||
@@ -93,5 +93,35 @@
|
|||||||
"time": "时间",
|
"time": "时间",
|
||||||
"ticketNo": "注单",
|
"ticketNo": "注单",
|
||||||
"player": "玩家",
|
"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": "风险池",
|
"poolsTitle": "风险池",
|
||||||
"hotPageTitle": "热门号码监控",
|
"hotPageTitle": "热门号码监控",
|
||||||
"soldOutPageTitle": "售罄号码列表",
|
"soldOutPageTitle": "售罄号码列表",
|
||||||
"allPoolsPageTitle": "全部风险池",
|
"allPoolsPageTitle": "号码赔付限额(风险池)",
|
||||||
|
"poolExplanationTitle": "这里控制每个号码还能承接多少潜在赔付",
|
||||||
|
"poolExplanation": "每个开注商、每个四位号码都有最高潜在赔付额度。下注会按最坏赔付预留额度;额度不足时该号码停售,结算或退款后释放。这里的金额是风险预留,不是实际派彩金额。",
|
||||||
"sourceReasonOptions": {
|
"sourceReasonOptions": {
|
||||||
"ticket_place": "下注占用",
|
"ticket_place": "下注占用",
|
||||||
"ticket_rollback": "注单回滚",
|
"ticket_rollback": "注单回滚",
|
||||||
@@ -36,10 +38,13 @@
|
|||||||
"sortRemainingAsc": "剩余额 ↑(紧俏)",
|
"sortRemainingAsc": "剩余额 ↑(紧俏)",
|
||||||
"sortNumberAsc": "号码 ↑",
|
"sortNumberAsc": "号码 ↑",
|
||||||
"loadPoolsFailed": "加载风险池失败",
|
"loadPoolsFailed": "加载风险池失败",
|
||||||
"capAmount": "封顶",
|
"capAmount": "最高赔付额度",
|
||||||
"lockedAmount": "已占用",
|
"lockedAmount": "当前下注预留",
|
||||||
"remainingAmount": "剩余",
|
"remainingAmount": "剩余可承接赔付",
|
||||||
"usageRatio": "占用比",
|
"usageRatio": "占用比例",
|
||||||
|
"emptyPools": "当前筛选下没有号码额度记录;未产生下注预留的号码通常不会列出。",
|
||||||
|
"emptyHighRiskPools": "当前没有占用比例达到 80% 的高风险号码。",
|
||||||
|
"emptySoldOutPools": "当前没有因额度不足或人工关闭而停售的号码。",
|
||||||
"poolStatus": "状态",
|
"poolStatus": "状态",
|
||||||
"soldOut": "售罄",
|
"soldOut": "售罄",
|
||||||
"warning": "预警",
|
"warning": "预警",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import i18n from "@/i18n";
|
|
||||||
import { getAdminSessionState } from "@/stores/admin-session";
|
import { getAdminSessionState } from "@/stores/admin-session";
|
||||||
import { readToken } from "@/stores/admin-token";
|
import { readToken } from "@/stores/admin-token";
|
||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
@@ -13,6 +11,9 @@ export const ADMIN_UNAUTHENTICATED_CODE = 8110;
|
|||||||
/** 与后端 {@see ErrorCode::AdminAccountDisabled} 一致 */
|
/** 与后端 {@see ErrorCode::AdminAccountDisabled} 一致 */
|
||||||
export const ADMIN_ACCOUNT_DISABLED_CODE = 8113;
|
export const ADMIN_ACCOUNT_DISABLED_CODE = 8113;
|
||||||
|
|
||||||
|
/** 与后端 ErrorCode::AdminSessionReplaced 一致 */
|
||||||
|
export const ADMIN_SESSION_REPLACED_CODE = 8115;
|
||||||
|
|
||||||
/** 登录验证码错误等,HTTP 可能为 401/422,不应踢出会话 */
|
/** 登录验证码错误等,HTTP 可能为 401/422,不应踢出会话 */
|
||||||
const LOGIN_FORM_ERROR_CODES = new Set([8111, 8112]);
|
const LOGIN_FORM_ERROR_CODES = new Set([8111, 8112]);
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ export function isAdminAuthRejected(err: unknown): boolean {
|
|||||||
if (err instanceof LotteryApiBizError) {
|
if (err instanceof LotteryApiBizError) {
|
||||||
return (
|
return (
|
||||||
err.code === ADMIN_UNAUTHENTICATED_CODE ||
|
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 (
|
if (
|
||||||
envelopeCode === ADMIN_UNAUTHENTICATED_CODE ||
|
envelopeCode === ADMIN_UNAUTHENTICATED_CODE ||
|
||||||
envelopeCode === ADMIN_ACCOUNT_DISABLED_CODE
|
envelopeCode === ADMIN_ACCOUNT_DISABLED_CODE ||
|
||||||
|
envelopeCode === ADMIN_SESSION_REPLACED_CODE
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -62,7 +65,8 @@ export function isAdminAuthRejected(err: unknown): boolean {
|
|||||||
if (isApiEnvelope(body) && body.code !== 0) {
|
if (isApiEnvelope(body) && body.code !== 0) {
|
||||||
return (
|
return (
|
||||||
body.code === ADMIN_UNAUTHENTICATED_CODE ||
|
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;
|
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()) {
|
if (typeof window === "undefined" || isAdminLoginPath()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginPath = "/admin/login";
|
const loginPath = "/admin/login?session=" + reason;
|
||||||
if (window.location.pathname !== loginPath) {
|
if (window.location.pathname !== loginPath) {
|
||||||
window.location.replace(loginPath);
|
window.location.replace(loginPath);
|
||||||
}
|
}
|
||||||
@@ -92,7 +116,7 @@ export function redirectToAdminLogin(): void {
|
|||||||
/**
|
/**
|
||||||
* 清除本地会话并跳转登录页。可在 axios 拦截器、/auth/me 刷新等任意上下文调用。
|
* 清除本地会话并跳转登录页。可在 axios 拦截器、/auth/me 刷新等任意上下文调用。
|
||||||
*/
|
*/
|
||||||
export function handleAdminAuthRejected(): void {
|
export function handleAdminAuthRejected(err?: unknown): void {
|
||||||
if (typeof window === "undefined" || isAdminLoginPath()) {
|
if (typeof window === "undefined" || isAdminLoginPath()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,21 +128,19 @@ export function handleAdminAuthRejected(): void {
|
|||||||
|
|
||||||
const session = getAdminSessionState();
|
const session = getAdminSessionState();
|
||||||
const hadSession = session.bearerToken != null || readToken() != null;
|
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.
|
// Best-effort HttpOnly cookie cleanup; the local session is cleared below.
|
||||||
});
|
});
|
||||||
session.clearSession();
|
session.clearSession();
|
||||||
|
|
||||||
if (hadSession) {
|
const reason =
|
||||||
toast.error(
|
hadSession && adminAuthErrorCode(err) === ADMIN_SESSION_REPLACED_CODE
|
||||||
i18n.t("auth.sessionExpired", {
|
? "replaced"
|
||||||
ns: "common",
|
: "expired";
|
||||||
defaultValue: "Sign-in expired. Please log in again.",
|
redirectToAdminLogin(reason);
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectToAdminLogin();
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
authRejectHandling = false;
|
authRejectHandling = false;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ adminHttp.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
if (isAdminAuthRejected(error)) {
|
if (isAdminAuthRejected(error)) {
|
||||||
handleAdminAuthRejected();
|
handleAdminAuthRejected(error);
|
||||||
}
|
}
|
||||||
if (isAxiosError(error) && typeof window !== "undefined") {
|
if (isAxiosError(error) && typeof window !== "undefined") {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
@@ -43,7 +43,7 @@ adminHttp.interceptors.response.use(
|
|||||||
|
|
||||||
function rejectAfterAuthCheck(err: unknown): never {
|
function rejectAfterAuthCheck(err: unknown): never {
|
||||||
if (isAdminAuthRejected(err)) {
|
if (isAdminAuthRejected(err)) {
|
||||||
handleAdminAuthRejected();
|
handleAdminAuthRejected(err);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
|||||||
draws: "draws",
|
draws: "draws",
|
||||||
rules_plays: "rules_plays",
|
rules_plays: "rules_plays",
|
||||||
rules_odds: "rules_odds",
|
rules_odds: "rules_odds",
|
||||||
|
bet_providers: "bet_providers",
|
||||||
jackpot: "jackpot",
|
jackpot: "jackpot",
|
||||||
risk_cap: "risk_cap",
|
risk_cap: "risk_cap",
|
||||||
risk: "risk",
|
risk: "risk",
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export async function proxyLotteryApi(
|
|||||||
"",
|
"",
|
||||||
expiredAdminTokenCookieOptions(isSecureRequest(request)),
|
expiredAdminTokenCookieOptions(isSecureRequest(request)),
|
||||||
);
|
);
|
||||||
|
} else if (path.startsWith("v1/admin/") && upstream.status === 401) {
|
||||||
|
response.cookies.set(
|
||||||
|
ADMIN_TOKEN_STORAGE_KEY,
|
||||||
|
"",
|
||||||
|
expiredAdminTokenCookieOptions(isSecureRequest(request)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
</Label>
|
</Label>
|
||||||
<Select value={moduleCode} onValueChange={(value) => setModuleCode(value ?? "all")}>
|
<Select value={moduleCode} onValueChange={(value) => setModuleCode(value ?? "all")}>
|
||||||
<SelectTrigger id="aud-module" className="w-full sm:w-44">
|
<SelectTrigger id="aud-module" className="w-full sm:w-44">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{moduleCode === "all" ? t("filterModuleAll") : t(`moduleOptions.${moduleCode}`)}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t("filterModuleAll")}</SelectItem>
|
<SelectItem value="all">{t("filterModuleAll")}</SelectItem>
|
||||||
@@ -159,7 +161,9 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
</Label>
|
</Label>
|
||||||
<Select value={operatorType} onValueChange={(value) => setOperatorType(value ?? "all")}>
|
<Select value={operatorType} onValueChange={(value) => setOperatorType(value ?? "all")}>
|
||||||
<SelectTrigger id="aud-op-type" className="w-full sm:w-36">
|
<SelectTrigger id="aud-op-type" className="w-full sm:w-36">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{operatorType === "all" ? t("filterOperatorTypeAll") : t(`operatorTypes.${operatorType}`)}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t("filterOperatorTypeAll")}</SelectItem>
|
<SelectItem value="all">{t("filterOperatorTypeAll")}</SelectItem>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ConfigChipGroupProps = {
|
type ConfigChipGroupProps = {
|
||||||
label?: string;
|
label?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
@@ -14,7 +14,7 @@ type ConfigChipGroupProps = {
|
|||||||
export function ConfigChipGroup({ label, children, className }: ConfigChipGroupProps) {
|
export function ConfigChipGroup({ label, children, className }: ConfigChipGroupProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2.5", className)}>
|
<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 className="flex flex-wrap gap-2">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
putOddsItems,
|
putOddsItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
import { getAdminBetProviders } from "@/api/admin-bet-providers";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
@@ -28,7 +30,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
|
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
@@ -602,7 +603,20 @@ export function OddsConfigDocScreen({
|
|||||||
|
|
||||||
const filtersInner = (
|
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
|
<ConfigChip
|
||||||
active={providerCode === GLOBAL_PROVIDER_CODE}
|
active={providerCode === GLOBAL_PROVIDER_CODE}
|
||||||
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
||||||
@@ -619,7 +633,20 @@ export function OddsConfigDocScreen({
|
|||||||
</ConfigChip>
|
</ConfigChip>
|
||||||
))}
|
))}
|
||||||
</ConfigChipGroup>
|
</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) => (
|
{catTabs.map((tab) => (
|
||||||
<ConfigChip
|
<ConfigChip
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -654,7 +681,20 @@ export function OddsConfigDocScreen({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{filteredTypes.length === 0 ? (
|
||||||
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -705,6 +745,50 @@ export function OddsConfigDocScreen({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
|
<>
|
||||||
|
<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
|
<ConfigVersionActions
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
@@ -717,6 +801,7 @@ export function OddsConfigDocScreen({
|
|||||||
onSaveDraft={() => void handleSave()}
|
onSaveDraft={() => void handleSave()}
|
||||||
onPublish={() => void requestPublishConfirm()}
|
onPublish={() => void requestPublishConfirm()}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -743,10 +828,21 @@ export function OddsConfigDocScreen({
|
|||||||
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
|
{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 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">
|
<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)}
|
{prizeScopeLabel(scope, t)}
|
||||||
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
|
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
|
||||||
</Label>
|
</AdminHelpText>
|
||||||
{row ? (
|
{row ? (
|
||||||
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
|
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
|
||||||
{sourceLabel}
|
{sourceLabel}
|
||||||
@@ -779,9 +875,17 @@ export function OddsConfigDocScreen({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="grid min-w-0 gap-2 border border-border/60 bg-muted/20 p-3">
|
<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" })}
|
{t("odds.rebateRate", { ns: "config" })}
|
||||||
</Label>
|
</AdminHelpText>
|
||||||
{canEditDraft ? (
|
{canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -895,7 +999,20 @@ export function OddsConfigDocScreen({
|
|||||||
{toolbarBlock}
|
{toolbarBlock}
|
||||||
<div className="grid gap-0 overflow-hidden rounded-lg border border-border/60 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
<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">
|
<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
|
<ConfigChip
|
||||||
active={providerCode === GLOBAL_PROVIDER_CODE}
|
active={providerCode === GLOBAL_PROVIDER_CODE}
|
||||||
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AdminHelpText } from "@/components/admin/admin-field-label";
|
||||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -60,7 +61,17 @@ export function OddsConfigPlayNav({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<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) => (
|
{catTabs.map((tab) => (
|
||||||
<ConfigChip
|
<ConfigChip
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -73,7 +84,14 @@ export function OddsConfigPlayNav({
|
|||||||
</ConfigChipGroup>
|
</ConfigChipGroup>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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
|
<Select
|
||||||
modal={false}
|
modal={false}
|
||||||
value={playSelectValue}
|
value={playSelectValue}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { ConfigChipGroup } from "@/modules/config/config-chip-group";
|
|||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
|
|
||||||
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -489,6 +491,48 @@ export function PlayConfigDocScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
|
<>
|
||||||
|
<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
|
<ConfigVersionActions
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
@@ -508,6 +552,7 @@ export function PlayConfigDocScreen() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -578,17 +623,43 @@ export function PlayConfigDocScreen() {
|
|||||||
</div>
|
</div>
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<div className="space-y-1.5">
|
<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) => {
|
{batchSwitchStates.map((group) => {
|
||||||
const groupOn = group.allEnabled;
|
const groupOn = group.allEnabled;
|
||||||
const isPartial =
|
const isPartial =
|
||||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||||
return (
|
return (
|
||||||
<label
|
<div
|
||||||
key={group.key}
|
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">
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
{t("play.batchEnabledCount", {
|
{t("play.batchEnabledCount", {
|
||||||
ns: "config",
|
ns: "config",
|
||||||
@@ -622,7 +693,7 @@ export function PlayConfigDocScreen() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ConfigChipGroup>
|
</ConfigChipGroup>
|
||||||
@@ -644,11 +715,71 @@ export function PlayConfigDocScreen() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
<TableHead className="w-[88px] text-center">
|
||||||
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
<AdminHelpText
|
||||||
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
helpId="play-status"
|
||||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
helpText={t("play.help.fields.status", { ns: "config" })}
|
||||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -32,7 +31,6 @@ import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
|||||||
import {
|
import {
|
||||||
PRD_DRAW_REOPEN_MANAGE,
|
PRD_DRAW_REOPEN_MANAGE,
|
||||||
PRD_PAYOUT_MANAGE,
|
PRD_PAYOUT_MANAGE,
|
||||||
PRD_PAYOUT_REVIEW,
|
|
||||||
} from "./draw-prd";
|
} from "./draw-prd";
|
||||||
|
|
||||||
type ScheduleStep = {
|
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 { t } = useTranslation(["draws", "common"]);
|
||||||
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const { draw: data, loading, error, refresh, drawId: idNum } = useDrawDetail();
|
const { draw: data, loading, error, refresh, drawId: idNum } = useDrawDetail();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
const canManageDraw = canManageDrawResults(profile?.permissions);
|
||||||
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
||||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
|
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
||||||
PRD_PAYOUT_MANAGE,
|
|
||||||
PRD_PAYOUT_REVIEW,
|
|
||||||
]);
|
|
||||||
const [acting, setActing] = useState<string | null>(null);
|
const [acting, setActing] = useState<string | null>(null);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
|
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
|
||||||
@@ -90,6 +94,23 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
|||||||
void loadFinance();
|
void loadFinance();
|
||||||
}, [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> {
|
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
||||||
if (!Number.isFinite(idNum)) return;
|
if (!Number.isFinite(idNum)) return;
|
||||||
setActing(name);
|
setActing(name);
|
||||||
@@ -127,7 +148,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
|||||||
type ActionDef = {
|
type ActionDef = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
variant: "outline" | "destructive";
|
variant: "default" | "outline" | "destructive";
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onConfirm: () => Promise<void>;
|
onConfirm: () => Promise<void>;
|
||||||
confirmTitle: string;
|
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) {
|
if (canReopenDraw) {
|
||||||
defs.push({
|
defs.push({
|
||||||
key: "reopen",
|
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);
|
return defs.filter((d) => d.enabled);
|
||||||
}, [canManageDraw, canReopenDraw, canRunSettlement, data, idNum, t]);
|
}, [canManageDraw, canReopenDraw, canRunSettlement, data, idNum, t]);
|
||||||
|
|
||||||
@@ -208,11 +233,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
|||||||
return <AdminNoResourceState />;
|
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 financeCurrency = financeSummary?.currency_code ?? "NPR";
|
||||||
const hasResultActivity = totalBatches > 0 || pendingReview > 0 || batch.published > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -256,49 +277,22 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
|||||||
|
|
||||||
<ScheduleTimeline steps={scheduleSteps} />
|
<ScheduleTimeline steps={scheduleSteps} />
|
||||||
|
|
||||||
{hasResultActivity ? (
|
<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">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<p className="font-semibold">{t("lifecycle.currentStage", { status: t(`statusOptions.${data.status}`) })}</p>
|
||||||
{canManageDraw && totalBatches > 0 ? (
|
<p className="mt-1 text-muted-foreground">{t(`lifecycle.${data.status}`)}</p>
|
||||||
<span className="rounded-md bg-muted px-2 py-1 tabular-nums">
|
{data.status === "cooldown" ? (
|
||||||
{t("batchSummaryTotal", { count: totalBatches })}
|
<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>
|
</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>
|
</div>
|
||||||
) : canManageDraw ? (
|
|
||||||
<Link
|
|
||||||
href={`/admin/draws/${drawId}/results`}
|
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{t("goToReviewTab")}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
{availableActions.length > 0 ? (
|
{availableActions.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2 border-t border-border/60 pt-4">
|
<div className="space-y-2 border-t border-border/60 pt-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
{availableActions.map((action) => (
|
{availableActions.map((action) => (
|
||||||
<Button
|
<Button
|
||||||
key={action.key}
|
key={action.key}
|
||||||
@@ -319,6 +313,10 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{data.status === "cooldown" && canReopenDraw ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t("reopenUsageHint")}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
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 { formatAdminMinorUnits } from "@/lib/money";
|
||||||
|
|
||||||
import { settlementBatchStatusLabel } from "./draw-display";
|
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 {
|
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||||
const { t } = useTranslation(["draws", "settlement", "common"]);
|
const { t } = useTranslation(["draws", "settlement", "common"]);
|
||||||
const tRef = useTranslationRef(["draws", "settlement", "common"]);
|
const tRef = useTranslationRef(["draws", "settlement", "common"]);
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const idNum = Number(drawId);
|
const idNum = Number(drawId);
|
||||||
|
const { draw, refresh: refreshDraw } = useDrawDetail();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
|
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
||||||
PRD_PAYOUT_MANAGE,
|
|
||||||
PRD_PAYOUT_REVIEW,
|
|
||||||
]);
|
|
||||||
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
|
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
const exportLabels = useExportLabels("drawFinance", { drawNo: data?.draw_no ?? drawId });
|
const exportLabels = useExportLabels("drawFinance", { drawNo: data?.draw_no ?? drawId });
|
||||||
@@ -80,8 +79,8 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
setSettling(true);
|
setSettling(true);
|
||||||
try {
|
try {
|
||||||
const res = await postAdminRunDrawSettlement(idNum);
|
const res = await postAdminRunDrawSettlement(idNum);
|
||||||
toast.success(res.ran ? t("runSettlement") : t("status"));
|
toast.success(res.cooldown_skipped ? t("settleEarlySuccess") : t("retrySettlementSuccess"));
|
||||||
await load();
|
await Promise.all([load(), refreshDraw()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: t("runSettlement") }));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: t("runSettlement") }));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -93,6 +92,18 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
void load();
|
void load();
|
||||||
}, [idNum]);
|
}, [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) {
|
if (loading && !data) {
|
||||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
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 currencyCode = data.currency_code ?? "NPR";
|
||||||
const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
|
const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
|
||||||
|
const drawStatus = draw?.status ?? data.draw_status;
|
||||||
|
const settlementActionLabel = drawStatus === "cooldown" ? t("settleEarly") : t("retrySettlement");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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()}>
|
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||||
{t("actions.refresh", { ns: "common" })}
|
{t("actions.refresh", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
|
{canRunSettlement && ["cooldown", "settling"].includes(drawStatus) ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canRunSettlement || settling || data.draw_status !== "settling"}
|
disabled={settling}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestConfirm({
|
requestConfirm({
|
||||||
title: t("confirm.runSettlementTitle"),
|
title: drawStatus === "cooldown"
|
||||||
description: t("confirm.runSettlementDescription"),
|
? t("confirm.settleEarlyTitle")
|
||||||
|
: t("confirm.retrySettlementTitle"),
|
||||||
|
description: drawStatus === "cooldown"
|
||||||
|
? t("confirm.settleEarlyDescription")
|
||||||
|
: t("confirm.retrySettlementDescription"),
|
||||||
onConfirm: () => runSettlement(),
|
onConfirm: () => runSettlement(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{settling ? t("processing") : t("runSettlement")}
|
{settling ? t("processing") : settlementActionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
) : (
|
||||||
href="/admin/settlement-batches"
|
<p className="self-center text-xs text-muted-foreground">
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
{canRunSettlement
|
||||||
>
|
? t("settlementUnavailable", { status: t(`statusOptions.${drawStatus}`) })
|
||||||
{t("settlementBatchList")}
|
: t("settlementManagePermissionRequired")}
|
||||||
</Link>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border/60">
|
<div className="rounded-lg border border-border/60">
|
||||||
<div className="border-b border-border/60 px-3 py-2.5">
|
<div className="border-b border-border/60 px-3 py-2.5">
|
||||||
<h2 className="text-sm font-semibold">{t("relatedSettlementBatches")}</h2>
|
<h2 className="text-sm font-semibold">{t("relatedSettlementBatches")}</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t("settlementBatchesHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
{data.settlement_batches.length === 0 ? (
|
{data.settlement_batches.length === 0 ? (
|
||||||
@@ -174,6 +194,9 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||||
|
<TableHead>{t("provider")}</TableHead>
|
||||||
|
<TableHead className="text-center">{t("resultVersion")}</TableHead>
|
||||||
|
<TableHead className="text-center">{t("settleVersion")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
<TableHead className="text-center">{t("ticketCount")}</TableHead>
|
<TableHead className="text-center">{t("ticketCount")}</TableHead>
|
||||||
<TableHead className="text-center">{t("winCount")}</TableHead>
|
<TableHead className="text-center">{t("winCount")}</TableHead>
|
||||||
@@ -185,7 +208,24 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{data.settlement_batches.map((b) => (
|
{data.settlement_batches.map((b) => (
|
||||||
<TableRow key={b.id}>
|
<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>
|
<TableCell>
|
||||||
<AdminStatusBadge status={b.status}>
|
<AdminStatusBadge status={b.status}>
|
||||||
{settlementBatchStatusLabel(b.status, t)}
|
{settlementBatchStatusLabel(b.status, t)}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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 ? (
|
{published.length === 0 ? (
|
||||||
<AdminNoResourceState message={t("noPublishedBatch")} />
|
<AdminNoResourceState message={t("noPublishedBatch")} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -181,8 +181,12 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
|||||||
return <AdminNoResourceState />;
|
return <AdminNoResourceState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canEnterManualResults =
|
||||||
|
canManageDraw && ["closed", "review"].includes(data.draw_status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{pending.length > 0 || canEnterManualResults ? (
|
||||||
<div className="rounded-lg border border-border/60">
|
<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">
|
<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>
|
<h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
|
||||||
@@ -244,8 +248,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canManageDraw ? (
|
{canEnterManualResults ? (
|
||||||
<div className="rounded-lg border border-border/60">
|
<div className="rounded-lg border border-border/60">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -330,7 +335,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="h-8"
|
||||||
disabled={savingManual || !manualProviderCode || !["closed", "review"].includes(data.draw_status)}
|
disabled={savingManual || !manualProviderCode}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestConfirm({
|
requestConfirm({
|
||||||
title: t("confirm.saveManualDraftTitle"),
|
title: t("confirm.saveManualDraftTitle"),
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
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 { canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false },
|
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false },
|
||||||
{ suffix: "/results", key: "results", label: "subnav.results", 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: "/finance", key: "finance", label: "subnav.finance", requiresManage: false },
|
||||||
{
|
{
|
||||||
suffix: "/risk/pools",
|
suffix: "/risk/pools",
|
||||||
@@ -49,6 +50,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
|||||||
const canViewDraw = canViewDrawResults(perms);
|
const canViewDraw = canViewDrawResults(perms);
|
||||||
const canViewFinance = canViewDrawFinance(perms);
|
const canViewFinance = canViewDrawFinance(perms);
|
||||||
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
|
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
|
||||||
|
const canViewTickets = adminHasAnyPermission(perms, [...PRD_TICKETS_ACCESS_ANY]);
|
||||||
|
|
||||||
const visibleSegments = useMemo(
|
const visibleSegments = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -59,13 +61,16 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
|||||||
if (segment.key === "finance" && !canViewFinance) {
|
if (segment.key === "finance" && !canViewFinance) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (segment.key === "tickets" && !canViewTickets) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if ("requiresRisk" in segment && segment.requiresRisk && !canViewRisk) {
|
if ("requiresRisk" in segment && segment.requiresRisk && !canViewRisk) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[canViewDraw, canViewFinance, canViewRisk],
|
[canViewDraw, canViewFinance, canViewRisk, canViewTickets],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AdminPageGuideDialog } from "@/components/admin/admin-page-guide-dialog";
|
||||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||||
@@ -37,6 +38,7 @@ export function JackpotConfigScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-4">
|
<div className="flex w-full max-w-none flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<AdminSubnav aria-label={t("pageTabs")}>
|
<AdminSubnav aria-label={t("pageTabs")}>
|
||||||
<AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
|
<AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
|
||||||
{t("tabConfig")}
|
{t("tabConfig")}
|
||||||
@@ -45,6 +47,41 @@ export function JackpotConfigScreen() {
|
|||||||
{t("tabRecords")}
|
{t("tabRecords")}
|
||||||
</AdminSubnavButton>
|
</AdminSubnavButton>
|
||||||
</AdminSubnav>
|
</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 />}
|
{tab === "config" ? <JackpotPoolsConsole embedded /> : <JackpotRecordsConsole embedded />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||||
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
|
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 { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
@@ -273,9 +277,14 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex 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")}
|
{t("status")}
|
||||||
</Label>
|
</AdminFieldLabel>
|
||||||
<Switch
|
<Switch
|
||||||
id={`status-${p.id}`}
|
id={`status-${p.id}`}
|
||||||
checked={statusOn}
|
checked={statusOn}
|
||||||
@@ -304,11 +313,27 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
</div>
|
</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">
|
<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
|
<Input
|
||||||
id={`cr-${p.id}`}
|
id={`cr-${p.id}`}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -316,21 +341,35 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
max={100}
|
max={100}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
disabled={!canManageJackpot}
|
||||||
value={d.contribution_rate}
|
value={d.contribution_rate}
|
||||||
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
id={`th-${p.id}`}
|
id={`th-${p.id}`}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
disabled={!canManageJackpot}
|
||||||
value={d.trigger_threshold}
|
value={d.trigger_threshold}
|
||||||
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
id={`pr-${p.id}`}
|
id={`pr-${p.id}`}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -338,31 +377,53 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
max={100}
|
max={100}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
disabled={!canManageJackpot}
|
||||||
value={d.payout_rate}
|
value={d.payout_rate}
|
||||||
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
id={`min-${p.id}`}
|
id={`min-${p.id}`}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
disabled={!canManageJackpot}
|
||||||
value={d.min_bet_amount}
|
value={d.min_bet_amount}
|
||||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 sm:col-span-2 lg:col-span-2">
|
<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
|
<Input
|
||||||
id={`gap-${p.id}`}
|
id={`gap-${p.id}`}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
disabled={!canManageJackpot}
|
||||||
value={d.force_trigger_draw_gap}
|
value={d.force_trigger_draw_gap}
|
||||||
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 sm:col-span-2 lg:col-span-3">
|
<div className="space-y-2 sm:col-span-2 lg:col-span-3">
|
||||||
<div>
|
<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>
|
<p className="text-xs text-muted-foreground">{t("comboTriggerPlaysHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
{playOptions.length === 0 ? (
|
{playOptions.length === 0 ? (
|
||||||
@@ -417,6 +478,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
|
|
||||||
{canManageJackpot ? (
|
{canManageJackpot ? (
|
||||||
<div className="mt-4 border-t border-border/60 pt-3">
|
<div className="mt-4 border-t border-border/60 pt-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-sm font-medium text-foreground hover:underline"
|
className="text-sm font-medium text-foreground hover:underline"
|
||||||
@@ -424,10 +486,22 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
>
|
>
|
||||||
{t("balanceAdjustmentTitle")}
|
{t("balanceAdjustmentTitle")}
|
||||||
</button>
|
</button>
|
||||||
|
<AdminHelpIcon
|
||||||
|
helpId={`balance-adjustment-${p.id}`}
|
||||||
|
helpText={t("help.fields.balanceAdjustment")}
|
||||||
|
helpAriaLabel={t("help.aria", { field: t("balanceAdjustmentTitle") })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{adjustmentOpen ? (
|
{adjustmentOpen ? (
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<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
|
<Select
|
||||||
value={adj.direction}
|
value={adj.direction}
|
||||||
onValueChange={(value: "increase" | "decrease" | null) => {
|
onValueChange={(value: "increase" | "decrease" | null) => {
|
||||||
@@ -435,7 +509,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
updateAdjustmentDraft(p.id, { direction: value });
|
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>
|
<SelectValue>
|
||||||
{(value) =>
|
{(value) =>
|
||||||
value === "increase"
|
value === "increase"
|
||||||
@@ -453,7 +527,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
id={`adj-amt-${p.id}`}
|
id={`adj-amt-${p.id}`}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
@@ -462,7 +542,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 sm:col-span-2">
|
<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
|
<Textarea
|
||||||
id={`adj-reason-${p.id}`}
|
id={`adj-reason-${p.id}`}
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -512,7 +598,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
{canManualBurst ? (
|
{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="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">
|
<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
|
<Input
|
||||||
id={`burst-draw-${p.id}`}
|
id={`burst-draw-${p.id}`}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export function ReportsConsole({ initialCategory = "profit" }: ReportsConsolePro
|
|||||||
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
|
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
|
||||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||||
setDisplayCurrency(currencyCode);
|
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({
|
setResult({
|
||||||
key: "player_win_loss",
|
key: "player_win_loss",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
postAdminRiskPoolRecover,
|
postAdminRiskPoolRecover,
|
||||||
} from "@/api/admin-risk";
|
} from "@/api/admin-risk";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
@@ -189,6 +190,12 @@ export function RiskPoolsConsole({
|
|||||||
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
|
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<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-toolbar">
|
||||||
<div className="admin-list-field">
|
<div className="admin-list-field">
|
||||||
<Label htmlFor="risk-pool-number" className="sm:w-20 sm:shrink-0">
|
<Label htmlFor="risk-pool-number" className="sm:w-20 sm:shrink-0">
|
||||||
@@ -291,6 +298,16 @@ export function RiskPoolsConsole({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading && !data ? <AdminTableLoadingRow colSpan={8} /> : null}
|
{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) => {
|
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
|
||||||
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
|
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
|
||||||
const acting = actingKey === `${row.provider_code}:${row.normalized_number}`;
|
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 { t } = useTranslation(["tickets", "common"]);
|
||||||
const tRef = useTranslationRef(["tickets", "common"]);
|
const tRef = useTranslationRef(["tickets", "common"]);
|
||||||
const playCodeLabel = useAdminPlayCodeLabel();
|
const playCodeLabel = useAdminPlayCodeLabel();
|
||||||
@@ -147,7 +151,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||||
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
const drawNoFromUrl = fixedDrawNo ?? (searchParams.get("draw_no") ?? "").trim();
|
||||||
const numberKeywordFromUrl = (searchParams.get("number") ?? "").trim();
|
const numberKeywordFromUrl = (searchParams.get("number") ?? "").trim();
|
||||||
const providerCodeFromUrl = (searchParams.get("provider_code") ?? "").trim().toUpperCase();
|
const providerCodeFromUrl = (searchParams.get("provider_code") ?? "").trim().toUpperCase();
|
||||||
const initialFilters: TicketFilters = {
|
const initialFilters: TicketFilters = {
|
||||||
@@ -184,7 +188,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
page,
|
page,
|
||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
...query,
|
...query,
|
||||||
draw_no: applied.drawNo.trim() || undefined,
|
draw_no: fixedDrawNo ?? (applied.drawNo.trim() || undefined),
|
||||||
provider_code: applied.providerCode.trim().toUpperCase() || undefined,
|
provider_code: applied.providerCode.trim().toUpperCase() || undefined,
|
||||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||||
number: applied.numberKeyword.trim() || undefined,
|
number: applied.numberKeyword.trim() || undefined,
|
||||||
@@ -198,7 +202,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [applied, page, perPage, tRef]);
|
}, [applied, fixedDrawNo, page, perPage, tRef]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -221,7 +225,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
setApplied({
|
setApplied({
|
||||||
...draft,
|
...draft,
|
||||||
playerQuery: draft.playerQuery.trim(),
|
playerQuery: draft.playerQuery.trim(),
|
||||||
drawNo: draft.drawNo.trim(),
|
drawNo: fixedDrawNo ?? draft.drawNo.trim(),
|
||||||
providerCode: draft.providerCode.trim().toUpperCase(),
|
providerCode: draft.providerCode.trim().toUpperCase(),
|
||||||
numberKeyword: draft.numberKeyword.trim(),
|
numberKeyword: draft.numberKeyword.trim(),
|
||||||
});
|
});
|
||||||
@@ -229,8 +233,9 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setDraft(emptyTicketFilters);
|
const reset = { ...emptyTicketFilters, drawNo: fixedDrawNo ?? "" };
|
||||||
setApplied(emptyTicketFilters);
|
setDraft(reset);
|
||||||
|
setApplied(reset);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
@@ -268,7 +273,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TicketFilterField>
|
</TicketFilterField>
|
||||||
<TicketFilterField id="pt-draw" label={t("drawNoOptional")}>
|
{!fixedDrawNo ? <TicketFilterField id="pt-draw" label={t("drawNoOptional")}>
|
||||||
<Input
|
<Input
|
||||||
id="pt-draw"
|
id="pt-draw"
|
||||||
className="h-8 w-full font-mono"
|
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")}>
|
<TicketFilterField id="pt-number" label={t("numberKeyword")}>
|
||||||
<Input
|
<Input
|
||||||
id="pt-number"
|
id="pt-number"
|
||||||
@@ -328,7 +333,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</TicketFilterField>
|
</TicketFilterField>
|
||||||
<TicketFilterField id="pt-date-range" label={t("placedDateRange")}>
|
{!fixedDrawNo ? <TicketFilterField id="pt-date-range" label={t("placedDateRange")}>
|
||||||
<AdminDateRangeField
|
<AdminDateRangeField
|
||||||
id="pt-date-range"
|
id="pt-date-range"
|
||||||
from={draft.startDate}
|
from={draft.startDate}
|
||||||
@@ -341,7 +346,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TicketFilterField>
|
</TicketFilterField> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
|
<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"
|
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"
|
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}
|
title={value}
|
||||||
aria-label={copyHint ?? t("copyTxnNo")}
|
aria-label={`${copyHint ?? t("copyTxnNo")} ${value}`}
|
||||||
onClick={(e) => void copy(e)}
|
onClick={(e) => void copy(e)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{value}</span>
|
<span className="min-w-0 flex-1 truncate">{value}</span>
|
||||||
|
|||||||
@@ -408,7 +408,13 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<AdminAgentIdentityCells row={row} />
|
<AdminAgentIdentityCells row={row} />
|
||||||
<AdminPlayerIdentityCells 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")}>
|
<TableCell className={adminMoneyCellClassName("text-right")}>
|
||||||
<AdminTableMoney>
|
<AdminTableMoney>
|
||||||
{formatAdminMinorUnits(row.amount, row.currency_code)}
|
{formatAdminMinorUnits(row.amount, row.currency_code)}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
|||||||
set({ adminProfile: result.admin });
|
set({ adminProfile: result.admin });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAdminAuthRejected(err)) {
|
if (isAdminAuthRejected(err)) {
|
||||||
handleAdminAuthRejected();
|
handleAdminAuthRejected(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export type AdminDrawFinanceSettlementBatchRow = {
|
export type AdminDrawFinanceSettlementBatchRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
provider_code: string | null;
|
||||||
|
provider_name: string | null;
|
||||||
|
result_version: number | null;
|
||||||
|
settle_version: number;
|
||||||
status: string;
|
status: string;
|
||||||
total_ticket_count: number;
|
total_ticket_count: number;
|
||||||
total_win_count: number;
|
total_win_count: number;
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export type AdminSettlementRunResponse = {
|
|||||||
draw_no: string;
|
draw_no: string;
|
||||||
status: string;
|
status: string;
|
||||||
settle_version: number;
|
settle_version: number;
|
||||||
|
cooldown_skipped: boolean;
|
||||||
|
cooling_end_time: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminSettlementWorkflowResponse = {
|
export type AdminSettlementWorkflowResponse = {
|
||||||
|
|||||||
Reference in New Issue
Block a user