feat(admin): enhance operations guidance and session handling
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

This commit is contained in:
wchino
2026-07-22 20:53:43 +08:00
parent 98a3d0b68e
commit 85ad369cf9
55 changed files with 1477 additions and 342 deletions

View File

@@ -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,
}, },

View File

@@ -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"

View File

@@ -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>
); );
} }

View 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>
);
}

View File

@@ -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",
}; };

View File

@@ -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>
); );
} }

View File

@@ -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],
}), }),
); );

View File

@@ -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",

View 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>
);
}

View File

@@ -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" />

View File

@@ -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" />;
} }

View File

@@ -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();

View File

@@ -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>
); );

View File

@@ -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

View File

@@ -69,7 +69,6 @@ if (!i18n.isInitialized) {
}, },
}); });
syncAdminLanguage(initialLanguage);
i18n.on("languageChanged", (lang) => { i18n.on("languageChanged", (lang) => {
syncAdminLanguage(normalizeAdminLanguage(lang)); syncAdminLanguage(normalizeAdminLanguage(lang));
}); });

View File

@@ -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",

View File

@@ -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",

View File

@@ -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?",

View File

@@ -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."
}
}
} }

View File

@@ -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",

View File

@@ -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": "क्रेडिट सेटलमेन्ट",

View File

@@ -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": "प्रणाली सेटिङ लोड असफल भयो",

View File

@@ -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": "टिकट",

View File

@@ -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 प्रयोग गर्न सक्छ।"
}
}
} }

View File

@@ -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": "टिकट",

View File

@@ -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": "取消",

View File

@@ -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": {

View File

@@ -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": "确认发布开奖结果?",

View File

@@ -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": "奖池配置没有草稿和发布流程,点击保存后立即用于后续蓄水与尚未完成的奖池结算。已经卖票但尚未结算的期号,也可能受到新触发参数和派彩比例影响。"
}
}
} }

View File

@@ -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": "预警",

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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",

View File

@@ -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;

View File

@@ -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>
@@ -304,4 +308,4 @@ export function AuditLogsConsole(): React.ReactElement {
</CardContent> </CardContent>
</Card> </Card>
); );
} }

View File

@@ -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>
); );

View File

@@ -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,18 +745,63 @@ export function OddsConfigDocScreen({
/> />
} }
actions={ actions={
<ConfigVersionActions <>
isDraft={isDraft} <AdminPageGuideDialog
canManage={canManage} triggerLabel={t("odds.help.openGuide", { ns: "config" })}
loadingList={resolvedLoadingList} title={t("odds.help.guideTitle", { ns: "config" })}
loadingDetail={resolvedLoadingDetail} description={t("odds.help.guideDescription", { ns: "config" })}
saving={saving} sections={[
suppressDraftActions={mergedLayout && isDraft} {
onRefresh={() => void refreshList()} title: t("odds.help.guide.scopeTitle", { ns: "config" }),
onNewDraft={() => void handleNewDraft()} description: t("odds.help.guide.scopeDescription", { ns: "config" }),
onSaveDraft={() => void handleSave()} items: [
onPublish={() => void requestPublishConfirm()} {
/> label: t("betProviders.title", { ns: "config" }),
description: t("odds.help.fields.provider", { ns: "config" }),
},
{
label: t("odds.category", { ns: "config" }),
description: t("odds.help.fields.category", { ns: "config" }),
},
{
label: t("odds.playType", { ns: "config" }),
description: t("odds.help.fields.playType", { ns: "config" }),
},
],
},
{
title: t("odds.help.guide.settingsTitle", { ns: "config" }),
description: t("odds.help.guide.settingsDescription", { ns: "config" }),
items: [
{
label: t("odds.table.multiplier", { ns: "config" }),
description: t("odds.help.fields.multiplier", { ns: "config" }),
},
{
label: t("odds.rebateRate", { ns: "config" }),
description: t("odds.help.fields.rebateRate", { ns: "config" }),
},
],
},
{
title: t("odds.help.guide.workflowTitle", { ns: "config" }),
description: t("odds.help.guide.workflowDescription", { ns: "config" }),
},
]}
/>
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={resolvedLoadingList}
loadingDetail={resolvedLoadingDetail}
saving={saving}
suppressDraftActions={mergedLayout && isDraft}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void requestPublishConfirm()}
/>
</>
} }
/> />
); );
@@ -743,10 +828,21 @@ export function OddsConfigDocScreen({
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => ( {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)}

View File

@@ -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}

View File

@@ -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,25 +491,68 @@ export function PlayConfigDocScreen() {
/> />
} }
actions={ actions={
<ConfigVersionActions <>
isDraft={isDraft} <AdminPageGuideDialog
canManage={canManage} triggerLabel={t("play.help.openGuide", { ns: "config" })}
loadingList={loadingList} title={t("play.help.guideTitle", { ns: "config" })}
loadingDetail={loadingDetail} description={t("play.help.guideDescription", { ns: "config" })}
saving={saving} sections={[
onRefresh={() => void refreshList()} {
onNewDraft={() => void handleNewDraft()} title: t("play.help.guide.settingsTitle", { ns: "config" }),
onSaveDraft={() => void handleSaveDraft()} description: t("play.help.guide.settingsDescription", { ns: "config" }),
onPublish={() => items: [
requestConfirm({ {
title: t("play.publishDialog.title", { ns: "config" }), label: t("play.table.status", { ns: "config" }),
description: t("play.publishDialog.description", { ns: "config" }), description: t("play.help.fields.status", { ns: "config" }),
confirmLabel: t("play.publishDialog.confirm", { ns: "config" }), },
confirmVariant: "destructive", {
onConfirm: () => handlePublish(), label: t("play.table.displayName", { ns: "config" }),
}) description: t("play.help.fields.displayName", { ns: "config" }),
} },
/> {
label: t("play.table.order", { ns: "config" }),
description: t("play.help.fields.order", { ns: "config" }),
},
{
label: t("play.table.minBet", { ns: "config" }),
description: t("play.help.fields.minBet", { ns: "config" }),
},
{
label: t("play.table.maxBet", { ns: "config" }),
description: t("play.help.fields.maxBet", { ns: "config" }),
},
],
},
{
title: t("play.help.guide.batchTitle", { ns: "config" }),
description: t("play.help.guide.batchDescription", { ns: "config" }),
},
{
title: t("play.help.guide.workflowTitle", { ns: "config" }),
description: t("play.help.guide.workflowDescription", { ns: "config" }),
},
]}
/>
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSaveDraft()}
onPublish={() =>
requestConfirm({
title: t("play.publishDialog.title", { ns: "config" }),
description: t("play.publishDialog.description", { ns: "config" }),
confirmLabel: t("play.publishDialog.confirm", { ns: "config" }),
confirmVariant: "destructive",
onConfirm: () => handlePublish(),
})
}
/>
</>
} }
/> />
} }
@@ -578,17 +623,43 @@ export function PlayConfigDocScreen() {
</div> </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>

View File

@@ -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,68 +277,45 @@ 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} </div>
{canManageDraw && pendingReview > 0 ? ( ) : null}
<Link </section>
href={`/admin/draws/${drawId}/results`}
className="rounded-md bg-amber-500/15 px-2 py-1 font-medium text-amber-800 dark:text-amber-200"
>
{t("batchSummaryPending", { count: pendingReview })}
</Link>
) : null}
{batch.published > 0 ? (
<Link
href={`/admin/draws/${drawId}/results`}
className="rounded-md bg-emerald-500/15 px-2 py-1 font-medium text-emerald-800 dark:text-emerald-200"
>
{t("batchSummaryPublished", { count: batch.published })}
</Link>
) : null}
{data.capabilities?.can_view_draw_finance !== false ? (
<Link
href={`/admin/draws/${drawId}/finance`}
className="text-sm font-medium text-primary hover:underline"
>
{t("viewFinance")}
</Link>
) : null}
</div>
) : canManageDraw ? (
<Link
href={`/admin/draws/${drawId}/results`}
className="text-sm font-medium text-primary hover:underline"
>
{t("goToReviewTab")}
</Link>
) : null}
{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">
{availableActions.map((action) => ( <div className="flex flex-wrap gap-2">
<Button {availableActions.map((action) => (
key={action.key} <Button
type="button" key={action.key}
size="sm" type="button"
variant={action.variant} size="sm"
disabled={acting !== null} variant={action.variant}
onClick={() => disabled={acting !== null}
requestConfirm({ onClick={() =>
title: action.confirmTitle, requestConfirm({
description: action.confirmDescription, title: action.confirmTitle,
confirmVariant: action.confirmVariant, description: action.confirmDescription,
onConfirm: () => runAction(action.label, action.onConfirm), confirmVariant: action.confirmVariant,
}) onConfirm: () => runAction(action.label, action.onConfirm),
} })
> }
{acting === action.label ? t("processing") : action.label} >
</Button> {acting === action.label ? t("processing") : action.label}
))} </Button>
))}
</div>
{data.status === "cooldown" && canReopenDraw ? (
<p className="text-xs text-muted-foreground">{t("reopenUsageHint")}</p>
) : null}
</div> </div>
) : null} ) : null}

View File

@@ -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>
<Button {canRunSettlement && ["cooldown", "settling"].includes(drawStatus) ? (
type="button" <Button
size="sm" type="button"
disabled={!canRunSettlement || settling || data.draw_status !== "settling"} size="sm"
onClick={() => disabled={settling}
requestConfirm({ onClick={() =>
title: t("confirm.runSettlementTitle"), requestConfirm({
description: t("confirm.runSettlementDescription"), title: drawStatus === "cooldown"
onConfirm: () => runSettlement(), ? t("confirm.settleEarlyTitle")
}) : t("confirm.retrySettlementTitle"),
} description: drawStatus === "cooldown"
> ? t("confirm.settleEarlyDescription")
{settling ? t("processing") : t("runSettlement")} : t("confirm.retrySettlementDescription"),
</Button> onConfirm: () => runSettlement(),
<Link })
href="/admin/settlement-batches" }
className={cn(buttonVariants({ variant: "outline", size: "sm" }))} >
> {settling ? t("processing") : settlementActionLabel}
{t("settlementBatchList")} </Button>
</Link> ) : (
<p className="self-center text-xs text-muted-foreground">
{canRunSettlement
? t("settlementUnavailable", { status: t(`statusOptions.${drawStatus}`) })
: t("settlementManagePermissionRequired")}
</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)}

View File

@@ -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")} />
) : ( ) : (

View File

@@ -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"),

View File

@@ -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 (

View 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} />;
}

View File

@@ -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,16 +38,52 @@ 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">
<AdminSubnav aria-label={t("pageTabs")}> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}> <AdminSubnav aria-label={t("pageTabs")}>
{t("tabConfig")} <AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
</AdminSubnavButton> {t("tabConfig")}
<AdminSubnavButton active={tab === "records"} onClick={() => switchTab("records")}> </AdminSubnavButton>
{t("tabRecords")} <AdminSubnavButton active={tab === "records"} onClick={() => switchTab("records")}>
</AdminSubnavButton> {t("tabRecords")}
</AdminSubnav> </AdminSubnavButton>
</AdminSubnav>
<AdminPageGuideDialog
triggerLabel={t("help.openGuide")}
title={t("help.guideTitle")}
description={t("help.guideDescription")}
sections={[
{
title: t("help.guide.settingsTitle"),
description: t("help.guide.settingsDescription"),
items: [
{ label: t("currentAmount"), description: t("help.fields.currentAmount") },
{ label: t("status"), description: t("help.fields.status") },
{ label: t("contributionRate"), description: t("help.fields.contributionRate") },
{ label: t("minBetAmount"), description: t("help.fields.minBetAmount") },
{ label: t("triggerThreshold"), description: t("help.fields.triggerThreshold") },
{ label: t("payoutRate"), description: t("help.fields.payoutRate") },
{ label: t("forceTriggerGap"), description: t("help.fields.forceTriggerGap") },
{ label: t("comboTriggerPlays"), description: t("help.fields.comboTriggerPlays") },
],
},
{
title: t("help.guide.actionsTitle"),
description: t("help.guide.actionsDescription"),
items: [
{ label: t("balanceAdjustmentTitle"), description: t("help.fields.balanceAdjustment") },
{ label: t("manualBurst"), description: t("help.fields.manualBurst") },
{ label: t("tabRecords"), description: t("help.fields.records") },
],
},
{
title: t("help.guide.effectiveTitle"),
description: t("help.guide.effectiveDescription"),
},
]}
/>
</div>
{tab === "config" ? <JackpotPoolsConsole embedded /> : <JackpotRecordsConsole embedded />} {tab === "config" ? <JackpotPoolsConsole embedded /> : <JackpotRecordsConsole embedded />}
</div> </div>
); );
} }

View File

@@ -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,17 +478,30 @@ 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">
<button <div className="flex items-center gap-1">
type="button" <button
className="text-sm font-medium text-foreground hover:underline" type="button"
onClick={() => toggleAdjustment(p.id, !adjustmentOpen)} className="text-sm font-medium text-foreground hover:underline"
> onClick={() => toggleAdjustment(p.id, !adjustmentOpen)}
{t("balanceAdjustmentTitle")} >
</button> {t("balanceAdjustmentTitle")}
</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"

View File

@@ -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,

View File

@@ -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}`;

View File

@@ -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">

View File

@@ -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>
@@ -49,4 +49,4 @@ export function WalletCellMonoId({
/> />
</button> </button>
); );
} }

View File

@@ -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)}

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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 = {