feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support

Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
2026-06-02 14:37:08 +08:00
parent a4e7a2d228
commit b15e377187
105 changed files with 5305 additions and 1596 deletions

113
src/api/admin-agents.ts Normal file
View File

@@ -0,0 +1,113 @@
import { adminRequest } from "@/lib/admin-http";
import type { AdminRoleRow } from "@/types/api/admin-user";
import type {
AgentAdminUserCreatePayload,
AgentAdminUserListData,
AgentAdminUserRoleSyncPayload,
AgentNodeCreatePayload,
AgentNodeRow,
AgentNodeUpdatePayload,
AgentRoleCreatePayload,
AgentRoleListData,
AgentTreeData,
AgentDelegationGrantsData,
AgentDelegationGrantSyncPayload,
} from "@/types/api/admin-agent";
import type { AdminUserPermissionRow } from "@/types/api/admin-user";
const A = `/admin`;
export async function getAgentTree(adminSiteId?: number): Promise<AgentTreeData> {
return adminRequest.get<AgentTreeData>(`${A}/agent-nodes/tree`, {
params: adminSiteId ? { admin_site_id: adminSiteId } : undefined,
});
}
export async function postAgentNode(body: AgentNodeCreatePayload): Promise<AgentNodeRow> {
return adminRequest.post<AgentNodeRow>(`${A}/agent-nodes`, body);
}
export async function putAgentNode(
agentNodeId: number,
body: AgentNodeUpdatePayload,
): Promise<AgentNodeRow> {
return adminRequest.put<AgentNodeRow>(`${A}/agent-nodes/${agentNodeId}`, body);
}
export async function deleteAgentNode(agentNodeId: number): Promise<null> {
return adminRequest.delete<null>(`${A}/agent-nodes/${agentNodeId}`);
}
export async function getAgentNodeRoles(agentNodeId: number): Promise<AgentRoleListData> {
return adminRequest.get<AgentRoleListData>(`${A}/agent-nodes/${agentNodeId}/roles`);
}
export async function postAgentRole(
agentNodeId: number,
body: AgentRoleCreatePayload,
): Promise<AdminRoleRow> {
return adminRequest.post<AdminRoleRow>(`${A}/agent-nodes/${agentNodeId}/roles`, body);
}
export async function putAgentRole(
roleId: number,
body: { name?: string; description?: string | null; status?: number },
): Promise<AdminRoleRow> {
return adminRequest.put<AdminRoleRow>(`${A}/agent-roles/${roleId}`, body);
}
export async function putAgentRolePermissions(
roleId: number,
permissionSlugs: string[],
): Promise<AdminRoleRow> {
return adminRequest.put<AdminRoleRow>(`${A}/agent-roles/${roleId}/permissions`, {
permission_slugs: permissionSlugs,
});
}
export async function deleteAgentRole(roleId: number): Promise<{ deleted: boolean; id: number }> {
return adminRequest.delete<{ deleted: boolean; id: number }>(`${A}/agent-roles/${roleId}`);
}
export async function getAgentNodeAdminUsers(agentNodeId: number): Promise<AgentAdminUserListData> {
return adminRequest.get<AgentAdminUserListData>(`${A}/agent-nodes/${agentNodeId}/admin-users`);
}
export async function postAgentAdminUser(
agentNodeId: number,
body: AgentAdminUserCreatePayload,
): Promise<AdminUserPermissionRow> {
return adminRequest.post<AdminUserPermissionRow>(
`${A}/agent-nodes/${agentNodeId}/admin-users`,
body,
);
}
export async function putAgentAdminUserRoles(
adminUserId: number,
body: AgentAdminUserRoleSyncPayload,
): Promise<AdminUserPermissionRow> {
return adminRequest.put<AdminUserPermissionRow>(
`${A}/agent-admin-users/${adminUserId}/roles`,
body,
);
}
export async function getAgentDelegationGrants(
agentNodeId: number,
): Promise<AgentDelegationGrantsData> {
return adminRequest.get<AgentDelegationGrantsData>(
`${A}/agent-nodes/${agentNodeId}/delegation-grants`,
);
}
export async function putAgentDelegationGrants(
agentNodeId: number,
body: AgentDelegationGrantSyncPayload,
): Promise<AgentDelegationGrantsData> {
return adminRequest.put<AgentDelegationGrantsData>(
`${A}/agent-nodes/${agentNodeId}/delegation-grants`,
body,
);
}

View File

@@ -22,6 +22,7 @@ export type AdminDrawListQuery = {
per_page?: number;
draw_no?: string;
status?: string;
agent_node_id?: number;
};
export async function getAdminDraws(q: AdminDrawListQuery = {}): Promise<AdminDrawListData> {

View File

@@ -17,6 +17,7 @@ export async function getAdminPlayers(params?: {
keyword?: string;
status?: number;
site_code?: string;
agent_node_id?: number;
}): Promise<AdminPlayerListData> {
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
}

View File

@@ -27,3 +27,14 @@ export async function updateAdminSetting(
): Promise<AdminSettingItem> {
return adminRequest.put<AdminSettingItem>(`${A}/settings/${key}`, { value });
}
export type AdminSettingBatchItem = {
key: string;
value: unknown;
};
export async function updateAdminSettingsBatch(
items: AdminSettingBatchItem[],
): Promise<AdminSettingListResponse> {
return adminRequest.put<AdminSettingListResponse>(`${A}/settings/batch`, { items });
}

View File

@@ -18,6 +18,7 @@ export type AdminSettlementBatchListQuery = {
per_page?: number;
draw_no?: string;
status?: string;
agent_node_id?: number;
};
export async function getAdminSettlementBatches(
@@ -33,6 +34,7 @@ export async function getAdminSettlementBatch(batchId: number): Promise<AdminSet
export type AdminSettlementBatchDetailsQuery = {
page?: number;
per_page?: number;
agent_node_id?: number;
};
export async function getAdminSettlementBatchDetails(

View File

@@ -11,6 +11,7 @@ export type TicketItemsListQuery = {
player_id?: number;
player_account?: string;
site_code?: string;
agent_node_id?: number;
draw_no?: string;
status?: string[];
number?: string;

View File

@@ -22,6 +22,8 @@ export type TransferOrderListQuery = {
status?: string;
/** 仅异常processing / failed / pending_reconcile */
abnormal?: boolean;
site_code?: string;
agent_node_id?: number;
};
export async function getAdminTransferOrders(
@@ -45,6 +47,8 @@ export type WalletTransactionListQuery = {
biz_type?: string;
status?: string;
abnormal?: boolean;
site_code?: string;
agent_node_id?: number;
};
export async function getAdminWalletTransactions(

View File

@@ -0,0 +1,18 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { AgentsConsole } from "@/modules/agents/agents-console";
import { PRD_AGENTS_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
export const metadata: Metadata = buildPageMetadata("agents", "title");
export default function AgentsPage() {
return (
<ModuleScaffold>
<AdminPermissionGate requiredAny={PRD_AGENTS_ACCESS_ANY}>
<AgentsConsole />
</AdminPermissionGate>
</ModuleScaffold>
);
}

View File

@@ -46,6 +46,7 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--animate-loading-dot-bounce: loading-dot-bounce 0.9s ease-in-out infinite;
}
:root {
@@ -208,3 +209,18 @@
text-align: center;
}
}
@keyframes loading-dot-bounce {
0%,
70%,
100% {
transform: translateY(0);
opacity: 0.35;
}
35% {
transform: translateY(-48%);
opacity: 1;
}
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useTranslation } from "react-i18next";
import { TableCell, TableHead } from "@/components/ui/table";
import { cn } from "@/lib/utils";
export type AdminAgentFields = {
agent_node_id?: number | null;
agent_code?: string | null;
agent_name?: string | null;
};
function cellText(value: string | null | undefined): string {
const trimmed = value?.trim() ?? "";
return trimmed !== "" ? trimmed : "—";
}
export function adminAgentDisplayLabel(row: AdminAgentFields): string {
const name = row.agent_name?.trim() ?? "";
const code = row.agent_code?.trim() ?? "";
if (name !== "" && code !== "") {
return `${name} · ${code}`;
}
return name || code || "—";
}
type HeadProps = { className?: string };
type CellProps = { row: AdminAgentFields; className?: string };
export function AdminAgentHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
{t("agentColumns.agent")}
</TableHead>
);
}
export function AdminAgentCell({ row, className }: CellProps): React.ReactElement {
return (
<TableCell className={cn("text-xs", className)}>
<span className="font-medium">{cellText(row.agent_name)}</span>
{row.agent_code ? (
<span className="mt-0.5 block font-mono text-[11px] text-muted-foreground">{row.agent_code}</span>
) : null}
</TableCell>
);
}
export function AdminAgentIdentityHeads({ className }: { className?: string }): React.ReactElement {
return <AdminAgentHead className={className} />;
}
export function AdminAgentIdentityCells({ row, className }: CellProps): React.ReactElement {
return <AdminAgentCell row={row} className={className} />;
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAgentTree } from "@/api/admin-agents";
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAdminProfile } from "@/stores/admin-session";
const ALL = "__all__";
type Props = {
id?: string;
value: number | undefined;
onChange: (agentNodeId: number | undefined) => void;
className?: string;
};
export function AdminAgentFilter({ id = "admin-agent-filter", value, onChange, className }: Props) {
const { t } = useTranslation("common");
const profile = useAdminProfile();
const [options, setOptions] = useState<FlatAgentOption[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
getAgentTree(profile?.agent?.admin_site_id)
.then((data) => {
if (!cancelled) {
setOptions(flattenAgentTree(data.tree));
}
})
.catch(() => {
if (!cancelled) {
setOptions([]);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [profile?.agent?.admin_site_id]);
const selectValue = value ? String(value) : ALL;
return (
<div className={className}>
<Label htmlFor={id} className="text-xs text-muted-foreground">
{t("agentColumns.filter")}
</Label>
<Select
value={selectValue}
onValueChange={(v) => onChange(v === ALL ? undefined : Number(v))}
disabled={loading || options.length === 0}
>
<SelectTrigger id={id} className="mt-1 h-9 w-full min-w-[10rem]">
<SelectValue placeholder={t("agentColumns.filterAll")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t("agentColumns.filterAll")}</SelectItem>
{options.map((opt) => (
<SelectItem key={opt.id} value={String(opt.id)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -35,6 +35,10 @@ const SETTINGS_ROUTE_LABELS: Record<string, string> = {
currencies: "currencies.title",
};
const TOP_ROUTE_LABELS: Record<string, string> = {
agents: "agents.title",
};
const CONFIG_ROUTE_LABELS: Record<string, string> = {
"integration-sites": "integrationSites.title",
plays: "nav.items.plays",
@@ -59,7 +63,7 @@ type BreadcrumbCrumb = {
};
export function AdminBreadcrumb() {
const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports"]);
const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports", "agents"]);
const pathname = usePathname();
const profile = useAdminProfile();
const navItems = profile?.navigation ?? [];
@@ -98,11 +102,14 @@ export function AdminBreadcrumb() {
isCurrent: pathname === navItem.href || segments.length === 2,
});
} else {
const topKey = TOP_ROUTE_LABELS[businessSegment];
breadcrumbs.push({
label: t(`nav.${businessSegment}`, {
ns: "common",
defaultValue: titleCase(businessSegment),
}),
label: topKey
? t(topKey, { ns: "agents", defaultValue: titleCase(businessSegment) })
: t(`nav.${businessSegment}`, {
ns: "common",
defaultValue: titleCase(businessSegment),
}),
href: `${ADMIN_BASE}/${businessSegment}`,
isCurrent: segments.length === 2,
});

View File

@@ -0,0 +1,94 @@
"use client";
import type { ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
LoadingDots,
type LoadingDotsSize,
} from "@/components/ui/loading-dots";
import { TableCell, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
/** 区块居中加载(列表、表单、详情页等) */
export function AdminLoadingState({
className,
label,
size = "md",
showLabel = false,
minHeight = "4rem",
}: {
className?: string;
label?: string;
size?: LoadingDotsSize;
showLabel?: boolean;
minHeight?: string | number;
}): ReactElement {
const { t } = useTranslation("common");
const resolvedLabel = label ?? t("states.loading");
return (
<div
className={cn(
"flex w-full items-center justify-center py-8 text-muted-foreground",
className,
)}
style={{ minHeight: typeof minHeight === "number" ? `${minHeight}px` : minHeight }}
>
<LoadingDots size={size} label={resolvedLabel} showLabel={showLabel} />
</div>
);
}
/** 下拉、弹层等紧凑区域 */
export function AdminLoadingInline({
className,
label,
size = "sm",
}: {
className?: string;
label?: string;
size?: LoadingDotsSize;
}): ReactElement {
const { t } = useTranslation("common");
const resolvedLabel = label ?? t("states.loading");
return (
<div
className={cn("flex justify-center py-3 text-muted-foreground", className)}
role="status"
aria-live="polite"
aria-busy="true"
>
<LoadingDots size={size} label={resolvedLabel} />
</div>
);
}
/** 表格内加载行colSpan 对齐列数) */
export function AdminTableLoadingRow({
colSpan,
label,
size = "md",
className,
cellClassName,
}: {
colSpan: number;
label?: string;
size?: LoadingDotsSize;
className?: string;
cellClassName?: string;
}): ReactElement {
const { t } = useTranslation("common");
const resolvedLabel = label ?? t("states.loading");
return (
<TableRow className={className}>
<TableCell colSpan={colSpan} className={cn("py-10", cellClassName)}>
<div className="flex justify-center">
<LoadingDots size={size} label={resolvedLabel} />
</div>
</TableCell>
</TableRow>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight } from "lucide-react";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import {
ADMIN_NAV_GROUP_ICON,
ADMIN_NAV_GROUP_ORDER,
groupAdminNavItems,
} from "@/lib/admin-nav-groups";
import { adminNavLabel } from "@/lib/admin-nav-label";
import { cn } from "@/lib/utils";
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
import { ADMIN_BASE, type AdminNavGroup, type AdminNavItem } from "@/modules/_config/admin-nav";
const NAV_BTN =
"h-8 gap-2 px-2.5 py-0 text-[13px] leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground [&_svg]:size-4";
const NAV_ACTIVE = "data-active:bg-red-600 data-active:text-white data-active:font-medium data-active:shadow-sm";
const SUB_NAV =
"h-8 min-h-8 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm";
function isActive(
pathname: string,
item: { href: string; activeMatchPrefix?: string; segment?: string },
): boolean {
const { href, activeMatchPrefix, segment } = item;
const prefix = activeMatchPrefix ?? href;
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
}
if (segment === "settings") {
return pathname === href;
}
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
function defaultOpenGroups(
groups: { group: AdminNavGroup; items: AdminNavItem[] }[],
pathname: string,
): Record<AdminNavGroup, boolean> {
const open = Object.fromEntries(
ADMIN_NAV_GROUP_ORDER.map((g) => [g, true]),
) as Record<AdminNavGroup, boolean>;
for (const { group, items } of groups) {
if (items.some((item) => isActive(pathname, item))) {
open[group] = true;
}
}
return open;
}
function NavLeaf({
item,
pathname,
t,
}: {
item: AdminNavItem;
pathname: string;
t: TFunction;
}): ReactElement {
const Icon = resolveAdminNavIcon(item.segment);
const active = isActive(pathname, item);
const label = adminNavLabel(item.segment, t, item.label);
return (
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
tooltip={label}
isActive={active}
render={<Link href={item.href} />}
className={cn(NAV_BTN, NAV_ACTIVE)}
>
<Icon data-icon="inline-start" aria-hidden />
<span>{label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
function NavSubLeaf({
item,
pathname,
t,
}: {
item: AdminNavItem;
pathname: string;
t: TFunction;
}): ReactElement {
const active = isActive(pathname, item);
const label = adminNavLabel(item.segment, t, item.label);
return (
<SidebarMenuSubItem>
<SidebarMenuSubButton
size="md"
isActive={active}
render={<Link href={item.href} />}
className={cn(SUB_NAV, NAV_ACTIVE)}
>
<span>{label}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
}
function NavCollapsibleGroup({
group,
items,
pathname,
open,
onToggle,
t,
}: {
group: AdminNavGroup;
items: AdminNavItem[];
pathname: string;
open: boolean;
onToggle: () => void;
t: TFunction;
}): ReactElement {
const GroupIcon = ADMIN_NAV_GROUP_ICON[group];
const groupLabel = t(`sidebar.group.${group}`, { ns: "common", defaultValue: group });
const hasActiveChild = items.some((item) => isActive(pathname, item));
return (
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
type="button"
data-open={open ? "" : undefined}
isActive={hasActiveChild && !open}
className={cn(NAV_BTN, hasActiveChild && !open && NAV_ACTIVE)}
onClick={onToggle}
>
<GroupIcon aria-hidden />
<span className="flex-1 truncate">{groupLabel}</span>
<ChevronRight
aria-hidden
className={cn(
"ml-auto size-4 shrink-0 text-sidebar-foreground/50 transition-transform duration-200",
open && "rotate-90",
)}
/>
</SidebarMenuButton>
{open ? (
<SidebarMenuSub className="mx-2 gap-0.5 border-sidebar-border/50 px-1.5 py-0.5">
{items.map((item) => (
<NavSubLeaf key={item.segment} item={item} pathname={pathname} t={t} />
))}
</SidebarMenuSub>
) : null}
</SidebarMenuItem>
);
}
export function AdminSidebarNav({
items,
}: {
items: readonly AdminNavItem[];
}): ReactElement {
const { t } = useTranslation("common");
const pathname = usePathname();
const navGroups = useMemo(() => groupAdminNavItems(items), [items]);
const [openGroups, setOpenGroups] = useState<Record<AdminNavGroup, boolean>>(() =>
defaultOpenGroups(navGroups, pathname),
);
useEffect(() => {
setOpenGroups((prev) => {
const next = { ...prev };
let changed = false;
for (const { group, items: groupItems } of navGroups) {
if (groupItems.some((item) => isActive(pathname, item)) && !next[group]) {
next[group] = true;
changed = true;
}
}
return changed ? next : prev;
});
}, [pathname, navGroups]);
const overview = navGroups.find((g) => g.group === "overview");
const collapsible = navGroups.filter((g) => g.group !== "overview");
return (
<SidebarMenu className="gap-0.5 px-1.5 py-1.5">
{overview?.items.map((item) => (
<NavLeaf key={item.segment} item={item} pathname={pathname} t={t} />
))}
{collapsible.map(({ group, items: groupItems }) => (
<NavCollapsibleGroup
key={group}
group={group}
items={groupItems}
pathname={pathname}
open={openGroups[group] ?? true}
onToggle={() =>
setOpenGroups((prev) => ({
...prev,
[group]: !(prev[group] ?? true),
}))
}
t={t}
/>
))}
</SidebarMenu>
);
}
export function AdminSidebarNavSkeleton(): ReactElement {
const { t } = useTranslation("common");
const widths = ["68%", "74%", "58%", "70%", "62%"] as const;
return (
<SidebarMenu className="gap-0.5 px-1.5 py-1.5 motion-safe:opacity-90">
<SidebarMenuItem>
<div aria-hidden className="flex h-8 items-center gap-2 rounded-md px-2.5">
<span className="size-4 rounded-sm bg-white/12 motion-safe:animate-pulse" />
<span className="h-2.5 w-14 rounded-full bg-white/12 motion-safe:animate-pulse" />
</div>
</SidebarMenuItem>
{widths.map((width, i) => (
<SidebarMenuItem key={i}>
<div aria-hidden className="flex h-8 items-center gap-2 rounded-md px-2.5">
<span
className="size-4 rounded-sm bg-white/12 motion-safe:animate-pulse"
style={{ animationDelay: `${i * 55}ms` }}
/>
<span
className="h-2 rounded-full bg-white/12 motion-safe:animate-pulse"
style={{ width, animationDelay: `${i * 55 + 40}ms` }}
/>
<span className="ml-auto size-3 rounded-sm bg-white/10 motion-safe:animate-pulse" />
</div>
</SidebarMenuItem>
))}
<span className="sr-only">{t("auth.checking")}</span>
</SidebarMenu>
);
}

View File

@@ -1,16 +1,16 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
AdminSidebarNav,
AdminSidebarNavSkeleton,
} from "@/components/admin/admin-sidebar-nav";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
@@ -18,63 +18,28 @@ import {
SidebarRail,
SidebarSeparator,
} from "@/components/ui/sidebar";
import { adminNavLabel } from "@/lib/admin-nav-label";
import { cn } from "@/lib/utils";
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
import { useAdminProfile, useAdminSessionStore } from "@/stores/admin-session";
/** 与常见导航项文字宽度接近,避免整齐灰条 */
const SIDEBAR_NAV_SKELETON_WIDTHS = ["68%", "82%", "58%", "74%", "64%", "78%", "55%", "70%", "62%"] as const;
function SidebarNavSkeletonRow({
labelWidth,
delayMs,
}: {
labelWidth: string;
delayMs: number;
}): ReactElement {
return (
<SidebarMenuItem>
<div
aria-hidden
className="flex h-8 w-full items-center gap-2 rounded-md px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1.5"
style={{ animationDelay: `${delayMs}ms` }}
>
<span
className="size-4 shrink-0 rounded-[4px] bg-white/12 motion-safe:animate-pulse"
style={{ animationDelay: `${delayMs}ms` }}
/>
<span
className="h-2.5 shrink-0 rounded-full bg-white/12 motion-safe:animate-pulse group-data-[collapsible=icon]:hidden"
style={{ width: labelWidth, animationDelay: `${delayMs + 40}ms` }}
/>
</div>
</SidebarMenuItem>
);
}
function AdminSidebarSkeleton(): ReactElement {
const { t } = useTranslation("common");
return (
<Sidebar collapsible="icon" className="overflow-hidden">
<SidebarHeader className="flex h-14 shrink-0 items-center gap-0 border-b border-sidebar-border p-0 px-2">
<Sidebar collapsible="icon">
<SidebarHeader className="flex shrink-0 flex-col gap-0 border-b border-sidebar-border px-2 py-2">
<SidebarMenu className="h-full w-full">
<SidebarMenuItem className="h-full">
<div className="flex h-12 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
<div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
<img
src="/logo.png"
alt="N lotto"
className="h-auto max-h-11 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-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
/>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="relative overflow-hidden">
<SidebarContent className="relative min-h-0 overflow-hidden p-0">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
aria-hidden
>
<img
@@ -82,81 +47,64 @@ function AdminSidebarSkeleton(): ReactElement {
alt=""
className="h-full w-full object-cover object-bottom"
/>
<div className="absolute inset-x-0 top-0 h-28 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" />
<div className="absolute inset-0 bg-sidebar/20" />
</div>
<SidebarGroup className="relative z-10">
<SidebarGroupLabel className="text-sidebar-foreground/55">
{t("sidebar.workspace", { defaultValue: "Workspace" })}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className={cn("gap-0.5", "motion-safe:opacity-90")}>
{SIDEBAR_NAV_SKELETON_WIDTHS.map((width, i) => (
<SidebarNavSkeletonRow key={i} labelWidth={width} delayMs={i * 55} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
<AdminSidebarNavSkeleton />
</div>
</SidebarContent>
<SidebarSeparator />
<SidebarRail />
<span className="sr-only" role="status" aria-live="polite">
{t("auth.checking")}
</span>
</Sidebar>
);
}
function isActive(pathname: string, item: { href: string; activeMatchPrefix?: string; segment?: string }): boolean {
const { href, activeMatchPrefix, segment } = item;
const prefix = activeMatchPrefix ?? href;
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
}
// Keep "settings" independent from its child routes like /admin/settings/currencies.
if (segment === "settings") {
return pathname === href;
}
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
export function AdminAppSidebar() {
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "audit", "reports"]);
const pathname = usePathname();
const shellAuthPending = useAdminSessionStore((s) => s.shellAuthPending);
const profile = useAdminProfile();
if (shellAuthPending) {
return <AdminSidebarSkeleton />;
}
const visibleNav = useMemo(
() => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"),
[profile?.navigation],
);
if (shellAuthPending) {
return <AdminSidebarSkeleton />;
}
return (
<Sidebar collapsible="icon" className="overflow-hidden">
<SidebarHeader className="flex h-14 shrink-0 items-center gap-0 border-b border-sidebar-border p-0 px-2">
<Sidebar collapsible="icon">
<SidebarHeader className="flex shrink-0 flex-col gap-0 border-b border-sidebar-border px-2 py-2">
<SidebarMenu className="h-full w-full">
<SidebarMenuItem className="h-full">
<SidebarMenuButton
render={<Link href={ADMIN_BASE} />}
className="h-full min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
className="h-10 min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
>
<div className="flex h-12 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
<div className="flex h-10 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
<img
src="/logo.png"
alt="N lotto"
className="h-auto max-h-11 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-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
/>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{profile?.agent ? (
<p
className="truncate px-1 pb-1 text-[11px] leading-tight font-medium text-sidebar-foreground/60 group-data-[collapsible=icon]:hidden"
title={profile.agent.name}
>
{profile.agent.name}
<span className="text-sidebar-foreground/40"> · {profile.agent.code}</span>
</p>
) : null}
</SidebarHeader>
<SidebarContent className="relative overflow-hidden">
<SidebarContent className="relative min-h-0 overflow-hidden p-0">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
aria-hidden
>
<img
@@ -164,32 +112,12 @@ export function AdminAppSidebar() {
alt=""
className="h-full w-full object-cover object-bottom"
/>
<div className="absolute inset-x-0 top-0 h-28 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" />
<div className="absolute inset-0 bg-sidebar/20" />
</div>
<SidebarGroup>
<SidebarGroupLabel>{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleNav.map((item) => {
const Icon = resolveAdminNavIcon(item.segment);
return (
<SidebarMenuItem key={item.segment}>
<SidebarMenuButton
tooltip={adminNavLabel(item.segment, t, item.label)}
isActive={isActive(pathname, item)}
render={<Link href={item.href} />}
className="font-medium text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-active:bg-red-600 data-active:text-white data-active:shadow-sm"
>
<Icon data-icon="inline-start" aria-hidden />
<span>{adminNavLabel(item.segment, t, item.label)}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
<AdminSidebarNav items={visibleNav} />
</div>
</SidebarContent>
<SidebarSeparator />
<SidebarRail />

View File

@@ -5,6 +5,7 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import { isAxiosError } from "axios";
@@ -23,6 +24,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
export function LoginForm() {
const { t } = useTranslation(["auth", "common"]);
const tRef = useTranslationRef(["auth", "common"]);
const router = useRouter();
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
@@ -42,7 +44,7 @@ export function LoginForm() {
try {
const data = await getAdminCaptcha();
if (!data) {
toast.error(t("captchaLoadFailed"));
toast.error(tRef.current("captchaLoadFailed"));
setCaptchaKey(null);
setCaptchaSrc(null);
@@ -54,7 +56,7 @@ export function LoginForm() {
} finally {
setLoadingCaptcha(false);
}
}, [t]);
}, []);
useEffect(() => {
let cancelled = false;

View File

@@ -0,0 +1,61 @@
"use client";
import type { ReactElement } from "react";
import { cn } from "@/lib/utils";
const dotSizeClass = {
sm: "size-1",
md: "size-1.5",
lg: "size-2",
} as const;
const gapClass = {
sm: "gap-0.5",
md: "gap-1",
lg: "gap-1.5",
} as const;
export type LoadingDotsSize = keyof typeof dotSizeClass;
/**
* 全局加载指示:三个圆点依次跳动。
* 用于表格、卡片、区块等;完整文案请用 `label` + `showLabel` 或外层 {@link AdminLoadingState}。
*/
export function LoadingDots({
size = "md",
className,
label,
showLabel = false,
}: {
size?: LoadingDotsSize;
className?: string;
/** 供屏幕阅读器;`showLabel` 为 true 时同时可见 */
label?: string;
showLabel?: boolean;
}): ReactElement {
return (
<span
role="status"
aria-live="polite"
aria-busy="true"
className={cn("inline-flex items-center", gapClass[size], className)}
>
{[0, 1, 2].map((index) => (
<span
key={index}
aria-hidden
className={cn(
"rounded-full bg-current",
dotSizeClass[size],
"animate-loading-dot-bounce",
)}
style={{ animationDelay: `${index * 0.14}s` }}
/>
))}
{label ? (
<span className={showLabel ? "text-sm text-muted-foreground" : "sr-only"}>{label}</span>
) : null}
</span>
);
}

View File

@@ -6,14 +6,50 @@ import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { useAsyncEffect } from "@/hooks/use-async-effect";
export type AdminSiteCodeOption = {
code: string;
name: string;
};
let cachedSites: AdminSiteCodeOption[] | null = null;
let inflightSites: Promise<AdminSiteCodeOption[]> | null = null;
export function clearCachedAdminSiteCodeOptions(): void {
cachedSites = null;
inflightSites = null;
}
async function fetchSiteCodeOptions(): Promise<AdminSiteCodeOption[]> {
if (cachedSites !== null) {
return cachedSites;
}
if (inflightSites !== null) {
return inflightSites;
}
inflightSites = getAdminIntegrationSites()
.then((data) => {
cachedSites = data.items.map((row) => ({
code: row.code,
name: row.name,
}));
return cachedSites;
})
.catch(() => {
cachedSites = [];
return [];
})
.finally(() => {
inflightSites = null;
});
return inflightSites;
}
/**
* 接入站点下拉(已按当前管理员站点权限过滤)。
* 接入站点下拉(已按当前管理员站点权限过滤;模块级缓存避免多页重复 GET)。
*/
export function useAdminSiteCodeOptions(): {
sites: AdminSiteCodeOption[];
@@ -24,24 +60,21 @@ export function useAdminSiteCodeOptions(): {
const profile = useAdminProfile();
const canLoad = adminHasAnyPermission(profile?.permissions, PRD_INTEGRATION_ACCESS_ANY);
const [sites, setSites] = useState<AdminSiteCodeOption[]>([]);
const [loading, setLoading] = useState(false);
const [sites, setSites] = useState<AdminSiteCodeOption[]>(cachedSites ?? []);
const [loading, setLoading] = useState(canLoad && cachedSites === null);
const reload = useCallback(async () => {
if (!canLoad) {
setSites([]);
setLoading(false);
return;
}
setLoading(true);
try {
const data = await getAdminIntegrationSites();
setSites(
data.items.map((row) => ({
code: row.code,
name: row.name,
})),
);
clearCachedAdminSiteCodeOptions();
const next = await fetchSiteCodeOptions();
setSites(next);
} catch {
setSites([]);
} finally {
@@ -49,11 +82,24 @@ export function useAdminSiteCodeOptions(): {
}
}, [canLoad]);
useEffect(() => {
queueMicrotask(() => {
void reload();
});
}, [reload]);
useAsyncEffect(() => {
if (!canLoad) {
setSites([]);
setLoading(false);
return;
}
if (cachedSites !== null) {
setSites(cachedSites);
setLoading(false);
return;
}
void (async () => {
setLoading(true);
const next = await fetchSiteCodeOptions();
setSites(next);
setLoading(false);
})();
}, [canLoad]);
return {
sites,

View File

@@ -0,0 +1,21 @@
"use client";
import { useEffect, useRef, type DependencyList } from "react";
/**
* 在依赖变化时执行异步副作用factory 始终用最新闭包,但不必把 `t` 等不稳定引用放进 deps。
*/
export function useAsyncEffect(
factory: () => void | Promise<void>,
deps: DependencyList,
): void {
const factoryRef = useRef(factory);
factoryRef.current = factory;
useEffect(() => {
queueMicrotask(() => {
void factoryRef.current();
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- factory 经 ref 同步deps 仅含真实查询参数
}, deps);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminPlayTypes } from "@/api/admin-config";
import {
getAdminPlayTypesLoadPromise,
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
export type PlayTypeOption = { code: string; label: string };
/**
* 从全局玩法缓存生成下拉选项;仅首次 miss 时请求 API语言切换只重算 label。
*/
export function useCachedPlayTypeOptions(): PlayTypeOption[] {
const { i18n } = useTranslation();
const [options, setOptions] = useState<PlayTypeOption[]>([]);
useEffect(() => {
let cancelled = false;
void (async () => {
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
if (cancelled) {
return;
}
setOptions(
getCachedAdminPlayTypes().map((item) => ({
code: item.play_code,
label:
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
})),
);
})();
return () => {
cancelled = true;
};
}, [i18n.language]);
return options;
}

View File

@@ -0,0 +1,12 @@
"use client";
import { useRef } from "react";
import { useTranslation, type UseTranslationOptions } from "react-i18next";
/** 稳定引用 i18n `t`,避免放进 useCallback/useEffect 依赖导致重复请求 */
export function useTranslationRef(ns?: string | string[], options?: UseTranslationOptions<string>) {
const { t } = useTranslation(ns, options);
const tRef = useRef(t);
tRef.current = t;
return tRef;
}

View File

@@ -24,6 +24,7 @@ import enTickets from "@/i18n/locales/en/tickets.json";
import enReconcile from "@/i18n/locales/en/reconcile.json";
import enReports from "@/i18n/locales/en/reports.json";
import enWallet from "@/i18n/locales/en/wallet.json";
import enAgents from "@/i18n/locales/en/agents.json";
import neAudit from "@/i18n/locales/ne/audit.json";
import neAdminUsers from "@/i18n/locales/ne/adminUsers.json";
import neAuth from "@/i18n/locales/ne/auth.json";
@@ -39,6 +40,7 @@ import neTickets from "@/i18n/locales/ne/tickets.json";
import neReconcile from "@/i18n/locales/ne/reconcile.json";
import neReports from "@/i18n/locales/ne/reports.json";
import neWallet from "@/i18n/locales/ne/wallet.json";
import neAgents from "@/i18n/locales/ne/agents.json";
import zhAudit from "@/i18n/locales/zh/audit.json";
import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json";
import zhAuth from "@/i18n/locales/zh/auth.json";
@@ -54,12 +56,13 @@ import zhTickets from "@/i18n/locales/zh/tickets.json";
import zhReconcile from "@/i18n/locales/zh/reconcile.json";
import zhReports from "@/i18n/locales/zh/reports.json";
import zhWallet from "@/i18n/locales/zh/wallet.json";
import zhAgents from "@/i18n/locales/zh/agents.json";
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh";
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "config"] as const;
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "agents", "config"] as const;
const resources = {
en: {
@@ -78,6 +81,7 @@ const resources = {
audit: enAudit,
settlement: enSettlement,
wallet: enWallet,
agents: enAgents,
},
ne: {
common: neCommon,
@@ -95,6 +99,7 @@ const resources = {
audit: neAudit,
settlement: neSettlement,
wallet: neWallet,
agents: neAgents,
},
zh: {
common: zhCommon,
@@ -112,6 +117,7 @@ const resources = {
audit: zhAudit,
settlement: zhSettlement,
wallet: zhWallet,
agents: zhAgents,
},
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;

View File

@@ -0,0 +1,61 @@
{
"title": "Agents",
"treeTitle": "Agent tree",
"detailTitle": "Node details",
"selectNode": "Select an agent node from the tree",
"loadFailed": "Failed to load agent tree",
"siteLabel": "Site",
"createChild": "Add child agent",
"editNode": "Edit node",
"deleteNode": "Delete node",
"deleteNodeConfirm": "This action cannot be undone. Make sure the node has no children, users, or roles.",
"code": "Code",
"name": "Name",
"depth": "Depth",
"path": "Path",
"status": "Status",
"isRoot": "Root",
"createSuccess": "Created agent {{name}}",
"updateSuccess": "Updated {{name}}",
"deleteSuccess": "Deleted agent {{name}}",
"saveFailed": "Save failed",
"codeRequired": "Code and name are required",
"tabs": {
"overview": "Overview",
"roles": "Roles",
"users": "Accounts",
"delegation": "Delegation ceiling"
},
"delegation": {
"title": "Delegation ceiling",
"hint": "Select actions this child agent may grant to its own subordinates. Agent roles cannot exceed this ceiling.",
"permission": "Action",
"canDelegate": "May delegate further",
"save": "Save ceiling",
"saveSuccess": "Delegation ceiling saved",
"empty": "No actions available to assign",
"rootDenied": "Root nodes do not use delegation ceilings"
},
"roles": {
"title": "Agent roles",
"create": "Create role",
"permissions": "Permissions",
"slug": "Slug",
"userCount": "Users",
"createSuccess": "Created role {{name}}",
"updateSuccess": "Updated role {{name}}",
"deleteSuccess": "Deleted role {{name}}",
"permissionSaveSuccess": "Permissions updated",
"readOnlyTemplate": "Read-only template",
"permissionSubsetHint": "Only permissions you hold can be assigned"
},
"users": {
"title": "Agent accounts",
"create": "Create account",
"username": "Username",
"password": "Password",
"roles": "Roles",
"createSuccess": "Created account {{name}}",
"roleSaveSuccess": "Roles updated for {{name}}"
}
}

View File

@@ -125,6 +125,11 @@
"display": "Player",
"sitePlayerId": "Player ID"
},
"agentColumns": {
"agent": "Agent",
"filter": "Agent",
"filterAll": "All agents"
},
"toolbar": {
"defaultAdmin": "Administrator",
"notifications": "Notifications",
@@ -155,10 +160,19 @@
"settings": "Settings",
"account": "Account settings",
"integration": "Integration sites",
"agents": "Agents",
"config": "Operations config"
},
"sidebar": {
"workspace": "Workspace"
"workspace": "Workspace",
"group": {
"overview": "Overview",
"agent": "Agent organization",
"operations": "Operations",
"finance": "Finance & reports",
"rules": "Rules & parameters",
"platform": "Platform"
}
},
"auth": {
"checking": "Checking sign-in status…",

View File

@@ -178,7 +178,18 @@
"loadFailed": "Failed to load system settings",
"saveSuccess": "System settings saved",
"saveRuntimeSuccess": "Draw and settlement parameters saved",
"saveDrawSuccess": "Draw parameters saved",
"saveCurrencyFormatSuccess": "Currency display format saved",
"saveSettlementSuccess": "Settlement automation saved",
"saveFrontendSuccess": "Front-end display settings saved",
"sections": {
"draw": "Draw schedule and review",
"drawDescription": "Controls draw timing, close window, manual review, and cooldown. Only changed fields in this block are submitted.",
"currencyFormat": "Currency display format",
"currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).",
"settlement": "Settlement automation",
"settlementDescription": "Controls whether tick auto-runs settlement, approval, and payout. Only changed fields in this block are submitted."
},
"saveFailed": "Failed to save system settings",
"unsavedChanges": "Unsaved changes",
"frontendConfig": "Front-end configuration",
@@ -217,6 +228,12 @@
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
"confirmSaveRuntimeTitle": "Save draw and settlement parameters?",
"confirmSaveRuntimeDescription": "This updates draw review, schedule timing, cooldown, and auto settlement/approval/payout. Play-rules HTML is not changed.",
"confirmSaveDrawTitle": "Save draw parameters?",
"confirmSaveDrawDescription": "This updates draw review, schedule timing, and cooldown in this block only.",
"confirmSaveCurrencyFormatTitle": "Save currency display format?",
"confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.",
"confirmSaveSettlementTitle": "Save settlement automation?",
"confirmSaveSettlementDescription": "This updates auto settlement, approval, and payout switches.",
"confirmSaveFrontendTitle": "Save front-end display settings?",
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed."
},

View File

@@ -29,6 +29,7 @@
"granularityDay": "By day",
"playBreakdown": "Play breakdown",
"playRanking": "Top 5 plays",
"agentRanking": "Top 5 agents",
"rankingMetricLabel": "Ranking metric",
"rankingMetrics": {
"bet": "By bet amount",
@@ -37,6 +38,7 @@
},
"periodDistribution": "Period structure",
"noPlayData": "No play data in this period",
"noAgentData": "No agent data in this period",
"periods": {
"today": "Today",
"last_7_days": "Last 7 days",
@@ -90,6 +92,7 @@
"batchPendingDraws": "Draws involved",
"batchPendingDrawsCount": "{{count}} draws pending",
"platformLockedAndCap": "Site locked {{locked}} / cap {{cap}}",
"platformCapNotConfigured": "Site locked {{locked}} · cap not configured",
"platformOrderAndTicket": "Site-wide {{orders}} orders · {{tickets}} lines",
"platformBetTotal": "Lifetime bet",
"platformNoFinanceActivity": "No bets site-wide yet",

View File

@@ -2,10 +2,13 @@
"title": "Reconcile",
"createTitle": "Create reconcile job",
"createDesc": "Manually check abnormal transfers by date range and optional player. Scheduled reconciliation still runs automatically.",
"scopeTitle": "Define the reconcile scope",
"scopeDescription": "Choose the business type and date range first, then decide whether to narrow it to one player.",
"reconcileType": "Reconcile type",
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
"reconcileTypeHint": "Only wallet transfer is currently supported.",
"dateRange": "Reconcile date range",
"dateRangeHint": "Start with a shorter period to spot concentrated issues before widening the search.",
"createTask": "Create reconcile job",
"submitting": "Submitting…",
"loadFailed": "Failed to load",
@@ -20,13 +23,21 @@
"createSuccess": "Reconcile job created",
"createFailed": "Failed to create job",
"noCreatePermission": "Current account cannot create reconcile jobs.",
"playerScopeTitle": "Optionally narrow to one player",
"playerAllPlayersHint": "If no player is selected, the reconcile job will cover all players in the chosen date range.",
"createSummaryAll": "A manual reconcile will run for all players from {{from}} to {{to}}.",
"createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.",
"jobsTitle": "Reconcile jobs",
"jobsDesc": "Use the action on the right to open paginated item details.",
"refresh": "Refresh",
"jobNo": "Job no.",
"type": "Type",
"status": "Status",
"itemCount": "Items",
"mismatchCount": "Mismatches",
"matchedCount": "Matched",
"period": "Period",
"finishedAt": "Finished at",
"createdAt": "Created at",
"operate": "Action",
"view": "View",
@@ -34,6 +45,7 @@
"sideARef": "Lottery ref",
"sideBRef": "Main site ref",
"differenceAmount": "Difference (cent)",
"detectedAt": "Detected at",
"noDetails": "No details",
"playerSearch": "Player (optional)",
"playerSearchPlaceholder": "Search by player ID / username / nickname",

View File

@@ -84,15 +84,109 @@
"subtitle": "Results appear below. Export as CSV or Excel.",
"empty": "No data. Adjust filters and try again.",
"exportableRows": "rows exportable",
"summaryScopeHint": "Except for the total record count, the stat cards above summarize the current preview page. Use full CSV/Excel export for full-range numbers.",
"scope": {
"currentPage": "Current page"
},
"columns": {
"primary": "",
"secondary": "",
"metricA": "",
"metricB": "",
"metricC": "",
"status": "",
"extra": "",
"time": ""
"primary": "Primary",
"secondary": "Secondary",
"metricA": "Metric A",
"metricB": "Metric B",
"metricC": "Metric C",
"status": "Status",
"extra": "Extra",
"time": "Time",
"drawProfit": {
"primary": "Draw / Batch",
"secondary": "Draw / Settlement status",
"metricA": "Orders / Tickets",
"metricB": "Tickets / Winners",
"metricC": "Bet / House P&L",
"status": "Payout / Jackpot",
"extra": "Batch count",
"time": "Finished"
},
"dailyProfit": {
"primary": "Business date",
"secondary": "Note",
"metricA": "Bet",
"metricB": "Payout",
"metricC": "House P&L",
"status": "Refund",
"extra": "Net",
"time": "Updated"
},
"playerWinLoss": {
"primary": "Player",
"secondary": "Player ID",
"metricA": "Bet",
"metricB": "Payout",
"metricC": "Net win/loss",
"status": "Tier",
"extra": "Note",
"time": "Time"
},
"playerTransfer": {
"primary": "Transfer no.",
"secondary": "Player",
"metricA": "Direction",
"metricB": "Status",
"metricC": "Amount",
"status": "External ref",
"extra": "Failure reason",
"time": "Created"
},
"hotNumberRisk": {
"primary": "Number / Log",
"secondary": "Draw / Action",
"metricA": "Cap / Amount",
"metricB": "Locked / Play",
"metricC": "Remaining / Ticket",
"status": "Sold out / Player",
"extra": "Usage / Reason",
"time": "Version / Time"
},
"playDimension": {
"primary": "Play",
"secondary": "Dimension",
"metricA": "Bet",
"metricB": "Payout",
"metricC": "House P&L",
"status": "Share",
"extra": "Note",
"time": "Time"
},
"soldOut": {
"primary": "Number",
"secondary": "Draw",
"metricA": "Cap",
"metricB": "Locked",
"metricC": "Remaining",
"status": "Sold out",
"extra": "Usage",
"time": "Version"
},
"rebateCommission": {
"primary": "Play",
"secondary": "Orders",
"metricA": "Rebate",
"metricB": "Ticket items",
"metricC": "Commission",
"status": "Rule hit",
"extra": "Note",
"time": "Time"
},
"adminAudit": {
"primary": "Log ID",
"secondary": "Operator type",
"metricA": "Operator ID",
"metricB": "Module",
"metricC": "Action",
"status": "Target type",
"extra": "IP",
"time": "Time"
}
},
"stats": {
"records": "Records",
@@ -179,7 +273,7 @@
},
"daily_profit": {
"title": "Daily P&L summary",
"summary": "Summarize bets, payouts, refunds, P&L, and net amount by date."
"summary": "Summarize bet amount, payout, and house P&L by business date. Refund and standalone net amount are not included yet."
},
"player_win_loss": {
"title": "Player win/loss report",

View File

@@ -0,0 +1,61 @@
{
"title": "Agents",
"treeTitle": "Agent tree",
"detailTitle": "Node details",
"selectNode": "Select an agent node from the tree",
"loadFailed": "Failed to load agent tree",
"siteLabel": "Site",
"createChild": "Add child agent",
"editNode": "Edit node",
"deleteNode": "Delete node",
"deleteNodeConfirm": "This action cannot be undone. Make sure the node has no children, users, or roles.",
"code": "Code",
"name": "Name",
"depth": "Depth",
"path": "Path",
"status": "Status",
"isRoot": "Root",
"createSuccess": "Created agent {{name}}",
"updateSuccess": "Updated {{name}}",
"deleteSuccess": "Deleted agent {{name}}",
"saveFailed": "Save failed",
"codeRequired": "Code and name are required",
"tabs": {
"overview": "Overview",
"roles": "Roles",
"users": "Accounts",
"delegation": "Delegation ceiling"
},
"delegation": {
"title": "Delegation ceiling",
"hint": "Select actions this child agent may grant to subordinates.",
"permission": "Action",
"canDelegate": "May delegate further",
"save": "Save ceiling",
"saveSuccess": "Delegation ceiling saved",
"empty": "No actions available",
"rootDenied": "Root nodes do not use delegation ceilings"
},
"roles": {
"title": "Agent roles",
"create": "Create role",
"permissions": "Permissions",
"slug": "Slug",
"userCount": "Users",
"createSuccess": "Created role {{name}}",
"updateSuccess": "Updated role {{name}}",
"deleteSuccess": "Deleted role {{name}}",
"permissionSaveSuccess": "Permissions updated",
"readOnlyTemplate": "Read-only template",
"permissionSubsetHint": "Only permissions you hold can be assigned"
},
"users": {
"title": "Agent accounts",
"create": "Create account",
"username": "Username",
"password": "Password",
"roles": "Roles",
"createSuccess": "Created account {{name}}",
"roleSaveSuccess": "Roles updated for {{name}}"
}
}

View File

@@ -125,6 +125,11 @@
"display": "खेलाडी",
"sitePlayerId": "खेलाडी ID"
},
"agentColumns": {
"agent": "एजेन्ट",
"filter": "एजेन्ट",
"filterAll": "सबै एजेन्ट"
},
"toolbar": {
"defaultAdmin": "प्रशासक",
"notifications": "सूचना",
@@ -155,10 +160,19 @@
"settings": "सेटिङ",
"account": "खाता सेटिङ",
"integration": "मुख्य साइट एकीकरण",
"agents": "एजेन्ट व्यवस्थापन",
"config": "सञ्चालन कन्फिगरेसन"
},
"sidebar": {
"workspace": "कार्यस्थान"
"workspace": "कार्यस्थान",
"group": {
"overview": "सारांश",
"agent": "एजेन्ट संगठन",
"operations": "दैनिक सञ्चालन",
"finance": "वित्त र रिपोर्ट",
"rules": "नियम र प्यारामिटर",
"platform": "प्लेटफर्म"
}
},
"auth": {
"checking": "लगइन स्थिति जाँच हुँदैछ…",

View File

@@ -29,6 +29,7 @@
"granularityDay": "दैनिक",
"playBreakdown": "प्ले विभाजन",
"playRanking": "शीर्ष ५ प्ले",
"agentRanking": "शीर्ष ५ एजेन्ट",
"rankingMetricLabel": "रैंकिङ मेट्रिक",
"rankingMetrics": {
"bet": "बेट रकम",
@@ -37,6 +38,7 @@
},
"periodDistribution": "अवधि संरचना",
"noPlayData": "यस अवधिमा प्ले डाटा छैन",
"noAgentData": "यस अवधिमा एजेन्ट डाटा छैन",
"periods": {
"today": "आज",
"last_7_days": "पछिल्लो ७ दिन",
@@ -90,6 +92,7 @@
"batchPendingDraws": "सम्बन्धित ड्रअ",
"batchPendingDrawsCount": "{{count}} ड्रअ पेन्डिङ",
"platformLockedAndCap": "साइट लक {{locked}} / क्याप {{cap}}",
"platformCapNotConfigured": "साइट लक {{locked}} · क्याप कन्फिगर गरिएको छैन",
"platformOrderAndTicket": "साइटव्यापी {{orders}} अर्डर · {{tickets}} लाइन",
"platformBetTotal": "जम्मा बेट",
"platformNoFinanceActivity": "साइटव्यापी अहिले बेट छैन",

View File

@@ -2,10 +2,13 @@
"title": "मिलान",
"createTitle": "म्यानुअल मिलान कार्य",
"createDesc": "मिति दायरा र वैकल्पिक खेलाडी चयनबाट असामान्य ट्रान्सफर म्यानुअल रूपमा जाँच गर्नुहोस्। scheduled reconciliation स्वतः चलिरहन्छ।",
"scopeTitle": "पहिले मिलानको दायरा तय गर्नुहोस्",
"scopeDescription": "पहिले व्यवसाय प्रकार र मिति दायरा रोज्नुहोस्, त्यसपछि आवश्यक परे एक खेलाडीमा सीमित गर्नुहोस्।",
"reconcileType": "मिलान प्रकार",
"reconcileTypeFixed": "वालेट ट्रान्सफर (मुख्य साइट ⇄ लटरी)",
"reconcileTypeHint": "हाल वालेट ट्रान्सफर मात्र समर्थित छ।",
"dateRange": "मिलान मिति दायरा",
"dateRangeHint": "पहिले छोटो समयावधि रोजेर समस्या कहाँ केन्द्रित छ हेर्नुहोस्, त्यसपछि आवश्यक परे दायरा बढाउनुहोस्।",
"createTask": "मिलान कार्य सिर्जना",
"submitting": "पेश हुँदैछ…",
"loadFailed": "लोड असफल भयो",
@@ -16,13 +19,21 @@
"createSuccess": "मिलान कार्य सिर्जना भयो",
"createFailed": "कार्य सिर्जना असफल भयो",
"noCreatePermission": "हालको खातासँग मिलान कार्य सिर्जना गर्ने अनुमति छैन।",
"playerScopeTitle": "आवश्यक परे एक खेलाडीमा सीमित गर्नुहोस्",
"playerAllPlayersHint": "खेलाडी नछानेमा, छनोट गरिएको मिति दायराभित्र सबै खेलाडीका लागि मिलान चलाइनेछ।",
"createSummaryAll": "{{from}} देखि {{to}} सम्म सबै खेलाडीका लागि म्यानुअल मिलान चलाइनेछ।",
"createSummaryPlayer": "खेलाडी {{player}} का लागि {{from}} देखि {{to}} सम्म म्यानुअल मिलान चलाइनेछ।",
"jobsTitle": "मिलान कार्यहरू",
"jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।",
"refresh": "रिफ्रेस",
"jobNo": "कार्य नं.",
"type": "प्रकार",
"status": "स्थिति",
"itemCount": "विवरण संख्या",
"mismatchCount": "असंगति",
"matchedCount": "मेल खाएका",
"period": "अवधि",
"finishedAt": "समाप्त समय",
"createdAt": "सिर्जना समय",
"operate": "कार्य",
"view": "हेर्नुहोस्",
@@ -30,6 +41,7 @@
"sideARef": "लटरी साइड सन्दर्भ",
"sideBRef": "मुख्य साइट सन्दर्भ",
"differenceAmount": "अन्तर (cent)",
"detectedAt": "फेला परेको समय",
"noDetails": "विवरण छैन",
"playerSearch": "खेलाडी (वैकल्पिक)",
"playerSearchPlaceholder": "player ID / username / nickname बाट खोज्नुहोस्",

View File

@@ -84,15 +84,109 @@
"subtitle": "तल तालिकामा नतिजा देखिन्छ।",
"empty": "डाटा छैन।",
"exportableRows": "पङ्क्ति निर्यात योग्य",
"summaryScopeHint": "कुल रेकर्ड बाहेक माथिका कार्डहरू हालको पूर्वावलोकन पृष्ठको योग हुन्। पूर्ण दायराको संख्या चाहिँ पूर्ण CSV/Excel निर्यात प्रयोग गर्नुहोस्।",
"scope": {
"currentPage": "हालको पृष्ठ"
},
"columns": {
"primary": "",
"secondary": "",
"metricA": "",
"metricB": "",
"metricC": "",
"status": "",
"extra": "",
"time": ""
"primary": "मुख्य",
"secondary": "सहायक",
"metricA": "सूचक A",
"metricB": "सूचक B",
"metricC": "सूचक C",
"status": "स्थिति",
"extra": "थप",
"time": "समय",
"drawProfit": {
"primary": "ड्र / ब्याच",
"secondary": "ड्र / सेटलमेन्ट स्थिति",
"metricA": "अर्डर / टिकट",
"metricB": "टिकट / विजेता",
"metricC": "बेट / हाउस P&L",
"status": "पेआउट / ज्याकपोट",
"extra": "ब्याच संख्या",
"time": "समाप्त"
},
"dailyProfit": {
"primary": "व्यावसायिक मिति",
"secondary": "टिप्पणी",
"metricA": "बेट",
"metricB": "पेआउट",
"metricC": "हाउस P&L",
"status": "रिफन्ड",
"extra": "नेट",
"time": "अपडेट"
},
"playerWinLoss": {
"primary": "खेलाडी",
"secondary": "खेलाडी ID",
"metricA": "बेट",
"metricB": "पेआउट",
"metricC": "नेट जित/हार",
"status": "स्तर",
"extra": "टिप्पणी",
"time": "समय"
},
"playerTransfer": {
"primary": "ट्रान्सफर नं.",
"secondary": "खेलाडी",
"metricA": "दिशा",
"metricB": "स्थिति",
"metricC": "रकम",
"status": "बाह्य सन्दर्भ",
"extra": "असफल कारण",
"time": "सिर्जना"
},
"hotNumberRisk": {
"primary": "नम्बर / लग",
"secondary": "ड्र / कार्य",
"metricA": "क्याप / रकम",
"metricB": "लक / खेल",
"metricC": "बाँकी / टिकट",
"status": "सोल्ड आउट / खेलाडी",
"extra": "प्रयोग / कारण",
"time": "संस्करण / समय"
},
"playDimension": {
"primary": "खेल",
"secondary": "आयाम",
"metricA": "बेट",
"metricB": "पेआउट",
"metricC": "हाउस P&L",
"status": "अनुपात",
"extra": "टिप्पणी",
"time": "समय"
},
"soldOut": {
"primary": "नम्बर",
"secondary": "ड्र",
"metricA": "क्याप",
"metricB": "लक",
"metricC": "बाँकी",
"status": "सोल्ड आउट",
"extra": "प्रयोग",
"time": "संस्करण"
},
"rebateCommission": {
"primary": "खेल",
"secondary": "अर्डर",
"metricA": "रिबेट",
"metricB": "टिकट आइटम",
"metricC": "कमिसन",
"status": "नियम मिलान",
"extra": "टिप्पणी",
"time": "समय"
},
"adminAudit": {
"primary": "लग ID",
"secondary": "अपरेटर प्रकार",
"metricA": "अपरेटर ID",
"metricB": "मोड्युल",
"metricC": "कार्य",
"status": "लक्ष्य प्रकार",
"extra": "IP",
"time": "समय"
}
},
"stats": {
"records": "रेकर्ड",
@@ -179,7 +273,7 @@
},
"daily_profit": {
"title": "दैनिक P&L सारांश",
"summary": "मिति अनुसार बेट, पेआउट,िफन्ड, P&L र नेट रकम सारांश गर्नुहोस्।"
"summary": "व्यावसायिक मितिअनुसार बेट रकम, पेआउट र हाउस P&L सारांश गर्नुहोस्। रिफन्ड र छुट्टै नेट रकम अहिले समावेश छैन।"
},
"player_win_loss": {
"title": "खेलाडी जित/हार रिपोर्ट",

View File

@@ -0,0 +1,61 @@
{
"title": "代理管理",
"treeTitle": "代理树",
"detailTitle": "节点详情",
"selectNode": "请从左侧选择代理节点",
"loadFailed": "加载代理树失败",
"siteLabel": "站点",
"createChild": "添加下级代理",
"editNode": "编辑节点",
"deleteNode": "删除节点",
"deleteNodeConfirm": "删除后不可恢复,请确认该节点无下级、无账号、无角色绑定。",
"code": "编码",
"name": "名称",
"depth": "层级",
"path": "路径",
"status": "状态",
"isRoot": "根节点",
"createSuccess": "已创建代理 {{name}}",
"updateSuccess": "已更新 {{name}}",
"deleteSuccess": "已删除代理 {{name}}",
"saveFailed": "保存失败",
"codeRequired": "请填写编码与名称",
"tabs": {
"overview": "概况",
"roles": "角色",
"users": "账号",
"delegation": "下放权限"
},
"delegation": {
"title": "下放权限上限",
"hint": "勾选允许该下级代理继续下放的操作;保存后创建角色时不可超出此范围。",
"permission": "操作",
"canDelegate": "可继续下放",
"save": "保存上限",
"saveSuccess": "下放上限已保存",
"empty": "暂无可配置的操作",
"rootDenied": "根节点无需配置下放上限"
},
"roles": {
"title": "代理角色",
"create": "创建角色",
"permissions": "权限",
"slug": "标识",
"userCount": "人数",
"createSuccess": "已创建角色 {{name}}",
"updateSuccess": "已更新角色 {{name}}",
"deleteSuccess": "已删除角色 {{name}}",
"permissionSaveSuccess": "权限已更新",
"readOnlyTemplate": "只读模板",
"permissionSubsetHint": "只能分配您当前拥有的权限"
},
"users": {
"title": "代理账号",
"create": "创建账号",
"username": "登录名",
"password": "密码",
"roles": "角色",
"createSuccess": "已创建账号 {{name}}",
"roleSaveSuccess": "已更新 {{name}} 的角色"
}
}

View File

@@ -125,6 +125,11 @@
"display": "玩家",
"sitePlayerId": "玩家 ID"
},
"agentColumns": {
"agent": "所属代理",
"filter": "代理",
"filterAll": "全部代理"
},
"toolbar": {
"defaultAdmin": "管理员",
"notifications": "通知",
@@ -155,10 +160,19 @@
"settings": "系统设置",
"account": "账号设置",
"integration": "接入站点",
"agents": "代理管理",
"config": "运营配置"
},
"sidebar": {
"workspace": "工作台"
"workspace": "工作台",
"group": {
"overview": "总览",
"agent": "代理组织",
"operations": "日常运营",
"finance": "资金与报表",
"rules": "规则与参数",
"platform": "平台管理"
}
},
"auth": {
"checking": "正在校验登录状态…",

View File

@@ -178,7 +178,18 @@
"loadFailed": "系统设置加载失败",
"saveSuccess": "系统设置已保存",
"saveRuntimeSuccess": "开奖与结算参数已保存",
"saveDrawSuccess": "开奖参数已保存",
"saveCurrencyFormatSuccess": "金额显示格式已保存",
"saveSettlementSuccess": "结算自动化参数已保存",
"saveFrontendSuccess": "前端展示配置已保存",
"sections": {
"draw": "开奖节奏与审核",
"drawDescription": "控制期号节奏、封盘与开奖后人工审核、冷静期。仅保存本区块内修改过的项。",
"currencyFormat": "金额显示格式",
"currencyFormatDescription": "全站金额展示的小数位与分隔符,与币种主数据无关。",
"settlement": "结算自动化",
"settlementDescription": "控制 tick 是否自动结算、审核与派彩。修改后只提交本区块变更项。"
},
"saveFailed": "系统设置保存失败",
"unsavedChanges": "有未保存的更改",
"frontendConfig": "前端配置",
@@ -217,6 +228,12 @@
"confirmSaveDescription": "将更新开奖审核、冷静期、自动结算/审核/派彩及玩法规则展示,可能影响全站运行。",
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
"confirmSaveDrawTitle": "确认保存开奖参数?",
"confirmSaveDrawDescription": "将更新开奖审核、期号节奏与冷静期等本区块字段。",
"confirmSaveCurrencyFormatTitle": "确认保存金额显示格式?",
"confirmSaveCurrencyFormatDescription": "将更新小数位与千分位/小数分隔符。",
"confirmSaveSettlementTitle": "确认保存结算自动化?",
"confirmSaveSettlementDescription": "将更新自动结算、审核与派彩相关开关。",
"confirmSaveFrontendTitle": "确认保存前端展示配置?",
"confirmSaveFrontendDescription": "将更新玩家端玩法规则页面 HTML不影响开奖与结算逻辑。"
},

View File

@@ -29,6 +29,7 @@
"granularityDay": "按天",
"playBreakdown": "玩法拆解 Top",
"playRanking": "玩法排行榜 Top 5",
"agentRanking": "代理排行榜 Top 5",
"rankingMetricLabel": "排行维度",
"rankingMetrics": {
"bet": "按投注金额",
@@ -37,6 +38,7 @@
},
"periodDistribution": "区间结构对比",
"noPlayData": "该区间暂无玩法数据",
"noAgentData": "该区间暂无代理数据",
"periods": {
"today": "今日",
"last_7_days": "近 7 天",
@@ -90,6 +92,7 @@
"batchPendingDraws": "涉及期数",
"batchPendingDrawsCount": "{{count}} 期待审",
"platformLockedAndCap": "全站已占用 {{locked}} / 封顶 {{cap}}",
"platformCapNotConfigured": "全站已占用 {{locked}} · 尚未配置封顶",
"platformOrderAndTicket": "全站 {{orders}} 单 · {{tickets}} 笔",
"platformBetTotal": "累计投注",
"platformNoFinanceActivity": "全站暂无投注",

View File

@@ -2,10 +2,13 @@
"title": "对账",
"createTitle": "人工发起对账",
"createDesc": "用于按日期范围并可选指定玩家,人工核对异常转账。系统定时对账仍会自动执行。",
"scopeTitle": "先定义对账范围",
"scopeDescription": "先确定要核对的业务类型和日期区间,再决定是否缩小到单个玩家。",
"reconcileType": "对账类型",
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
"reconcileTypeHint": "当前仅支持钱包划转。",
"dateRange": "对账日期范围",
"dateRangeHint": "建议优先选较短时间段,先看异常是否集中,再按需扩大范围。",
"createTask": "创建对账任务",
"submitting": "提交中…",
"loadFailed": "加载失败",
@@ -20,13 +23,21 @@
"createSuccess": "已创建对账任务",
"createFailed": "创建失败",
"noCreatePermission": "当前账号无新建对账任务权限。",
"playerScopeTitle": "再决定是否指定玩家",
"playerAllPlayersHint": "不选择玩家时,会按日期范围对全量玩家做一次人工对账。",
"createSummaryAll": "将对 {{from}} 至 {{to}} 的全量玩家发起人工对账。",
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
"jobsTitle": "对账任务",
"jobsDesc": "在右侧操作中查看差异明细与分页。",
"refresh": "刷新",
"jobNo": "任务号",
"type": "类型",
"status": "状态",
"itemCount": "明细数",
"mismatchCount": "异常数",
"matchedCount": "一致数",
"period": "对账周期",
"finishedAt": "完成时间",
"createdAt": "创建时间",
"operate": "操作",
"view": "查看",
@@ -34,6 +45,7 @@
"sideARef": "彩票侧引用",
"sideBRef": "主站侧引用",
"differenceAmount": "差额(分)",
"detectedAt": "发现时间",
"noDetails": "无明细",
"playerSearch": "指定玩家(可选)",
"playerSearchPlaceholder": "输入玩家 ID / 用户名 / 昵称搜索",

View File

@@ -84,15 +84,109 @@
"subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。",
"empty": "暂无数据,请调整筛选条件后重试。",
"exportableRows": "行可导出",
"summaryScopeHint": "上方统计卡除“记录数”外,默认按当前预览页汇总;需要全量口径请使用“导出 CSV/Excel全量”。",
"scope": {
"currentPage": "当前页"
},
"columns": {
"primary": "",
"secondary": "",
"metricA": "",
"metricB": "",
"metricC": "",
"status": "",
"extra": "",
"time": ""
"primary": "主字段",
"secondary": "辅助字段",
"metricA": "指标 A",
"metricB": "指标 B",
"metricC": "指标 C",
"status": "状态",
"extra": "补充信息",
"time": "时间",
"drawProfit": {
"primary": "期号 / 批次",
"secondary": "期状态 / 结算状态",
"metricA": "订单 / 票数",
"metricB": "票数 / 中奖数",
"metricC": "下注 / 平台盈亏",
"status": "派彩 / Jackpot",
"extra": "结算批次数",
"time": "完成时间"
},
"dailyProfit": {
"primary": "业务日",
"secondary": "说明",
"metricA": "下注",
"metricB": "派彩",
"metricC": "平台盈亏",
"status": "退款",
"extra": "净额",
"time": "更新时间"
},
"playerWinLoss": {
"primary": "玩家",
"secondary": "玩家 ID",
"metricA": "下注",
"metricB": "派彩",
"metricC": "净输赢",
"status": "层级",
"extra": "备注",
"time": "时间"
},
"playerTransfer": {
"primary": "转账单号",
"secondary": "玩家",
"metricA": "方向",
"metricB": "状态",
"metricC": "金额",
"status": "外部流水",
"extra": "失败原因",
"time": "创建时间"
},
"hotNumberRisk": {
"primary": "号码 / 日志",
"secondary": "期号 / 动作",
"metricA": "封顶 / 金额",
"metricB": "已占用 / 玩法",
"metricC": "剩余 / 注单",
"status": "售罄 / 玩家",
"extra": "使用率 / 原因",
"time": "版本 / 时间"
},
"playDimension": {
"primary": "玩法",
"secondary": "维度",
"metricA": "下注",
"metricB": "派彩",
"metricC": "平台盈亏",
"status": "占比",
"extra": "备注",
"time": "时间"
},
"soldOut": {
"primary": "号码",
"secondary": "期号",
"metricA": "封顶",
"metricB": "已占用",
"metricC": "剩余",
"status": "是否售罄",
"extra": "使用率",
"time": "版本"
},
"rebateCommission": {
"primary": "玩法",
"secondary": "订单数",
"metricA": "回水",
"metricB": "注单数",
"metricC": "佣金",
"status": "配置命中",
"extra": "备注",
"time": "时间"
},
"adminAudit": {
"primary": "日志 ID",
"secondary": "操作者类型",
"metricA": "操作者 ID",
"metricB": "模块",
"metricC": "动作",
"status": "目标类型",
"extra": "IP",
"time": "时间"
}
},
"stats": {
"records": "记录数",
@@ -179,7 +273,7 @@
},
"daily_profit": {
"title": "每日盈亏汇总",
"summary": "按自然日汇总投注、派奖、退款、盈亏和净额。"
"summary": "按业务日汇总投注、派彩与平台盈亏,当前不包含退款与单独净额字段。"
},
"player_win_loss": {
"title": "玩家输赢报表",

View File

@@ -0,0 +1,23 @@
import type { AgentNodeRow } from "@/types/api/admin-agent";
export type FlatAgentOption = {
id: number;
label: string;
depth: number;
};
export function flattenAgentTree(nodes: readonly AgentNodeRow[], depth = 0): FlatAgentOption[] {
const out: FlatAgentOption[] = [];
for (const node of nodes) {
const prefix = depth > 0 ? `${"—".repeat(depth)} ` : "";
out.push({
id: node.id,
depth,
label: `${prefix}${node.name} (${node.code})`,
});
if (node.children?.length) {
out.push(...flattenAgentTree(node.children, depth + 1));
}
}
return out;
}

View File

@@ -0,0 +1,48 @@
import type { LucideIcon } from "lucide-react";
import {
CalendarClock,
LayoutDashboard,
Network,
Settings,
SlidersHorizontal,
Wallet,
} from "lucide-react";
import type { AdminNavGroup, AdminNavItem } from "@/modules/_config/admin-nav";
export const ADMIN_NAV_GROUP_ICON: Record<AdminNavGroup, LucideIcon> = {
overview: LayoutDashboard,
agent: Network,
operations: CalendarClock,
finance: Wallet,
rules: SlidersHorizontal,
platform: Settings,
};
/** 与 Laravel {@link AdminAuthorizationRegistry::NAV_GROUP_ORDER} 一致 */
export const ADMIN_NAV_GROUP_ORDER: readonly AdminNavGroup[] = [
"overview",
"agent",
"operations",
"finance",
"rules",
"platform",
] as const;
export function groupAdminNavItems(
items: readonly AdminNavItem[],
): { group: AdminNavGroup; items: AdminNavItem[] }[] {
const buckets = new Map<AdminNavGroup, AdminNavItem[]>();
for (const item of items) {
const group = item.nav_group ?? "operations";
const list = buckets.get(group) ?? [];
list.push(item);
buckets.set(group, list);
}
return ADMIN_NAV_GROUP_ORDER.filter((group) => buckets.has(group)).map((group) => ({
group,
items: buckets.get(group)!,
}));
}

View File

@@ -21,6 +21,7 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
audit: "audit",
settings: "settings",
integration: "integration",
agents: "agents",
config: "config",
};

View File

@@ -44,6 +44,14 @@ export function getAdminPlayTypesLoadPromise(
return inflightLoad;
}
/** 确保玩法目录已加载并返回缓存列表(全局去重,配置页勿直接 getAdminPlayTypes */
export async function ensureAdminPlayTypesLoaded(
loader: () => Promise<{ items: AdminPlayTypeRow[] }>,
): Promise<AdminPlayTypeRow[]> {
await getAdminPlayTypesLoadPromise(loader);
return getCachedAdminPlayTypes();
}
/** 解析玩法显示名;无配置时回退 play_code */
export function resolveAdminPlayTypeDisplayName(
playCode: string | null | undefined,

View File

@@ -131,3 +131,20 @@ export const PRD_PAYOUT_ACCESS_ANY = [
/** 接入站点配置页 */
export const PRD_INTEGRATION_ACCESS_ANY = [PRD_INTEGRATION_VIEW, PRD_INTEGRATION_MANAGE] as const;
/** 代理管理 */
export const PRD_AGENT_VIEW = "prd.agent.view" as const;
export const PRD_AGENT_MANAGE = "prd.agent.manage" as const;
export const PRD_AGENT_ROLE_VIEW = "prd.agent.role.view" as const;
export const PRD_AGENT_ROLE_MANAGE = "prd.agent.role.manage" as const;
export const PRD_AGENT_USER_VIEW = "prd.agent.user.view" as const;
export const PRD_AGENT_USER_MANAGE = "prd.agent.user.manage" as const;
export const PRD_AGENTS_ACCESS_ANY = [
PRD_AGENT_VIEW,
PRD_AGENT_MANAGE,
PRD_AGENT_ROLE_VIEW,
PRD_AGENT_ROLE_MANAGE,
PRD_AGENT_USER_VIEW,
PRD_AGENT_USER_MANAGE,
] as const;

View File

@@ -0,0 +1,44 @@
import { getAdminSettings } from "@/api/admin-settings";
const SETTLEMENT_GROUP = "settlement";
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
let cachedApplyRebateToPayout: boolean | null = null;
let inflight: Promise<boolean> | null = null;
export function peekApplyRebateToPayoutSetting(): boolean | null {
return cachedApplyRebateToPayout;
}
export function setCachedApplyRebateToPayoutSetting(value: boolean): void {
cachedApplyRebateToPayout = value;
}
export function clearCachedSettlementSettings(): void {
cachedApplyRebateToPayout = null;
inflight = null;
}
/** 读取「派彩时再扣回水」开关(模块级缓存,避免配置页重复 GET settings */
export async function loadApplyRebateToPayoutSetting(): Promise<boolean> {
if (cachedApplyRebateToPayout !== null) {
return cachedApplyRebateToPayout;
}
if (inflight !== null) {
return inflight;
}
inflight = getAdminSettings(SETTLEMENT_GROUP)
.then((res) => {
const hit = res.items.find((item) => item.key === APPLY_REBATE_TO_PAYOUT_KEY);
const value = Boolean(hit?.value ?? false);
cachedApplyRebateToPayout = value;
return value;
})
.catch(() => false)
.finally(() => {
inflight = null;
});
return inflight;
}

View File

@@ -2,6 +2,15 @@ import { getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
const DEFAULT_DECIMAL_PLACES = 2;
/** 接口缺字段或非数字时按 0 处理,避免仪表盘出现 NPRNaN */
export function coerceAdminMinor(value: unknown): number {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) {
return 0;
}
return Math.trunc(n);
}
export function getAdminCurrencyDecimalPlaces(currencyCode: string | null | undefined): number {
const code = currencyCode?.trim().toUpperCase();
if (!code) {
@@ -23,11 +32,12 @@ export function formatAdminMinorUnits(
currencyCode = "NPR",
decimalPlaces?: number,
): string {
const safeMinor = coerceAdminMinor(minor);
const resolvedDecimalPlaces =
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
? decimalPlaces
: getAdminCurrencyDecimalPlaces(currencyCode);
const major = minor / 10 ** resolvedDecimalPlaces;
const major = safeMinor / 10 ** resolvedDecimalPlaces;
return `${currencyCode} ${major.toLocaleString(undefined, {
minimumFractionDigits: resolvedDecimalPlaces,
maximumFractionDigits: resolvedDecimalPlaces,

View File

@@ -15,6 +15,7 @@ import enSettlement from "@/i18n/locales/en/settlement.json";
import enCommon from "@/i18n/locales/en/common.json";
import enTickets from "@/i18n/locales/en/tickets.json";
import enWallet from "@/i18n/locales/en/wallet.json";
import enAgents from "@/i18n/locales/en/agents.json";
const EN_FLAT: Record<string, Record<string, unknown>> = {
dashboard: enDashboard,
@@ -33,6 +34,7 @@ const EN_FLAT: Record<string, Record<string, unknown>> = {
config: enConfig,
common: enCommon,
auth: enAuth,
agents: enAgents,
};
function getByPath(obj: Record<string, unknown>, path: string): string | undefined {

View File

@@ -3,9 +3,11 @@ import {
CalendarClock,
CircleDollarSign,
FileSpreadsheet,
Globe,
Landmark,
LayoutDashboard,
LogIn,
Network,
Scale,
ScrollText,
Settings,
@@ -23,6 +25,7 @@ import type { AdminNavItem } from "@/modules/_config/admin-nav";
export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon> =
{
dashboard: LayoutDashboard,
agents: Network,
players: Users,
draws: CalendarClock,
rules_plays: SlidersHorizontal,
@@ -39,6 +42,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
admin_users: ShieldCheck,
admin_roles: ShieldCheck,
currencies: CircleDollarSign,
integration: Globe,
settings: Settings,
};

View File

@@ -1,7 +1,16 @@
export const ADMIN_BASE = "/admin" as const;
export type AdminNavGroup =
| "overview"
| "agent"
| "operations"
| "finance"
| "rules"
| "platform";
export type AdminNavSegment =
| "dashboard"
| "agents"
| "players"
| "draws"
| "rules_plays"
@@ -18,12 +27,15 @@ export type AdminNavSegment =
| "audit"
| "admin_users"
| "admin_roles"
| "currencies";
| "currencies"
| "integration";
export type AdminNavItem = {
label: string;
href: string;
segment: AdminNavSegment;
nav_group?: AdminNavGroup;
platform_only?: boolean;
activeMatchPrefix?: string;
requiredAny?: readonly string[];
};

View File

@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -32,6 +34,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -59,6 +62,7 @@ function permissionLabel(slug: string, fallback: string, t: (key: string) => str
export function AdminRolesConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
const tRef = useTranslationRef(["adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
@@ -118,19 +122,17 @@ export function AdminRolesConsole(): React.ReactElement {
setCatalog(catalogData);
setRoles(roleData.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("roleLoadFailed");
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("roleLoadFailed");
setErr(msg);
setRoles([]);
} finally {
setLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, []);
function isDirectGroupOpen(key: string): boolean {
return directMenuExpanded[key] === true;
@@ -329,9 +331,6 @@ export function AdminRolesConsole(): React.ReactElement {
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && roles.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
<div className="rounded-md border">
<Table id="admin-roles-table">
<TableHeader>
@@ -347,7 +346,9 @@ export function AdminRolesConsole(): React.ReactElement {
</TableRow>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
{loading && roles.length === 0 ? (
<AdminTableLoadingRow colSpan={8} />
) : roles.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}

View File

@@ -1,10 +1,12 @@
"use client";
import { KeyRound, Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -34,6 +36,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -51,6 +54,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
export function AdminUsersConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
const tRef = useTranslationRef(["adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const exportLabels = useExportLabels("adminUsers");
const profile = useAdminProfile();
@@ -112,7 +116,7 @@ export function AdminUsersConsole(): React.ReactElement {
setTotal(listData.meta.total);
setLastPage(Math.max(1, listData.meta.last_page));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
setErr(msg);
setItems([]);
setTotal(0);
@@ -120,13 +124,11 @@ export function AdminUsersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query, t]);
}, [page, perPage, query]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [page, perPage, query]);
function toggleFormCreateRole(slug: string, checked: boolean): void {
setFormCreateRoles((prev) => {
@@ -360,9 +362,6 @@ export function AdminUsersConsole(): React.ReactElement {
</CardHeader>
<CardContent className="admin-list-content">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
<div className="admin-table-shell">
<Table id="admin-users-table">
<TableHeader>
@@ -377,7 +376,9 @@ export function AdminUsersConsole(): React.ReactElement {
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
{loading && items.length === 0 ? (
<AdminTableLoadingRow colSpan={7} />
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}

View File

@@ -0,0 +1,1084 @@
"use client";
import { ChevronRight, KeyRound, Pencil, Plus, Search, Shield, Trash2, Users } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { toast } from "sonner";
import {
deleteAgentNode,
deleteAgentRole,
getAgentNodeAdminUsers,
getAgentNodeRoles,
getAgentTree,
postAgentAdminUser,
postAgentNode,
postAgentRole,
putAgentAdminUserRoles,
putAgentNode,
putAgentRole,
putAgentRolePermissions,
getAgentDelegationGrants,
putAgentDelegationGrants,
} from "@/api/admin-agents";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { getAdminUserPermissionCatalog } from "@/api/admin-users";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_MANAGE,
PRD_AGENT_ROLE_MANAGE,
PRD_AGENT_ROLE_VIEW,
PRD_AGENT_USER_MANAGE,
PRD_AGENT_USER_VIEW,
} from "@/lib/admin-prd";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import type { AgentDelegationGrantRow, AgentNodeRow } from "@/types/api/admin-agent";
import type { AdminPermissionCatalogData, AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
import { LotteryApiBizError } from "@/types/api/errors";
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
const out: AgentNodeRow[] = [];
const walk = (list: AgentNodeRow[]) => {
for (const node of list) {
out.push(node);
if (node.children?.length) {
walk(node.children);
}
}
};
walk(nodes);
return out;
}
function filterTree(nodes: AgentNodeRow[], keyword: string): AgentNodeRow[] {
const normalized = keyword.trim().toLowerCase();
if (!normalized) {
return nodes;
}
const filterNode = (node: AgentNodeRow): AgentNodeRow | null => {
const children = node.children
?.map((child) => filterNode(child))
.filter((child): child is AgentNodeRow => child !== null) ?? [];
const selfMatch =
node.name.toLowerCase().includes(normalized) || node.code.toLowerCase().includes(normalized);
if (!selfMatch && children.length === 0) {
return null;
}
return {
...node,
children,
};
};
return nodes.map((node) => filterNode(node)).filter((node): node is AgentNodeRow => node !== null);
}
function AgentTreeNodes({
nodes,
depth,
selectedId,
expandedIds,
onToggleExpand,
onSelect,
}: {
nodes: AgentNodeRow[];
depth: number;
selectedId: number | null;
expandedIds: Set<number>;
onToggleExpand: (nodeId: number) => void;
onSelect: (node: AgentNodeRow) => void;
}): React.ReactElement {
return (
<ul className={depth === 0 ? "space-y-0.5" : "ml-3 border-l border-border pl-2"}>
{nodes.map((node) => (
<li key={node.id}>
<div
className={cn(
"group flex items-center gap-1 rounded-md pr-2",
selectedId === node.id ? "bg-primary/5 ring-1 ring-primary/20" : "hover:bg-muted/60",
)}
>
{node.children && node.children.length > 0 ? (
<button
type="button"
aria-label="toggle children"
onClick={() => onToggleExpand(node.id)}
className="ml-1 rounded-sm p-0.5 text-muted-foreground hover:bg-muted"
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 transition-transform",
expandedIds.has(node.id) && "rotate-90",
)}
/>
</button>
) : (
<span className="ml-1 size-4 shrink-0" />
)}
<button
type="button"
onClick={() => onSelect(node)}
className={cn(
"flex min-w-0 flex-1 items-center gap-1 rounded-md py-1.5 text-left text-sm",
selectedId === node.id && "font-medium text-foreground",
)}
>
<span className="truncate">{node.name}</span>
<span className="ml-auto font-mono text-[11px] text-muted-foreground">{node.code}</span>
</button>
</div>
{node.children && node.children.length > 0 && expandedIds.has(node.id) ? (
<AgentTreeNodes
nodes={node.children}
depth={depth + 1}
selectedId={selectedId}
expandedIds={expandedIds}
onToggleExpand={onToggleExpand}
onSelect={onSelect}
/>
) : null}
</li>
))}
</ul>
);
}
export function AgentsConsole(): React.ReactElement {
const { t } = useTranslation(["agents", "adminUsers", "common"]);
const tRef = useTranslationRef(["agents", "adminUsers", "common"]);
const profile = useAdminProfile();
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const canManageNode = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_MANAGE]);
const canViewRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_VIEW, PRD_AGENT_ROLE_MANAGE]);
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_MANAGE]);
const canViewUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_VIEW, PRD_AGENT_USER_MANAGE]);
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_MANAGE]);
const isSuperAdmin = profile?.is_super_admin === true;
const [siteOptions, setSiteOptions] = useState<{ id: number; label: string }[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
const [tree, setTree] = useState<AgentNodeRow[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [treeKeyword, setTreeKeyword] = useState("");
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<number>>(new Set());
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
const [users, setUsers] = useState<AdminUserPermissionRow[]>([]);
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
const [nodeCode, setNodeCode] = useState("");
const [nodeName, setNodeName] = useState("");
const [nodeStatus, setNodeStatus] = useState(1);
const [nodeSaving, setNodeSaving] = useState(false);
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
const [roleSlug, setRoleSlug] = useState("");
const [roleName, setRoleName] = useState("");
const [rolePerms, setRolePerms] = useState<string[]>([]);
const [roleSaving, setRoleSaving] = useState(false);
const [permDialogOpen, setPermDialogOpen] = useState(false);
const [permRoleId, setPermRoleId] = useState<number | null>(null);
const [draftPerms, setDraftPerms] = useState<string[]>([]);
const [permSaving, setPermSaving] = useState(false);
const [userDialogOpen, setUserDialogOpen] = useState(false);
const [userUsername, setUserUsername] = useState("");
const [userNickname, setUserNickname] = useState("");
const [userPassword, setUserPassword] = useState("");
const [userRoleIds, setUserRoleIds] = useState<number[]>([]);
const [userSaving, setUserSaving] = useState(false);
const [delegationGrants, setDelegationGrants] = useState<AgentDelegationGrantRow[]>([]);
const [delegationSaving, setDelegationSaving] = useState(false);
const flatNodes = useMemo(() => flattenTree(tree), [tree]);
const filteredTree = useMemo(() => filterTree(tree, treeKeyword), [tree, treeKeyword]);
const selected = useMemo(
() => flatNodes.find((n) => n.id === selectedId) ?? null,
[flatNodes, selectedId],
);
const selectedChildrenCount = selected?.children?.length ?? 0;
const selectedDescendantCount = useMemo(() => {
if (!selected?.children?.length) {
return 0;
}
return flattenTree(selected.children).length;
}, [selected]);
const canManageDelegation =
canManageNode &&
selected !== null &&
!selected.is_root &&
(isSuperAdmin || profile?.agent?.id === selected.parent_id);
const assignablePermissionSlugs = useMemo(() => {
const mine = new Set(profile?.permissions ?? []);
const slugs: string[] = [];
for (const group of catalog?.permission_menu_groups ?? []) {
for (const p of group.permissions) {
if (mine.has(p.slug)) {
slugs.push(p.slug);
}
}
}
if (slugs.length === 0) {
for (const p of catalog?.permissions ?? []) {
if (mine.has(p.slug)) {
slugs.push(p.slug);
}
}
}
return slugs;
}, [catalog, profile?.permissions]);
const loadTree = useCallback(async (siteId?: number | null) => {
setLoading(true);
setErr(null);
try {
const data = await getAgentTree(siteId ?? undefined);
setTree(data.tree);
setAdminSiteId(data.admin_site_id);
setExpandedNodeIds(new Set(flattenTree(data.tree).map((node) => node.id)));
if (selectedId === null && data.tree.length > 0) {
const first = flattenTree(data.tree)[0];
if (first) {
setSelectedId(first.id);
}
}
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
} finally {
setLoading(false);
}
}, [selectedId, tRef]);
const loadDetail = useCallback(async (nodeId: number) => {
if (canViewRoles) {
const roleData = await getAgentNodeRoles(nodeId);
setRoles(roleData.items);
} else {
setRoles([]);
}
if (canViewUsers) {
const userData = await getAgentNodeAdminUsers(nodeId);
setUsers(userData.items);
} else {
setUsers([]);
}
const node = flattenTree(tree).find((n) => n.id === nodeId);
const showDelegation =
canManageNode &&
node !== undefined &&
!node.is_root &&
(isSuperAdmin || profile?.agent?.id === node.parent_id);
if (showDelegation) {
const grantData = await getAgentDelegationGrants(nodeId);
setDelegationGrants(grantData.grants);
} else {
setDelegationGrants([]);
}
}, [canManageNode, canViewRoles, canViewUsers, isSuperAdmin, profile?.agent?.id, tree]);
useAsyncEffect(() => {
if (isSuperAdmin) {
void getAdminIntegrationSites()
.then((data) => {
setSiteOptions(
data.items.map((row) => ({ id: row.id, label: `${row.name} (${row.code})` })),
);
if (data.items.length > 0 && adminSiteId === null) {
setAdminSiteId(data.items[0]?.id ?? null);
}
})
.catch(() => setSiteOptions([]));
} else if (profile?.agent?.admin_site_id) {
setAdminSiteId(profile.agent.admin_site_id);
}
void getAdminUserPermissionCatalog().then(setCatalog).catch(() => setCatalog(null));
}, [isSuperAdmin, profile?.agent?.admin_site_id]);
useAsyncEffect(() => {
if (adminSiteId === null && !isSuperAdmin && profile?.agent?.admin_site_id) {
setAdminSiteId(profile.agent.admin_site_id);
return;
}
if (adminSiteId !== null || !isSuperAdmin) {
void loadTree(adminSiteId);
}
}, [adminSiteId, isSuperAdmin, loadTree, profile?.agent?.admin_site_id]);
useAsyncEffect(() => {
if (selectedId !== null) {
void loadDetail(selectedId).catch(() => {
toast.error(tRef.current("loadFailed"));
});
}
}, [selectedId, loadDetail, tRef]);
const openCreateChild = () => {
if (!selected) {
return;
}
setNodeDialogMode("create");
setNodeCode("");
setNodeName("");
setNodeStatus(1);
setNodeDialogOpen(true);
};
const openEditNode = () => {
if (!selected) {
return;
}
setNodeDialogMode("edit");
setNodeCode(selected.code);
setNodeName(selected.name);
setNodeStatus(selected.status);
setNodeDialogOpen(true);
};
const saveNode = async () => {
if (!nodeName.trim() || (nodeDialogMode === "create" && !nodeCode.trim())) {
toast.error(t("codeRequired"));
return;
}
setNodeSaving(true);
try {
if (nodeDialogMode === "create" && selected) {
await postAgentNode({
parent_id: selected.id,
code: nodeCode.trim(),
name: nodeName.trim(),
status: nodeStatus,
});
toast.success(t("createSuccess", { name: nodeName.trim() }));
} else if (selected) {
await putAgentNode(selected.id, { name: nodeName.trim(), status: nodeStatus });
toast.success(t("updateSuccess", { name: nodeName.trim() }));
}
setNodeDialogOpen(false);
await loadTree(adminSiteId);
if (selectedId !== null) {
await loadDetail(selectedId);
}
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setNodeSaving(false);
}
};
const saveNewRole = async () => {
if (!selected || !roleSlug.trim() || !roleName.trim()) {
return;
}
setRoleSaving(true);
try {
await postAgentRole(selected.id, {
slug: roleSlug.trim(),
name: roleName.trim(),
permission_slugs: rolePerms,
});
toast.success(t("roles.createSuccess", { name: roleName.trim() }));
setRoleDialogOpen(false);
await loadDetail(selected.id);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setRoleSaving(false);
}
};
const saveRolePermissions = async () => {
if (permRoleId === null) {
return;
}
setPermSaving(true);
try {
await putAgentRolePermissions(permRoleId, draftPerms);
toast.success(t("roles.permissionSaveSuccess"));
setPermDialogOpen(false);
if (selectedId !== null) {
await loadDetail(selectedId);
}
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setPermSaving(false);
}
};
const saveDelegation = async () => {
if (!selected) {
return;
}
setDelegationSaving(true);
try {
const data = await putAgentDelegationGrants(selected.id, {
grants: delegationGrants.map((g) => ({
menu_action_id: g.menu_action_id,
can_delegate: g.can_delegate,
})),
});
setDelegationGrants(data.grants);
toast.success(t("delegation.saveSuccess"));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setDelegationSaving(false);
}
};
const saveNewUser = async () => {
if (!selected || !userUsername.trim() || !userPassword.trim()) {
return;
}
setUserSaving(true);
try {
await postAgentAdminUser(selected.id, {
username: userUsername.trim(),
nickname: userNickname.trim() || userUsername.trim(),
password: userPassword,
role_ids: userRoleIds,
});
toast.success(t("users.createSuccess", { name: userNickname.trim() || userUsername.trim() }));
setUserDialogOpen(false);
await loadDetail(selected.id);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setUserSaving(false);
}
};
if (loading && tree.length === 0) {
return <AdminLoadingState label={t("treeTitle")} />;
}
return (
<div className="space-y-4">
<ConfirmDialog />
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl font-semibold">{t("title")}</h1>
{isSuperAdmin && siteOptions.length > 0 ? (
<Select
value={adminSiteId !== null ? String(adminSiteId) : undefined}
onValueChange={(v) => setAdminSiteId(Number(v))}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder={t("siteLabel")} />
</SelectTrigger>
<SelectContent>
{siteOptions.map((opt) => (
<SelectItem key={opt.id} value={String(opt.id)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
<div className="grid gap-4 lg:grid-cols-[minmax(220px,280px)_1fr]">
<AdminPageCard title={t("treeTitle")}>
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={treeKeyword}
onChange={(e) => setTreeKeyword(e.target.value)}
className="pl-8"
placeholder={t("treeSearch", { defaultValue: "搜索代理编码/名称" })}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setExpandedNodeIds(new Set(flatNodes.map((node) => node.id)))}
>
{t("expandAll", { defaultValue: "展开全部" })}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
selected ? setExpandedNodeIds(new Set([selected.id])) : setExpandedNodeIds(new Set())
}
>
{t("collapseAll", { defaultValue: "收起全部" })}
</Button>
</div>
</div>
<ScrollArea className="mt-3 h-[min(66vh,620px)] pr-2">
<AgentTreeNodes
nodes={filteredTree}
depth={0}
selectedId={selectedId}
expandedIds={expandedNodeIds}
onToggleExpand={(nodeId) => {
setExpandedNodeIds((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
}}
onSelect={(node) => setSelectedId(node.id)}
/>
</ScrollArea>
</AdminPageCard>
<AdminPageCard title={selected ? selected.name : t("detailTitle")}>
{!selected ? (
<p className="text-sm text-muted-foreground">{t("selectNode")}</p>
) : (
<Tabs defaultValue="overview">
<div className="mb-4 grid gap-3 rounded-xl border bg-muted/20 p-3 md:grid-cols-4">
<div className="space-y-1 rounded-lg bg-background/80 p-3">
<p className="text-xs text-muted-foreground">{t("status")}</p>
<div className="flex items-center gap-2">
<AdminStatusBadge tone={resolveRoleStatusTone(selected.status)}>
{selected.status === 1
? t("common:status.enabled", { defaultValue: "Enabled" })
: t("common:status.disabled", { defaultValue: "Disabled" })}
</AdminStatusBadge>
{selected.is_root ? (
<Badge variant="secondary">{t("isRoot", { defaultValue: "Root" })}</Badge>
) : null}
</div>
</div>
<div className="space-y-1 rounded-lg bg-background/80 p-3">
<p className="text-xs text-muted-foreground">{t("childrenCount", { defaultValue: "直属下级" })}</p>
<p className="text-lg font-semibold">{selectedChildrenCount}</p>
</div>
<div className="space-y-1 rounded-lg bg-background/80 p-3">
<p className="text-xs text-muted-foreground">{t("descendantsCount", { defaultValue: "全部下级" })}</p>
<p className="text-lg font-semibold">{selectedDescendantCount}</p>
</div>
<div className="space-y-1 rounded-lg bg-background/80 p-3">
<p className="text-xs text-muted-foreground">{t("nodeCode", { defaultValue: "节点编码" })}</p>
<p className="truncate font-mono text-xs text-muted-foreground">{selected.code}</p>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<TabsList>
<TabsTrigger value="overview">{t("tabs.overview")}</TabsTrigger>
{canViewRoles ? <TabsTrigger value="roles">{t("tabs.roles")}</TabsTrigger> : null}
{canViewUsers ? <TabsTrigger value="users">{t("tabs.users")}</TabsTrigger> : null}
{canManageDelegation ? (
<TabsTrigger value="delegation">{t("tabs.delegation")}</TabsTrigger>
) : null}
</TabsList>
{canManageNode && !selected.is_root ? (
<Button type="button" size="sm" variant="outline" onClick={openEditNode}>
<Pencil className="mr-1 size-3.5" />
{t("editNode")}
</Button>
) : null}
{canManageNode ? (
<Button type="button" size="sm" onClick={openCreateChild}>
<Plus className="mr-1 size-3.5" />
{t("createChild")}
</Button>
) : null}
</div>
<TabsContent value="overview" className="space-y-2 text-sm">
<div className="grid gap-3 xl:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-2 rounded-xl border bg-background p-4">
<p>
<span className="text-muted-foreground">{t("code")}:</span> {selected.code}
</p>
<p>
<span className="text-muted-foreground">{t("depth")}:</span> {selected.depth}
</p>
<p>
<span className="text-muted-foreground">{t("path")}:</span>{" "}
<code className="text-xs">{selected.path}</code>
</p>
</div>
<div className="space-y-3 rounded-xl border bg-background p-4">
<p className="text-sm font-medium">{t("quickActions", { defaultValue: "常用操作" })}</p>
{canManageNode ? (
<Button type="button" className="w-full justify-start" onClick={openCreateChild}>
<Plus className="mr-1 size-3.5" />
{t("createChild")}
</Button>
) : null}
{canManageNode && !selected.is_root ? (
<Button type="button" variant="outline" className="w-full justify-start" onClick={openEditNode}>
<Pencil className="mr-1 size-3.5" />
{t("editNode")}
</Button>
) : null}
{canManageNode && !selected.is_root ? (
<Button
type="button"
variant="destructive"
className="w-full justify-start"
onClick={() => {
requestConfirm({
title: selected.name,
description: t("deleteNodeConfirm", {
defaultValue: "删除后不可恢复,请确认该节点无下级、无账号、无角色绑定。",
}),
onConfirm: async () => {
await deleteAgentNode(selected.id);
toast.success(t("deleteSuccess", { name: selected.name }));
setSelectedId(selected.parent_id ?? null);
await loadTree(adminSiteId);
},
});
}}
>
<Trash2 className="mr-1 size-3.5" />
{t("deleteNode", { defaultValue: "删除节点" })}
</Button>
) : null}
{canViewRoles ? (
<Button
type="button"
variant="outline"
className="w-full justify-start"
onClick={() => {
setRoleSlug("");
setRoleName("");
setRolePerms([]);
setRoleDialogOpen(true);
}}
disabled={!canManageRoles}
>
<Shield className="mr-1 size-3.5" />
{t("roles.create")}
</Button>
) : null}
{canViewUsers ? (
<Button
type="button"
variant="outline"
className="w-full justify-start"
onClick={() => {
setUserUsername("");
setUserNickname("");
setUserPassword("");
setUserRoleIds([]);
setUserDialogOpen(true);
}}
disabled={!canManageUsers}
>
<Users className="mr-1 size-3.5" />
{t("users.create")}
</Button>
) : null}
</div>
</div>
</TabsContent>
{canViewRoles ? (
<TabsContent value="roles">
<div className="mb-3 flex justify-end">
{canManageRoles ? (
<Button
type="button"
size="sm"
onClick={() => {
setRoleSlug("");
setRoleName("");
setRolePerms([]);
setRoleDialogOpen(true);
}}
>
<Plus className="mr-1 size-3.5" />
{t("roles.create")}
</Button>
) : null}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("roles.slug")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("roles.userCount")}</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role) => (
<TableRow key={role.id}>
<TableCell className="font-mono text-xs">{role.slug}</TableCell>
<TableCell>{role.name}</TableCell>
<TableCell>{role.user_count}</TableCell>
<TableCell>
{canManageRoles && !role.is_read_only_template ? (
<AdminRowActionsMenu
actions={[
{
key: "permissions",
label: t("roles.permissions"),
icon: KeyRound,
onClick: () => {
setPermRoleId(role.id);
setDraftPerms([...role.permission_slugs]);
setPermDialogOpen(true);
},
},
{
key: "delete",
label: t("common:actions.delete", { defaultValue: "Delete" }),
icon: Trash2,
destructive: true,
onClick: () => {
requestConfirm({
title: role.name,
description: t("common:confirm.deleteDescription", {
defaultValue: "This cannot be undone.",
}),
onConfirm: async () => {
await deleteAgentRole(role.id);
toast.success(t("roles.deleteSuccess", { name: role.name }));
if (selectedId !== null) {
await loadDetail(selectedId);
}
},
});
},
},
]}
/>
) : role.is_read_only_template ? (
<span className="text-xs text-muted-foreground">
{t("roles.readOnlyTemplate")}
</span>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
) : null}
{canViewUsers ? (
<TabsContent value="users">
<div className="mb-3 flex justify-end">
{canManageUsers ? (
<Button
type="button"
size="sm"
onClick={() => {
setUserUsername("");
setUserNickname("");
setUserPassword("");
setUserRoleIds([]);
setUserDialogOpen(true);
}}
>
<Users className="mr-1 size-3.5" />
{t("users.create")}
</Button>
) : null}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("users.username")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("users.roles")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.nickname}</TableCell>
<TableCell className="text-xs">{user.roles.join(", ") || "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
) : null}
{canManageDelegation ? (
<TabsContent value="delegation">
<p className="mb-3 text-sm text-muted-foreground">{t("delegation.hint")}</p>
{delegationGrants.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("delegation.empty")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("delegation.permission")}</TableHead>
<TableHead className="w-[140px]">{t("delegation.canDelegate")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{delegationGrants.map((grant) => (
<TableRow key={grant.menu_action_id}>
<TableCell>
<div className="font-medium">{grant.name}</div>
<div className="font-mono text-xs text-muted-foreground">
{grant.permission_code}
</div>
</TableCell>
<TableCell>
<Checkbox
checked={grant.can_delegate}
onCheckedChange={(checked) => {
setDelegationGrants((prev) =>
prev.map((row) =>
row.menu_action_id === grant.menu_action_id
? { ...row, can_delegate: checked === true }
: row,
),
);
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="mt-4 flex justify-end">
<Button
type="button"
size="sm"
disabled={delegationSaving || delegationGrants.length === 0}
onClick={() => void saveDelegation()}
>
{t("delegation.save")}
</Button>
</div>
</TabsContent>
) : null}
</Tabs>
)}
</AdminPageCard>
</div>
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{nodeDialogMode === "create" ? t("createChild") : t("editNode")}
</DialogTitle>
</DialogHeader>
{nodeDialogMode === "create" ? (
<div className="space-y-2">
<Label htmlFor="agent-code">{t("code")}</Label>
<Input
id="agent-code"
value={nodeCode}
onChange={(e) => setNodeCode(e.target.value)}
autoComplete="off"
/>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="agent-name">{t("name")}</Label>
<Input
id="agent-name"
value={nodeName}
onChange={(e) => setNodeName(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={nodeStatus === 1} onCheckedChange={(v) => setNodeStatus(v ? 1 : 0)} />
<Label>{t("status")}</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "Cancel" })}
</Button>
<Button type="button" disabled={nodeSaving} onClick={() => void saveNode()}>
{t("common:actions.save", { defaultValue: "Save" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("roles.create")}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>{t("roles.slug")}</Label>
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t("name")}</Label>
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
</div>
<p className="text-xs text-muted-foreground">{t("roles.permissionSubsetHint")}</p>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border p-2">
{assignablePermissionSlugs.map((slug) => (
<label key={slug} className="flex items-center gap-2 text-sm">
<Checkbox
checked={rolePerms.includes(slug)}
onCheckedChange={(checked) => {
setRolePerms((prev) =>
checked ? [...prev, slug] : prev.filter((s) => s !== slug),
);
}}
/>
<span className="font-mono text-xs">{slug}</span>
</label>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRoleDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "Cancel" })}
</Button>
<Button type="button" disabled={roleSaving} onClick={() => void saveNewRole()}>
{t("common:actions.save", { defaultValue: "Save" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("roles.permissions")}</DialogTitle>
</DialogHeader>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-md border p-2">
{assignablePermissionSlugs.map((slug) => (
<label key={slug} className="flex items-center gap-2 text-sm">
<Checkbox
checked={draftPerms.includes(slug)}
onCheckedChange={(checked) => {
setDraftPerms((prev) =>
checked ? [...prev, slug] : prev.filter((s) => s !== slug),
);
}}
/>
<span className="font-mono text-xs">{slug}</span>
</label>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPermDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "Cancel" })}
</Button>
<Button type="button" disabled={permSaving} onClick={() => void saveRolePermissions()}>
{t("common:actions.save", { defaultValue: "Save" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={userDialogOpen} onOpenChange={setUserDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("users.create")}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>{t("users.username")}</Label>
<Input value={userUsername} onChange={(e) => setUserUsername(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t("name")}</Label>
<Input value={userNickname} onChange={(e) => setUserNickname(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t("users.password")}</Label>
<Input
type="password"
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
/>
</div>
{roles.length > 0 ? (
<div className="space-y-2">
<Label>{t("users.roles")}</Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded-md border p-2">
{roles.map((role) => (
<label key={role.id} className="flex items-center gap-2 text-sm">
<Checkbox
checked={userRoleIds.includes(role.id)}
onCheckedChange={(checked) => {
setUserRoleIds((prev) =>
checked
? [...prev, role.id]
: prev.filter((id) => id !== role.id),
);
}}
/>
{role.name}
</label>
))}
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setUserDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "Cancel" })}
</Button>
<Button type="button" disabled={userSaving} onClick={() => void saveNewUser()}>
{t("common:actions.save", { defaultValue: "Save" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,8 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminAuditLogs } from "@/api/admin-audit";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -26,6 +29,7 @@ import type { AdminAuditLogListData } from "@/types/api/admin-audit";
export function AuditLogsConsole(): React.ReactElement {
const { t } = useTranslation(["audit", "common"]);
const tRef = useTranslationRef(["audit", "common"]);
const exportLabels = useExportLabels("auditLogs");
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminAuditLogListData | null>(null);
@@ -69,18 +73,16 @@ export function AuditLogsConsole(): React.ReactElement {
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate, t]);
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
const meta = data?.meta;
@@ -200,11 +202,7 @@ export function AuditLogsConsole(): React.ReactElement {
</div>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
{(loading && !data) || data ? (
<>
<div className="admin-table-shell">
<Table id="audit-logs-table">
@@ -219,7 +217,9 @@ export function AuditLogsConsole(): React.ReactElement {
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
{loading && !data ? (
<AdminTableLoadingRow colSpan={6} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("empty")}

View File

@@ -32,11 +32,14 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
@@ -106,6 +109,7 @@ export function OddsConfigDocScreen({
onVersionIdChange,
}: OddsConfigDocScreenProps) {
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
@@ -144,15 +148,16 @@ export function OddsConfigDocScreen({
const refreshTypes = useCallback(async () => {
setLoadingTypes(true);
try {
const d = await getAdminPlayTypes();
setTypes(d.items);
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setTypes([]);
} finally {
setLoadingTypes(false);
}
}, [t]);
}, []);
const refreshList = useCallback(async () => {
setLoadingList(true);
@@ -161,23 +166,21 @@ export function OddsConfigDocScreen({
const d = await getAllConfigVersions(getOddsVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, [t]);
}, []);
useEffect(() => {
useAsyncEffect(() => {
if (workspace) {
return;
}
queueMicrotask(() => {
void refreshTypes();
void refreshList();
});
}, [refreshTypes, refreshList, workspace]);
void Promise.all([refreshTypes(), refreshList()]);
}, [workspace]);
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
@@ -186,13 +189,15 @@ export function OddsConfigDocScreen({
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, [t]);
}, []);
useEffect(() => {
if (workspace) {
@@ -638,9 +643,11 @@ export function OddsConfigDocScreen({
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
{resolvedLoadingDetail || resolvedLoadingTypes ? (
<p className={cn("text-center text-sm text-muted-foreground", mergedLayout ? "py-6" : "py-8")}>
{t("odds.loadingDetails", { ns: "config" })}
</p>
<AdminLoadingState
className={cn(mergedLayout ? "py-6" : "py-8")}
minHeight="6rem"
label={t("odds.loadingDetails", { ns: "config" })}
/>
) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">

View File

@@ -41,11 +41,14 @@ import {
TableRow,
} from "@/components/ui/table";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
import { PRD_PLAY_SWITCH_MANAGE } from "@/lib/admin-prd";
@@ -138,6 +141,7 @@ function buildPlayConfigSavePayload(
export function PlayConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
@@ -165,19 +169,18 @@ export function PlayConfigDocScreen() {
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, [t]);
}, []);
useEffect(() => {
queueMicrotask(() => {
void refreshList();
});
}, [refreshList]);
useAsyncEffect(() => {
void refreshList();
}, []);
const loadDetail = useCallback(async (id: number) => {
const requestSeq = detailRequestSeq.current + 1;
@@ -196,7 +199,9 @@ export function PlayConfigDocScreen() {
if (detailRequestSeq.current !== requestSeq) {
return;
}
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
} finally {
@@ -204,7 +209,7 @@ export function PlayConfigDocScreen() {
setLoadingDetail(false);
}
}
}, [t]);
}, []);
useEffect(() => {
if (list.length === 0) {
@@ -538,7 +543,7 @@ export function PlayConfigDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
<AdminLoadingState minHeight="6rem" className="py-6" />
) : (
<Table>
<TableHeader>

View File

@@ -19,7 +19,14 @@ import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { getAdminSettings, updateAdminSetting } from "@/api/admin-settings";
import { updateAdminSetting } from "@/api/admin-settings";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import {
loadApplyRebateToPayoutSetting,
setCachedApplyRebateToPayoutSetting,
} from "@/lib/admin-settlement-settings-cache";
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
@@ -33,6 +40,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
@@ -58,7 +66,6 @@ import {
} from "@/modules/config/doc/odds-rebate-rates";
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
const SETTLEMENT_GROUP = "settlement";
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
function dimensionDistinctPrimaryScopePercents(
@@ -98,6 +105,7 @@ export function RebateConfigDocScreen({
onVersionIdChange,
}: RebateConfigDocScreenProps) {
const { t } = useTranslation(["config", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
@@ -137,54 +145,52 @@ export function RebateConfigDocScreen({
const refreshTypes = useCallback(async () => {
try {
const d = await getAdminPlayTypes();
setTypes(d.items);
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setTypes([]);
}
}, [t]);
}, []);
const refreshList = useCallback(async () => {
try {
const d = await getAllConfigVersions(getOddsVersions);
setListRows(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setListRows([]);
}
}, [t]);
}, []);
const loadWinEnjoySetting = useCallback(async () => {
setWinEnjoyLoading(true);
try {
const res = await getAdminSettings(SETTLEMENT_GROUP);
const hit = res.items.find((item) => item.key === APPLY_REBATE_TO_PAYOUT_KEY);
setApplyRebateToPayout(Boolean(hit?.value));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
} finally {
setWinEnjoyLoading(false);
}
}, [t]);
useEffect(() => {
useAsyncEffect(() => {
if (workspace) {
return;
}
queueMicrotask(async () => {
void (async () => {
setLoading(true);
await refreshTypes();
await refreshList();
await Promise.all([refreshTypes(), refreshList()]);
setLoading(false);
});
}, [refreshTypes, refreshList, workspace]);
})();
}, [workspace]);
useEffect(() => {
queueMicrotask(() => {
void loadWinEnjoySetting();
});
}, [loadWinEnjoySetting]);
useAsyncEffect(() => {
void (async () => {
setWinEnjoyLoading(true);
try {
setApplyRebateToPayout(await loadApplyRebateToPayoutSetting());
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
} finally {
setWinEnjoyLoading(false);
}
})();
}, []);
useEffect(() => {
if (!workspace) {
@@ -202,6 +208,7 @@ export function RebateConfigDocScreen({
setWinEnjoySaving(true);
try {
await updateAdminSetting(APPLY_REBATE_TO_PAYOUT_KEY, checked);
setCachedApplyRebateToPayoutSetting(checked);
setApplyRebateToPayout(checked);
toast.success(t("rebate.winEnjoy.saveSuccess", { ns: "config" }));
} catch (e) {
@@ -214,8 +221,7 @@ export function RebateConfigDocScreen({
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
const pt = await getAdminPlayTypes();
const typeList = pt.items;
const typeList = await ensureAdminPlayTypesLoaded(getAdminPlayTypes);
setTypes(typeList);
const d = await getOddsVersion(id);
const rows = d.items.map((it) => ({ ...it }));
@@ -225,13 +231,15 @@ export function RebateConfigDocScreen({
setP3(inferRebatePercentFromDimension(3, rows, typeList));
setP4(inferRebatePercentFromDimension(4, rows, typeList));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, [t]);
}, []);
useEffect(() => {
if (workspace) {
@@ -614,7 +622,7 @@ export function RebateConfigDocScreen({
) : null}
{resolvedLoading || resolvedLoadingDetail ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
<AdminLoadingState minHeight="6rem" className="py-6" />
) : null}
</>
);

View File

@@ -32,6 +32,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
import {
@@ -45,7 +46,9 @@ import {
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
@@ -86,6 +89,7 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
export function RiskCapDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_RISK_CAP_MANAGE]);
@@ -113,19 +117,18 @@ export function RiskCapDocScreen() {
const d = await getAllConfigVersions(getRiskCapVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, [t]);
}, []);
useEffect(() => {
queueMicrotask(() => {
void refreshList();
});
}, [refreshList]);
useAsyncEffect(() => {
void refreshList();
}, []);
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
const defaultRow = rows.find(isDefaultRiskRow);
@@ -151,14 +154,16 @@ export function RiskCapDocScreen() {
setDraftRows(mapped);
syncDefaultCapFromRows(mapped);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
syncDefaultCapFromRows([]);
} finally {
setLoadingDetail(false);
}
}, [t]);
}, []);
useEffect(() => {
if (list.length === 0) {
@@ -498,7 +503,7 @@ export function RiskCapDocScreen() {
}
>
{loadingDetail ? (
<p className="text-sm text-muted-foreground">{t("riskCap.loadingDetails", { ns: "config" })}</p>
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
) : (

View File

@@ -1,13 +1,12 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getAdminSettings,
updateAdminSetting,
} from "@/api/admin-settings";
import { getAdminSettings, updateAdminSettingsBatch } from "@/api/admin-settings";
import { useOptionalAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
import { WALLET_GROUP, WALLET_KEYS } from "@/modules/settings/settings-keys";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { Button } from "@/components/ui/button";
import { ConfigDocPage } from "@/modules/config/config-doc-page";
@@ -15,15 +14,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LotteryApiBizError } from "@/types/api/errors";
const WALLET_GROUP = "wallet";
const KEYS = {
IN_MIN: "wallet.transfer_in_min_minor",
IN_MAX: "wallet.transfer_in_max_minor",
OUT_MIN: "wallet.transfer_out_min_minor",
OUT_MAX: "wallet.transfer_out_max_minor",
} as const;
function minorUnitsToDisplay(n: unknown, decimals = 2): string {
const num = Number(n);
if (!Number.isFinite(num)) return "";
@@ -43,12 +33,24 @@ interface Draft {
outMax: string;
}
function draftFromKv(kv: Record<string, unknown>): Draft {
return {
inMin: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MIN] ?? 100),
inMax: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MAX] ?? 0),
outMin: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MIN] ?? 100),
outMax: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MAX] ?? 0),
};
}
type WalletConfigDocScreenProps = {
embedded?: boolean;
};
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useRef(t);
tRef.current = t;
const shared = useOptionalAdminSettingsData();
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [draft, setDraft] = useState<Draft>({
inMin: "",
@@ -57,55 +59,81 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
outMax: "",
});
const [saved, setSaved] = useState<Draft>({ inMin: "", inMax: "", outMin: "", outMax: "" });
const [loading, setLoading] = useState(true);
const [standaloneLoading, setStandaloneLoading] = useState(!embedded);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const dirty =
draft.inMin !== saved.inMin ||
draft.inMax !== saved.inMax ||
draft.outMin !== saved.outMin ||
draft.outMax !== saved.outMax;
const load = useCallback(async () => {
setLoading(true);
const loading = embedded ? (shared?.loading ?? true) : standaloneLoading;
const loadStandalone = useCallback(async () => {
setStandaloneLoading(true);
try {
const res = await getAdminSettings(WALLET_GROUP);
const kv: Record<string, unknown> = {};
for (const item of res.items) {
kv[item.key] = item.value;
}
const d: Draft = {
inMin: minorUnitsToDisplay(kv[KEYS.IN_MIN] ?? 100),
inMax: minorUnitsToDisplay(kv[KEYS.IN_MAX] ?? 0),
outMin: minorUnitsToDisplay(kv[KEYS.OUT_MIN] ?? 100),
outMax: minorUnitsToDisplay(kv[KEYS.OUT_MAX] ?? 0),
};
const d = draftFromKv(kv);
setDraft(d);
setSaved(d);
setDirty(false);
} catch {
toast.error(t("wallet.loadFailed", { ns: "config" }));
toast.error(tRef.current("wallet.loadFailed", { ns: "config" }));
} finally {
setLoading(false);
setStandaloneLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
if (!embedded) {
void loadStandalone();
}
}, [embedded, loadStandalone]);
useEffect(() => {
if (!embedded || shared?.kv === null || shared?.kv === undefined) {
return;
}
const d = draftFromKv(shared.kv);
setDraft(d);
setSaved(d);
}, [embedded, shared?.kv]);
const handleChange = (field: keyof Draft, value: string) => {
setDraft((prev) => ({ ...prev, [field]: value }));
setDirty(true);
};
const handleSave = async () => {
const items = [];
if (draft.inMin !== saved.inMin) {
items.push({ key: WALLET_KEYS.IN_MIN, value: displayToMinorUnits(draft.inMin) });
}
if (draft.inMax !== saved.inMax) {
items.push({ key: WALLET_KEYS.IN_MAX, value: displayToMinorUnits(draft.inMax) });
}
if (draft.outMin !== saved.outMin) {
items.push({ key: WALLET_KEYS.OUT_MIN, value: displayToMinorUnits(draft.outMin) });
}
if (draft.outMax !== saved.outMax) {
items.push({ key: WALLET_KEYS.OUT_MAX, value: displayToMinorUnits(draft.outMax) });
}
if (items.length === 0) {
return;
}
setSaving(true);
try {
await updateAdminSetting(KEYS.IN_MIN, displayToMinorUnits(draft.inMin));
await updateAdminSetting(KEYS.IN_MAX, displayToMinorUnits(draft.inMax));
await updateAdminSetting(KEYS.OUT_MIN, displayToMinorUnits(draft.outMin));
await updateAdminSetting(KEYS.OUT_MAX, displayToMinorUnits(draft.outMax));
await updateAdminSettingsBatch(items);
const updates: Record<string, unknown> = {};
for (const item of items) {
updates[item.key] = item.value;
}
shared?.patchKv(updates);
toast.success(t("wallet.saveSuccess", { ns: "config" }));
setSaved(draft);
setDirty(false);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("wallet.saveFailed", { ns: "config" }),
@@ -186,13 +214,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
<Button variant="outline" onClick={() => setDraft(saved)} disabled={saving}>
{t("wallet.discard", { ns: "config" })}
</Button>
)}

View File

@@ -1,8 +1,10 @@
"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 { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import { getAdminDraws } from "@/api/admin-draws";
@@ -26,6 +28,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigSection } from "@/modules/config/config-section";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
@@ -37,6 +40,7 @@ type PoolFilter = "all" | "sold_out" | "high_risk";
export function RiskCapRuntimePanel() {
const { t } = useTranslation(["config", "risk", "draws", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const [draws, setDraws] = useState<AdminDrawListItem[]>([]);
const [drawsLoading, setDrawsLoading] = useState(true);
const [drawId, setDrawId] = useState<string>("");
@@ -64,12 +68,14 @@ export function RiskCapRuntimePanel() {
setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev));
}
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDraws([]);
} finally {
setDrawsLoading(false);
}
}, [t]);
}, []);
const loadPools = useCallback(async () => {
if (!drawId) {
@@ -94,24 +100,22 @@ export function RiskCapRuntimePanel() {
setPools(data.items);
setCurrencyCode(data.currency_code);
} catch (e) {
setPoolsError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setPoolsError(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setPools([]);
} finally {
setPoolsLoading(false);
}
}, [appliedNumber, drawId, poolFilter, t]);
}, [appliedNumber, drawId, poolFilter]);
useEffect(() => {
queueMicrotask(() => {
void loadDraws();
});
}, [loadDraws]);
useAsyncEffect(() => {
void loadDraws();
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadPools();
});
}, [loadPools]);
useAsyncEffect(() => {
void loadPools();
}, [appliedNumber, drawId, poolFilter]);
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;
@@ -226,11 +230,7 @@ export function RiskCapRuntimePanel() {
</TableHeader>
<TableBody>
{poolsLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("states.loading", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableLoadingRow colSpan={5} />
) : pools.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">

View File

@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -10,6 +9,8 @@ import {
getOddsVersion,
getOddsVersions,
} from "@/api/admin-config";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -42,7 +43,7 @@ export function useOddsConfigWorkspace(
selectedId: string,
onSelectedIdChange: (id: string) => void,
): OddsConfigWorkspace {
const { t } = useTranslation("common");
const tRef = useTranslationRef("common");
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
@@ -52,6 +53,7 @@ export function useOddsConfigWorkspace(
const [loadingDetail, setLoadingDetail] = useState(false);
const [error, setError] = useState<string | null>(null);
const detailRequestSeq = useRef(0);
const bootstrappedRef = useRef(false);
const applyDetail = useCallback((next: OddsVersionDetail) => {
setDetail(next);
@@ -61,15 +63,14 @@ export function useOddsConfigWorkspace(
const refreshTypes = useCallback(async () => {
setLoadingTypes(true);
try {
const d = await getAdminPlayTypes();
setTypes(d.items);
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed"));
setTypes([]);
} finally {
setLoadingTypes(false);
}
}, [t]);
}, []);
const refreshList = useCallback(async () => {
setLoadingList(true);
@@ -78,13 +79,13 @@ export function useOddsConfigWorkspace(
const d = await getAllConfigVersions(getOddsVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed");
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed");
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, [t]);
}, []);
const loadDetail = useCallback(
async (id: number) => {
@@ -100,7 +101,7 @@ export function useOddsConfigWorkspace(
if (seq !== detailRequestSeq.current) {
return;
}
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed"));
setDetail(null);
setDraftRows([]);
} finally {
@@ -109,7 +110,7 @@ export function useOddsConfigWorkspace(
}
}
},
[applyDetail, t],
[applyDetail],
);
const reloadDetail = useCallback(async () => {
@@ -124,8 +125,11 @@ export function useOddsConfigWorkspace(
}, [loadDetail, selectedId]);
useEffect(() => {
void refreshTypes();
void refreshList();
if (bootstrappedRef.current) {
return;
}
bootstrappedRef.current = true;
void Promise.all([refreshTypes(), refreshList()]);
}, [refreshTypes, refreshList]);
useEffect(() => {

View File

@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { cn } from "@/lib/utils";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
import {
DailyTrendChart,
PlayBreakdownChart,
@@ -356,6 +357,112 @@ export function DashboardPlayRankingCard({
);
}
export function DashboardAgentRankingCard({
analytics,
}: {
analytics: DashboardAnalyticsState;
}): ReactNode {
const { t } = useTranslation(["dashboard", "common"]);
const {
enabled,
rankingMetric,
loading,
topAgentRows,
currency,
formatMoney,
formatSignedMoney,
} = analytics;
if (!enabled) {
return null;
}
const metricValue = (row: (typeof topAgentRows)[number]): number => {
if (rankingMetric === "payout") {
return row.total_payout_minor;
}
if (rankingMetric === "profit") {
return row.approx_house_gross_minor;
}
return row.total_bet_minor;
};
const maxAbs = Math.max(1, ...topAgentRows.map((r) => Math.abs(metricValue(r))));
const formatRowValue = (row: (typeof topAgentRows)[number]): string => {
const v = metricValue(row);
if (rankingMetric === "profit") {
return formatSignedMoney(v, currency);
}
return formatMoney(v, currency);
};
const barColor = (row: (typeof topAgentRows)[number]): string => {
if (rankingMetric === "bet") {
return DASHBOARD_CHART_COLORS.primary;
}
if (rankingMetric === "payout") {
return DASHBOARD_CHART_COLORS.rose;
}
return row.approx_house_gross_minor >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
};
return (
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
<CardHeader className="space-y-2 border-b border-border/60 px-4 py-3">
<CardTitle className="text-sm font-semibold">{t("analytics.agentRanking")}</CardTitle>
<p className="text-xs text-muted-foreground">
{t(`analytics.rankingMetrics.${rankingMetric}`)}
</p>
</CardHeader>
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
{loading ? (
<Skeleton className="h-[210px] w-full" />
) : topAgentRows.length > 0 ? (
<div className="space-y-1.5">
{topAgentRows.map((row, idx) => {
const v = metricValue(row);
const pct = (Math.abs(v) / maxAbs) * 100;
const color = barColor(row);
return (
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 w-5 shrink-0 text-center text-[11px] font-semibold text-muted-foreground">
#{idx + 1}
</span>
<div className="min-w-0">
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p>
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p>
</div>
</div>
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
{formatRowValue(row)}
</div>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted/30">
<div
className="h-full rounded-full"
style={{
width: `${Math.max(2, pct)}%`,
backgroundColor: color,
opacity: 0.35,
}}
/>
</div>
</div>
);
})}
</div>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noAgentData")}</p>
)}
</CardContent>
</Card>
);
}
/** 单列堆叠布局(兼容旧用法) */
export function DashboardAnalyticsPanel({
enabled,

View File

@@ -20,14 +20,12 @@ import {
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import { getAdminPlayTypes } from "@/api/admin-config";
import {
getAdminPlayTypesLoadPromise,
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import {
DashboardAnalyticsMain,
DashboardAgentRankingCard,
DashboardPlayRankingCard,
} from "@/modules/dashboard/dashboard-analytics-panel";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
@@ -50,7 +48,11 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { normalizeAdminLanguage } from "@/i18n";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import {
coerceAdminMinor,
formatAdminMinorUnits,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -66,9 +68,10 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
type HotPlayTab = "4D" | "3D" | "2D" | "special";
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals;
const major = safeMinor / 10 ** decimals;
try {
return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency",
@@ -77,7 +80,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
maximumFractionDigits: decimals,
}).format(major);
} catch {
return formatAdminMinorUnits(minor, code, decimals);
return formatAdminMinorUnits(safeMinor, code, decimals);
}
}
@@ -162,28 +165,8 @@ export function DashboardConsole(): ReactElement {
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
const loadPlayOptions = useCallback(async () => {
try {
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
setPlayOptions(
getCachedAdminPlayTypes().map((item) => ({
code: item.play_code,
label:
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
})),
);
} catch {
setPlayOptions([]);
}
}, [i18n.language]);
useEffect(() => {
queueMicrotask(() => {
void loadPlayOptions();
});
}, [loadPlayOptions]);
const playOptions = useCachedPlayTypeOptions();
const tRef = useTranslationRef(["dashboard", "common"]);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -230,27 +213,30 @@ export function DashboardConsole(): ReactElement {
setAbnormalTransferTotal(d.abnormal_transfer_total);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [t]);
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void load(false);
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load(false);
}, []);
const currency =
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
const canFinance = capabilities?.draw_finance_risk ?? false;
const platformLocked = platformRisk?.locked_amount ?? 0;
const platformCap = platformRisk?.cap_amount ?? 0;
const platformUsagePct = platformRisk?.usage_percent ?? 0;
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
const rawPlatformUsagePct = platformRisk?.usage_percent;
const platformUsagePct =
typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct)
? Math.min(100, Math.max(0, rawPlatformUsagePct))
: platformCap > 0
? (platformLocked / platformCap) * 100
: 0;
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
@@ -359,10 +345,16 @@ export function DashboardConsole(): ReactElement {
href="/admin/risk"
title={t("riskCapUsage")}
value={`${platformUsagePct.toFixed(1)}%`}
subtitle={t("platformLockedAndCap", {
locked: formatMoneyMinor(platformLocked, currency),
cap: formatMoneyMinor(platformCap, currency),
})}
subtitle={
platformCap > 0
? t("platformLockedAndCap", {
locked: formatMoneyMinor(platformLocked, currency),
cap: formatMoneyMinor(platformCap, currency),
})
: t("platformCapNotConfigured", {
locked: formatMoneyMinor(platformLocked, currency),
})
}
actionLabel={t("occupancyDetails")}
icon={<Shield className="size-5" aria-hidden />}
accent={
@@ -542,6 +534,7 @@ export function DashboardConsole(): ReactElement {
{showAnalytics ? (
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
<DashboardPlayRankingCard analytics={analytics} />
<DashboardAgentRankingCard analytics={analytics} />
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">

View File

@@ -29,6 +29,11 @@ import {
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
coerceAdminMinor,
formatAdminMinorDecimal,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
import { cn } from "@/lib/utils";
import {
buildBatchProgressConfig,
@@ -53,6 +58,74 @@ export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
type MoneyFormatter = (minor: number, currency: string | null) => string;
type DashboardFinanceMetricCell = {
key: string;
label: string;
amount: number;
emphasize: boolean;
};
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */
function formatDashboardMetricAmount(
minor: number,
currencyCode: string | null,
formatMoney: MoneyFormatter,
): { display: string; title: string } {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
return {
display: formatAdminMinorDecimal(safeMinor, code, decimals),
title: formatMoney(safeMinor, currencyCode),
};
}
function DashboardFinanceMetricCells({
cells,
currency,
formatMoney,
}: {
cells: readonly DashboardFinanceMetricCell[];
currency: string | null;
formatMoney: MoneyFormatter;
}): ReactElement {
return (
<div className="grid grid-cols-3 gap-1.5">
{cells.map((cell) => {
const { display, title } = formatDashboardMetricAmount(
cell.amount,
currency,
formatMoney,
);
return (
<div
key={cell.key}
className={cn(
"min-w-0 rounded-lg px-1 py-2 ring-1",
cell.emphasize
? "bg-primary/6 ring-primary/15"
: "bg-muted/30 ring-border/50",
)}
>
<p className="line-clamp-2 text-center text-[10px] leading-tight text-muted-foreground">
{cell.label}
</p>
<p
className={cn(
"mt-1 truncate text-center text-[10px] font-bold tabular-nums leading-tight",
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
title={title}
>
{display}
</p>
</div>
);
})}
</div>
);
}
function usageBarFill(pct: number): string {
if (pct >= 95) {
return DASHBOARD_CHART_COLORS.rose;
@@ -485,10 +558,11 @@ export function PayoutPanelSnapshot({
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const bet = finance.total_bet_minor;
const win = finance.total_win_payout_minor;
const jackpot = finance.total_jackpot_win_minor;
const hasPayout = win + jackpot > 0;
const bet = coerceAdminMinor(finance.total_bet_minor);
const win = coerceAdminMinor(finance.total_win_payout_minor);
const jackpot = coerceAdminMinor(finance.total_jackpot_win_minor);
const payout = coerceAdminMinor(finance.total_payout_minor);
const hasPayout = payout > 0 || win + jackpot > 0;
if (bet <= 0 && !hasPayout) {
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
@@ -502,29 +576,7 @@ export function PayoutPanelSnapshot({
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
{cells.map((cell) => (
<div
key={cell.key}
className={cn(
"rounded-lg px-1.5 py-2 ring-1",
cell.emphasize
? "bg-primary/6 ring-primary/15"
: "bg-muted/30 ring-border/50",
)}
>
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
<p
className={cn(
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
>
{formatMoney(cell.amount, currency)}
</p>
</div>
))}
</div>
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
{hasPayout ? (
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
) : (
@@ -983,7 +1035,10 @@ export function ResultBatchQueueSummary({
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const { pending_review_total, pending_draw_count, published_total, batch_total } = queue;
const pendingReviewTotal = coerceAdminMinor(queue.pending_review_total);
const pendingDrawCount = coerceAdminMinor(queue.pending_draw_count);
const publishedTotal = coerceAdminMinor(queue.published_total);
const batchTotal = coerceAdminMinor(queue.batch_total);
return (
<div className="grid grid-cols-3 gap-2 text-center">
@@ -994,7 +1049,7 @@ export function ResultBatchQueueSummary({
compact ? "text-lg" : "text-2xl",
)}
>
{pending_review_total}
{pendingReviewTotal}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
</div>
@@ -1005,18 +1060,16 @@ export function ResultBatchQueueSummary({
compact ? "text-lg" : "text-2xl",
)}
>
{published_total}
{publishedTotal}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
</div>
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
{batch_total}
{pendingDrawCount > 0 ? pendingDrawCount : batchTotal}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">
{pending_draw_count > 0
? t("batchPendingDrawsCount", { count: pending_draw_count })
: t("batchTotal")}
{pendingDrawCount > 0 ? t("batchPendingDraws") : t("batchTotal")}
</p>
</div>
</div>
@@ -1032,10 +1085,14 @@ export function PlatformLifetimePayoutSnapshot({
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const bet = finance.total_bet_minor;
const win = finance.total_win_minor;
const jackpot = finance.total_jackpot_minor;
const hasPayout = win + jackpot > 0;
const bet = coerceAdminMinor(finance.total_bet_minor);
const payout = coerceAdminMinor(finance.total_payout_minor);
let win = coerceAdminMinor(finance.total_win_minor);
let jackpot = coerceAdminMinor(finance.total_jackpot_minor);
if (payout > 0 && win + jackpot === 0) {
win = payout;
}
const hasPayout = payout > 0 || win + jackpot > 0;
if (bet <= 0 && !hasPayout) {
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
@@ -1049,29 +1106,7 @@ export function PlatformLifetimePayoutSnapshot({
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center">
{cells.map((cell) => (
<div
key={cell.key}
className={cn(
"rounded-lg px-1.5 py-2 ring-1",
cell.emphasize
? "bg-primary/6 ring-primary/15"
: "bg-muted/30 ring-border/50",
)}
>
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
<p
className={cn(
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
>
{formatMoney(cell.amount, currency)}
</p>
</div>
))}
</div>
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
{!hasPayout ? (
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
{t("platformNoPayoutYet")}

View File

@@ -1,16 +1,23 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { format, subDays } from "date-fns";
import { useTranslation } from "react-i18next";
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import {
coerceAdminMinor,
formatAdminMinorUnits,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardAnalyticsData,
AdminDashboardAnalyticsAgentRow,
DashboardAnalyticsMetric,
DashboardAnalyticsPeriod,
} from "@/types/api/admin-dashboard-analytics";
@@ -27,9 +34,10 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals;
const major = safeMinor / 10 ** decimals;
try {
return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency",
@@ -38,7 +46,7 @@ export function formatDashboardMoneyMinor(minor: number, currencyCode: string |
maximumFractionDigits: decimals,
}).format(major);
} catch {
return formatAdminMinorUnits(minor, code, decimals);
return formatAdminMinorUnits(safeMinor, code, decimals);
}
}
@@ -58,6 +66,7 @@ export function useDashboardAnalytics({
playOptions: { code: string; label: string }[];
}) {
const { t } = useTranslation(["dashboard", "common"]);
const tRef = useTranslationRef(["dashboard", "common"]);
const playLabel = useAdminPlayCodeLabel();
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
@@ -94,19 +103,18 @@ export function useDashboardAnalytics({
const needsAuthSync =
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
setError(
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
needsAuthSync
? tRef.current("warnings.apiResourceMissing")
: raw || tRef.current("warnings.loadFailed"),
);
} finally {
setLoading(false);
}
}, [enabled, period, playCode, customFrom, customTo, t]);
}, [enabled, period, playCode, customFrom, customTo]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load();
}, [enabled, period, playCode, customFrom, customTo]);
const currency = data?.currency_code ?? null;
const summary = data?.summary;
@@ -152,6 +160,28 @@ export function useDashboardAnalytics({
return rows.slice(0, 5);
}, [data, rankingMetric]);
const metricAgentValue = useCallback(
(row: AdminDashboardAnalyticsAgentRow): number => {
if (rankingMetric === "payout") {
return row.total_payout_minor;
}
if (rankingMetric === "profit") {
return row.approx_house_gross_minor;
}
return row.total_bet_minor;
},
[rankingMetric],
);
const topAgentRows = useMemo(() => {
if (!data) {
return [];
}
const rows = [...data.agent_breakdown];
rows.sort((a, b) => metricAgentValue(b) - metricAgentValue(a));
return rows.slice(0, 5);
}, [data, metricAgentValue]);
const sparklines = useMemo(() => {
const series = data?.daily_series ?? [];
return {
@@ -183,6 +213,7 @@ export function useDashboardAnalytics({
playOptions,
resolvePlayLabel,
topPlayRows,
topAgentRows,
sparklines,
formatMoney: formatDashboardMoneyMinor,
formatSignedMoney: formatDashboardSignedMoneyMinor,

View File

@@ -1,8 +1,10 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -17,6 +19,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -50,6 +53,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
export function DrawDetailConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const idNum = Number(drawId);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
@@ -67,7 +71,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError(t("invalidDrawId"));
setError(tRef.current("invalidDrawId"));
setLoading(false);
return;
}
@@ -77,11 +81,11 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
setData(await getAdminDraw(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum, t]);
}, [idNum]);
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
if (!Number.isFinite(idNum)) return;
@@ -97,15 +101,12 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
}
}
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load();
}, [idNum]);
if (loading && !data) {
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error || !data) {

View File

@@ -1,8 +1,10 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
@@ -11,6 +13,7 @@ import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -37,6 +40,7 @@ import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "settlement", "common"]);
const tRef = useTranslationRef(["draws", "settlement", "common"]);
useAdminCurrencyCatalog();
const idNum = Number(drawId);
const profile = useAdminProfile();
@@ -54,7 +58,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
const load = useCallback(async () => {
if (!Number.isFinite(idNum) || idNum < 1) {
setErr(t("invalidDrawId"));
setErr(tRef.current("invalidDrawId"));
setLoading(false);
return;
}
@@ -63,12 +67,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
try {
setData(await getAdminDrawFinanceSummary(idNum));
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
setData(null);
} finally {
setLoading(false);
}
}, [idNum, t]);
}, [idNum]);
async function runSettlement(): Promise<void> {
if (!Number.isFinite(idNum) || idNum < 1) return;
@@ -84,14 +88,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
}
}
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [idNum]);
if (loading && !data) {
return <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>;
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (err || !data) {

View File

@@ -2,8 +2,10 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -14,6 +16,7 @@ import {
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -34,6 +37,7 @@ import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const router = useRouter();
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
@@ -50,7 +54,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError(t("invalidDrawId"));
setError(tRef.current("invalidDrawId"));
setLoading(false);
return;
}
@@ -60,18 +64,15 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum, t]);
}, [idNum]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load();
}, [idNum]);
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
if (!Number.isFinite(batchNum)) return undefined;
@@ -115,7 +116,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error || !data) {

View File

@@ -1,12 +1,15 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -28,6 +31,7 @@ import { DrawStatusBadge } from "./draw-status-badge";
export function DrawResultsConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
@@ -39,7 +43,7 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError(t("invalidDrawId"));
setError(tRef.current("invalidDrawId"));
setLoading(false);
return;
}
@@ -49,21 +53,18 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum, t]);
}, [idNum]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load();
}, [idNum]);
if (loading && !data) {
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error || !data) {

View File

@@ -1,8 +1,10 @@
"use client";
import { Dices, Rocket, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -14,6 +16,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -56,6 +59,7 @@ function randomDrawNumber4d(): string {
export function DrawReviewConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
@@ -73,7 +77,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError(t("invalidDrawId"));
setError(tRef.current("invalidDrawId"));
setLoading(false);
return;
}
@@ -83,18 +87,15 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum, t]);
}, [idNum]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load();
}, [idNum]);
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
data,
@@ -148,7 +149,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error || !data) {

View File

@@ -1,8 +1,10 @@
"use client";
import { Ban, Eye, Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -14,10 +16,12 @@ import {
} from "@/api/admin-draws";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -86,6 +90,7 @@ function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): s
export function DrawsIndexConsole() {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const exportLabels = useExportLabels("drawsList");
useAdminCurrencyCatalog();
const defaultCurrency = "NPR";
@@ -106,6 +111,8 @@ export function DrawsIndexConsole() {
const [draftStatus, setDraftStatus] = useState("");
const [appliedDrawNo, setAppliedDrawNo] = useState("");
const [appliedStatus, setAppliedStatus] = useState("");
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<number>(10);
const [generating, setGenerating] = useState(false);
@@ -137,17 +144,18 @@ export function DrawsIndexConsole() {
appliedStatus.trim() === "" || appliedStatus === DRAW_FILTER_ALL
? undefined
: appliedStatus.trim(),
agent_node_id: appliedAgentNodeId,
});
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("loadFailed");
e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
async function generatePlan(): Promise<void> {
setGenerating(true);
@@ -168,12 +176,9 @@ export function DrawsIndexConsole() {
}
}
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
useAsyncEffect(() => {
void load();
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
const handleSelectAll = useCallback((checked: boolean) => {
if (checked && data) {
@@ -293,6 +298,12 @@ export function DrawsIndexConsole() {
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
<AdminAgentFilter
id="draws-agent-filter"
className="admin-list-field sm:w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="draw-filter-no" className="sm:w-10 sm:shrink-0">
{t("drawNo")}
@@ -347,6 +358,7 @@ export function DrawsIndexConsole() {
onClick={() => {
setAppliedDrawNo(draftDrawNo);
setAppliedStatus(draftStatus);
setAppliedAgentNodeId(agentNodeId);
setPage(1);
}}
>
@@ -358,8 +370,10 @@ export function DrawsIndexConsole() {
onClick={() => {
setDraftDrawNo("");
setDraftStatus("");
setAgentNodeId(undefined);
setAppliedDrawNo("");
setAppliedStatus("");
setAppliedAgentNodeId(undefined);
setPage(1);
}}
>
@@ -410,11 +424,7 @@ export function DrawsIndexConsole() {
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.loading", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableLoadingRow colSpan={10} />
) : data === null || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">

View File

@@ -1,8 +1,10 @@
"use client";
import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -37,6 +39,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { getAdminPageBundle } from "@/lib/admin-permission-bundles";
import { useAdminProfile } from "@/stores/admin-session";
@@ -138,6 +141,7 @@ function formToPayload(
export function IntegrationSitesConsole() {
const { t } = useTranslation("config");
const tRef = useTranslationRef("config");
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(
profile?.permissions,
@@ -174,18 +178,16 @@ export function IntegrationSitesConsole() {
setItems(data.items);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
error instanceof LotteryApiBizError ? error.message : tRef.current("integrationSites.loadFailed"),
);
} finally {
setLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, []);
function openCreate(): void {
setMode("create");
@@ -352,7 +354,7 @@ export function IntegrationSitesConsole() {
}
>
{loading ? (
<p className="text-sm text-muted-foreground">{t("integrationSites.loading")}</p>
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
) : (

View File

@@ -1,7 +1,9 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import {
getAdminJackpotPoolAdjustments,
@@ -40,6 +42,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
type Draft = {
contribution_rate: string;
@@ -78,6 +81,7 @@ type JackpotPoolsConsoleProps = {
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
const { t } = useTranslation(["jackpot", "common"]);
const tRef = useTranslationRef(["jackpot", "common"]);
const profile = useAdminProfile();
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);
@@ -114,17 +118,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
setAdjustmentDrafts(adjDrafts);
setAdjustmentRows(adjRows);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
} finally {
setLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, []);
const updateDraft = (id: number, patch: Partial<Draft>) => {
setDrafts((prev) => ({
@@ -229,7 +231,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
const poolList = (
<div className={embedded ? "space-y-4" : "space-y-8"}>
{loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null}
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
{!loading && items.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
) : null}

View File

@@ -1,8 +1,10 @@
"use client";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -13,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -74,7 +77,7 @@ function JackpotRecordTableSection({
/>
</div>
{loading && !hasData ? (
<p className="px-4 py-6 text-sm text-muted-foreground">{t("states.loading")}</p>
<AdminLoadingState minHeight="6rem" className="px-4 py-6" />
) : (
<div className={TABLE_IN_SHELL_CLASS}>{children}</div>
)}
@@ -83,8 +86,12 @@ function JackpotRecordTableSection({
);
}
type RecordTab = "payout" | "contribution";
export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) {
const { t } = useTranslation(["jackpot", "common"]);
const tRef = useTranslationRef(["jackpot"]);
const [recordTab, setRecordTab] = useState<RecordTab>("payout");
const payoutExport = useExportLabels("jackpotPayouts");
const contributionExport = useExportLabels("jackpotContributions");
const formatDt = useAdminDateTimeFormatter();
@@ -100,7 +107,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
const [cPer, setCPer] = useState(10);
const [loadingP, setLoadingP] = useState(true);
const [loadingC, setLoadingC] = useState(true);
const [loadingC, setLoadingC] = useState(false);
const [err, setErr] = useState<string | null>(null);
const loadPayouts = useCallback(async () => {
@@ -113,11 +120,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
});
setPayouts(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("payoutLoadFailed"));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("payoutLoadFailed"));
} finally {
setLoadingP(false);
}
}, [pPage, pPer, appliedDrawNo, t]);
}, [pPage, pPer, appliedDrawNo]);
const loadContribs = useCallback(async () => {
setLoadingC(true);
@@ -129,23 +136,22 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
});
setContribs(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("contributionLoadFailed"));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("contributionLoadFailed"));
} finally {
setLoadingC(false);
}
}, [cPage, cPer, appliedDrawNo, t]);
}, [cPage, cPer, appliedDrawNo]);
useEffect(() => {
queueMicrotask(() => {
void loadPayouts();
});
}, [loadPayouts]);
useAsyncEffect(() => {
void loadPayouts();
}, [pPage, pPer, appliedDrawNo]);
useEffect(() => {
queueMicrotask(() => {
void loadContribs();
});
}, [loadContribs]);
useAsyncEffect(() => {
if (recordTab !== "contribution") {
return;
}
void loadContribs();
}, [recordTab, cPage, cPer, appliedDrawNo]);
const applyDraw = () => {
setAppliedDrawNo(drawNo);
@@ -328,9 +334,27 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
{filterBlock}
{err ? <p className="text-destructive text-sm">{err}</p> : null}
<div className="flex flex-wrap gap-2 border-b border-border/70 pb-3">
<Button
type="button"
size="sm"
variant={recordTab === "payout" ? "default" : "outline"}
onClick={() => setRecordTab("payout")}
>
{t("payoutRecords")}
</Button>
<Button
type="button"
size="sm"
variant={recordTab === "contribution" ? "default" : "outline"}
onClick={() => setRecordTab("contribution")}
>
{t("contributionRecords")}
</Button>
</div>
<div className="space-y-6">
{payoutTable}
{contributionTable}
{recordTab === "payout" ? payoutTable : contributionTable}
</div>
</>
);

View File

@@ -1,11 +1,13 @@
"use client";
import { Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -16,6 +18,8 @@ import {
postAdminPlayerUnfreeze,
putAdminPlayer,
} from "@/api/admin-player";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -33,6 +37,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
@@ -81,6 +86,7 @@ const PLAYER_STATUS_OPTIONS = [
export function PlayersConsole(): React.ReactElement {
const { t } = useTranslation(["players", "common"]);
const tRef = useTranslationRef(["players", "common"]);
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const formatDt = useAdminDateTimeFormatter();
const exportLabels = useExportLabels("players");
@@ -95,6 +101,8 @@ export function PlayersConsole(): React.ReactElement {
const [query, setQuery] = useState("");
const [siteCode, setSiteCode] = useState("");
const [appliedSiteCode, setAppliedSiteCode] = useState("");
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [items, setItems] = useState<AdminPlayerRow[]>([]);
const [total, setTotal] = useState(0);
@@ -131,12 +139,13 @@ export function PlayersConsole(): React.ReactElement {
per_page: perPage,
keyword: query.trim() || undefined,
site_code: appliedSiteCode.trim() || undefined,
agent_node_id: appliedAgentNodeId,
});
setItems(data.items);
setTotal(data.meta.total);
setLastPage(Math.max(1, data.meta.last_page));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
setErr(msg);
setItems([]);
setTotal(0);
@@ -144,13 +153,11 @@ export function PlayersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query, appliedSiteCode, t]);
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
function openCreateAccount(): void {
setAccountMode("create");
@@ -334,6 +341,12 @@ export function PlayersConsole(): React.ReactElement {
</Select>
</div>
) : null}
<AdminAgentFilter
id="players-agent-filter"
className="admin-list-field sm:w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
{t("search")}
@@ -349,6 +362,7 @@ export function PlayersConsole(): React.ReactElement {
setPage(1);
setQuery(keyword.trim());
setAppliedSiteCode(siteCode.trim());
setAppliedAgentNodeId(agentNodeId);
}
}}
/>
@@ -365,6 +379,7 @@ export function PlayersConsole(): React.ReactElement {
setPage(1);
setQuery(keyword.trim());
setAppliedSiteCode(siteCode.trim());
setAppliedAgentNodeId(agentNodeId);
}}
>
{t("search")}
@@ -377,15 +392,13 @@ export function PlayersConsole(): React.ReactElement {
</CardHeader>
<CardContent className="admin-list-content">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
<div className="admin-table-shell">
<Table id="players-table">
<TableHeader>
<TableRow>
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("site")}</TableHead>
<AdminAgentHead />
<TableHead>{t("sitePlayerId")}</TableHead>
<TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</TableHead>
@@ -398,9 +411,11 @@ export function PlayersConsole(): React.ReactElement {
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 && !loading ? (
{loading && items.length === 0 ? (
<AdminTableLoadingRow colSpan={12} />
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground">
<TableCell colSpan={12} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -413,6 +428,7 @@ export function PlayersConsole(): React.ReactElement {
<TableCell>
<span className="font-mono text-xs">{row.site_code}</span>
</TableCell>
<AdminAgentCell row={row} />
<TableCell>
<span className="font-mono text-xs">{row.site_player_id}</span>
</TableCell>

View File

@@ -1,8 +1,10 @@
"use client";
import { Eye } from "lucide-react";
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -20,6 +22,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -36,6 +39,7 @@ import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow } from "@/types/api/admin-player";
import type {
AdminReconcileJobRow,
AdminReconcileItemsData,
AdminReconcileJobListData,
} from "@/types/api/admin-reconcile";
@@ -80,8 +84,23 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
}
}
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
const raw = summary?.[key];
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
}
function renderPeriodRange(
row: Pick<AdminReconcileJobRow, "period_start" | "period_end">,
formatTs: (value: string | null | undefined) => string,
): string {
const from = row.period_start ? formatTs(row.period_start) : "—";
const to = row.period_end ? formatTs(row.period_end) : "—";
return `${from} ~ ${to}`;
}
export function ReconcileConsole(): React.ReactElement {
const { t } = useTranslation(["reconcile", "common"]);
const tRef = useTranslationRef(["reconcile", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
@@ -115,18 +134,16 @@ export function ReconcileConsole(): React.ReactElement {
const d = await getAdminReconcileJobs({ page, per_page: perPage });
setJobs(d);
} catch (e) {
setJobsErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setJobsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setJobs(null);
} finally {
setJobsLoading(false);
}
}, [page, perPage, t]);
}, [page, perPage]);
useEffect(() => {
queueMicrotask(() => {
void loadJobs();
});
}, [loadJobs]);
useAsyncEffect(() => {
void loadJobs();
}, [page, perPage]);
const loadItems = useCallback(async () => {
if (selectedId == null) {
@@ -141,18 +158,16 @@ export function ReconcileConsole(): React.ReactElement {
});
setItems(d);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadItemsFailed"));
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadItemsFailed"));
setItems(null);
} finally {
setItemsLoading(false);
}
}, [selectedId, itemsPage, itemsPerPage, t]);
}, [selectedId, itemsPage, itemsPerPage]);
useEffect(() => {
queueMicrotask(() => {
void loadItems();
});
}, [loadItems]);
useAsyncEffect(() => {
void loadItems();
}, [selectedId, itemsPage, itemsPerPage]);
const loadPlayers = useCallback(async (keyword: string) => {
const q = keyword.trim();
@@ -218,6 +233,9 @@ export function ReconcileConsole(): React.ReactElement {
const jm = jobs?.meta;
const im = items?.meta;
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
return (
<div className="flex w-full max-w-none flex-col gap-6">
@@ -225,28 +243,157 @@ export function ReconcileConsole(): React.ReactElement {
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
<CardDescription>{t("createDesc")}</CardDescription>
</CardHeader>
<CardContent className="admin-list-content pt-4">
<div className="grid gap-4 lg:grid-cols-[minmax(220px,0.9fr)_minmax(220px,0.95fr)_auto] lg:items-end">
<div className="grid gap-1.5">
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="rounded-xl border bg-muted/15 p-4">
<div className="mb-4 flex items-start gap-3">
<div className="rounded-lg bg-background p-2 text-muted-foreground">
<CalendarRange className="size-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{t("scopeTitle")}</div>
<p className="text-sm text-muted-foreground">{t("scopeDescription")}</p>
</div>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
<p className="text-xs text-muted-foreground">{t("reconcileTypeHint")}</p>
</div>
<div className="grid gap-1.5">
<AdminDateRangeField
id="rc-date-range"
label={t("dateRange")}
from={dateFrom}
to={dateTo}
onRangeChange={({ from, to }) => {
setDateFrom(from);
setDateTo(to);
}}
/>
<p className="text-xs text-muted-foreground">{t("dateRangeHint")}</p>
</div>
</div>
</div>
<div className="grid gap-1.5">
<AdminDateRangeField
id="rc-date-range"
label={t("dateRange")}
from={dateFrom}
to={dateTo}
onRangeChange={({ from, to }) => {
setDateFrom(from);
setDateTo(to);
}}
/>
<div className="rounded-xl border bg-background p-4">
<div className="mb-4 flex items-start gap-3">
<div className="rounded-lg bg-muted/20 p-2 text-muted-foreground">
<UserRound className="size-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{t("playerScopeTitle")}</div>
<p className="text-sm text-muted-foreground">{t("playerSearchHint")}</p>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
<Input
id="rc-player-search"
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
placeholder={t("playerSearchPlaceholder")}
/>
</div>
{selectedPlayer ? (
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-foreground">
{selectedPlayer.site_player_id}
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
</div>
<div className="truncate text-xs text-muted-foreground">
{t("playerSelected")} · {selectedPlayer.site_code}
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedPlayer(null);
setPlayerSearch("");
setPlayerResults([]);
}}
>
{t("playerClear")}
</Button>
</div>
) : null}
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
<div className="mt-4 rounded-lg border bg-background">
<div className="max-h-56 overflow-y-auto">
{playerLoading ? (
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
) : playerResults.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
) : (
<div className="divide-y">
{playerResults.map((player) => {
const active = selectedPlayer?.id === player.id;
return (
<button
key={player.id}
type="button"
className={cn(
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
active && "bg-muted/30",
)}
onClick={() => {
setSelectedPlayer(player);
setPlayerSearch(player.site_player_id);
}}
>
<div className="min-w-0">
<div className="truncate font-medium text-foreground">
{player.site_player_id}
{player.nickname ? ` · ${player.nickname}` : ""}
</div>
<div className="truncate text-xs text-muted-foreground">
{player.username ?? "—"} · {player.site_code}
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{active ? t("playerSelectedShort") : t("playerChoose")}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
) : (
<div className="mt-4 rounded-lg border border-dashed bg-muted/10 px-3 py-3 text-sm text-muted-foreground">
{t("playerAllPlayersHint")}
</div>
)}
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3">
<div className="min-w-0 text-sm text-muted-foreground">
{selectedPlayer
? t("createSummaryPlayer", {
player: selectedPlayer.site_player_id,
from: dateFrom || "—",
to: dateTo || "—",
})
: t("createSummaryAll", {
from: dateFrom || "—",
to: dateTo || "—",
})}
</div>
<Button
type="button"
className="w-full lg:w-auto"
className="w-full sm:w-auto"
disabled={submitting}
onClick={() =>
requestConfirm({
@@ -263,82 +410,6 @@ export function ReconcileConsole(): React.ReactElement {
{submitting ? t("submitting") : t("createTask")}
</Button>
</div>
<div className="grid gap-1.5 pt-4">
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
<Input
id="rc-player-search"
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
placeholder={t("playerSearchPlaceholder")}
/>
{selectedPlayer ? (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-foreground">
{selectedPlayer.site_player_id}
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedPlayer(null);
setPlayerSearch("");
setPlayerResults([]);
}}
>
{t("playerClear")}
</Button>
</div>
) : null}
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
<div className="rounded-lg border bg-background">
<div className="max-h-56 overflow-y-auto">
{playerLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{t("loadingPlayers")}</div>
) : playerResults.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
) : (
<div className="divide-y">
{playerResults.map((player) => {
const active = selectedPlayer?.id === player.id;
return (
<button
key={player.id}
type="button"
className={cn(
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
active && "bg-muted/30",
)}
onClick={() => {
setSelectedPlayer(player);
setPlayerSearch(player.site_player_id);
}}
>
<div className="min-w-0">
<div className="truncate font-medium text-foreground">
{player.site_player_id}
{player.nickname ? ` · ${player.nickname}` : ""}
</div>
<div className="truncate text-xs text-muted-foreground">
{player.username ?? "—"} · {player.site_code}
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{active ? t("playerSelectedShort") : t("playerChoose")}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
) : null}
</div>
</CardContent>
</Card>
) : (
@@ -349,6 +420,7 @@ export function ReconcileConsole(): React.ReactElement {
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
<CardDescription>{t("jobsDesc")}</CardDescription>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
{t("refresh")}
@@ -356,9 +428,6 @@ export function ReconcileConsole(): React.ReactElement {
</CardHeader>
<CardContent className="admin-list-content pt-4">
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
{jobsLoading && !jobs ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null}
{jobs ? (
<>
<div className="admin-table-shell">
@@ -373,7 +442,10 @@ export function ReconcileConsole(): React.ReactElement {
</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-center">{t("itemCount")}</TableHead>
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
<TableHead>{t("period")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead>
<TableHead>{t("createdAt")}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 bg-muted/20 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("operate")}
@@ -381,9 +453,11 @@ export function ReconcileConsole(): React.ReactElement {
</TableRow>
</TableHeader>
<TableBody>
{jobs.items.length === 0 ? (
{jobsLoading && !jobs ? (
<AdminTableLoadingRow colSpan={10} />
) : jobs.items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -402,12 +476,28 @@ export function ReconcileConsole(): React.ReactElement {
{jobStatusLabel(row.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-center tabular-nums">
{getJobSummaryValue(row.summary_json, "item_count")}
</TableCell>
<TableCell className="text-center tabular-nums">
<span
className={cn(
getJobSummaryValue(row.summary_json, "mismatch_count") > 0
? "font-medium text-amber-700"
: "text-muted-foreground",
)}
>
{getJobSummaryValue(row.summary_json, "mismatch_count")}
</span>
</TableCell>
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
<span className="line-clamp-2">
{row.period_start ? formatTs(row.period_start) : "—"} ~{" "}
{row.period_end ? formatTs(row.period_end) : "—"}
{renderPeriodRange(row, formatTs)}
</span>
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.finished_at)}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
@@ -475,10 +565,27 @@ export function ReconcileConsole(): React.ReactElement {
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
{itemsLoading && !items ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
<AdminLoadingState minHeight="6rem" className="py-6" />
) : null}
{items ? (
<>
<div className="mb-4 grid gap-3 md:grid-cols-3">
<div className="rounded-lg border bg-background px-4 py-3">
<div className="text-xs text-muted-foreground">{t("itemCount")}</div>
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobItemCount}</div>
</div>
<div className="rounded-lg border bg-background px-4 py-3">
<div className="text-xs text-muted-foreground">{t("mismatchCount")}</div>
<div className="mt-1 flex items-center gap-2 text-xl font-semibold tabular-nums text-amber-700">
<ShieldAlert className="size-4" />
{selectedJobMismatchCount}
</div>
</div>
<div className="rounded-lg border bg-background px-4 py-3">
<div className="text-xs text-muted-foreground">{t("matchedCount")}</div>
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobMatchedCount}</div>
</div>
</div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t("jobNo")} {items.job_no}</span>
<span>·</span>
@@ -493,7 +600,7 @@ export function ReconcileConsole(): React.ReactElement {
)}
</span>
<span>·</span>
<span>{t("period")} {selectedJob ? `${selectedJob.period_start ? formatTs(selectedJob.period_start) : "—"} ~ ${selectedJob.period_end ? formatTs(selectedJob.period_end) : "—"}` : "—"}</span>
<span>{t("period")} {selectedJob ? renderPeriodRange(selectedJob, formatTs) : "—"}</span>
</div>
<div className="rounded-lg border bg-background">
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
@@ -502,29 +609,47 @@ export function ReconcileConsole(): React.ReactElement {
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("sideARef")}</TableHead>
<TableHead>{t("sideBRef")}</TableHead>
<TableHead>{t("differenceAmount")}</TableHead>
<TableHead className="text-right">{t("differenceAmount")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("detectedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
<TableCell colSpan={6} className="text-muted-foreground">
{t("noDetails")}
</TableCell>
</TableRow>
) : (
items.items.map((r) => (
<TableRow key={r.id}>
<TableRow
key={r.id}
className={cn(
r.status === "mismatch" && "bg-amber-500/5",
r.status === "matched" && "bg-emerald-500/5",
)}
>
<TableCell>{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
<TableCell className="text-right tabular-nums">
<span
className={cn(
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
)}
>
{r.difference_amount}
</span>
</TableCell>
<TableCell>
<AdminStatusBadge status={r.status}>
{itemStatusLabel(r.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(r.created_at)}
</TableCell>
</TableRow>
))
)}

View File

@@ -1,7 +1,9 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import { Download, RefreshCw } from "lucide-react";
@@ -10,6 +12,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -40,6 +43,7 @@ type ReportJobsPanelProps = {
export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) {
const { t } = useTranslation(["reports", "common"]);
const tRef = useTranslationRef(["reports", "common"]);
const formatTs = useAdminDateTimeFormatter();
const [jobs, setJobs] = useState<AdminReportJobRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -51,18 +55,16 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
const data = await getAdminReportJobs({ page: 1, per_page: 10 });
setJobs(data.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("tasks.loadFailed"));
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed"));
setJobs([]);
} finally {
setLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
queueMicrotask(() => {
void loadJobs();
});
}, [loadJobs, refreshToken]);
useAsyncEffect(() => {
void loadJobs();
}, [refreshToken]);
async function handleDownload(job: AdminReportJobRow): Promise<void> {
if (!canExport || job.status !== "completed") {
@@ -111,11 +113,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.loading", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableLoadingRow colSpan={6} />
) : jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">

View File

@@ -20,13 +20,10 @@ import {
} from "lucide-react";
import { getAdminAuditLogs } from "@/api/admin-audit";
import { getAdminPlayTypes } from "@/api/admin-config";
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import {
getAdminPlayTypesLoadPromise,
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
import { getAdminPlayers } from "@/api/admin-player";
import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs";
@@ -49,6 +46,8 @@ import { getAdminTransferOrders } from "@/api/admin-wallet";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Button } from "@/components/ui/button";
@@ -56,6 +55,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
@@ -121,12 +121,24 @@ type ReportDefinition = {
connected: boolean;
};
type PreviewColumns = {
primary: string;
secondary: string;
metricA: string;
metricB: string;
metricC: string;
status: string;
extra: string;
time: string;
};
type ReportFilters = {
drawNo: string;
drawId: number | null;
number: string;
player: string;
playerId: number | null;
agentNodeId: number | undefined;
play: string;
operator: string;
operatorId: number | null;
@@ -190,6 +202,7 @@ const emptyFilters: ReportFilters = {
number: "",
player: "",
playerId: null,
agentNodeId: undefined,
play: "",
operator: "",
operatorId: null,
@@ -302,6 +315,10 @@ function formatPlainMoney(value: number, currencyCode: string | null | undefined
return formatAdminMinorUnits(value, currencyCode || "NPR");
}
function formatUsagePercent(ratio: number | null | undefined): string {
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
}
function optionText(...parts: Array<string | number | null | undefined>): string {
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
}
@@ -314,6 +331,7 @@ function reportListParams(filters: ReportFilters, page: number, perPage: number)
date_to: filters.dateTo || undefined,
player_id: filters.playerId ?? undefined,
play_code: filters.play.trim() || undefined,
agent_node_id: filters.agentNodeId,
};
}
@@ -328,23 +346,22 @@ function parsePositiveInteger(value: string): number | null {
async function resolveDraw(
filters: ReportFilters,
t: (key: string, options?: { ns?: string; drawNo?: string }) => string,
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
): Promise<{ id: number; draw_no: string }> {
if (filters.drawId && filters.drawNo.trim()) {
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
if (filters.drawId != null && filters.drawId > 0) {
const drawNo = filters.drawNo.trim();
return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) };
}
const drawNo = filters.drawNo.trim();
if (!drawNo) {
throw new LotteryApiBizError(t("validation.drawNoRequired", { ns: "reports" }), -1, null);
throw new LotteryApiBizError(messages.drawNoRequired, -1, null);
}
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
if (!matched) {
throw new LotteryApiBizError(t("validation.drawNoNotFound", { ns: "reports", drawNo }), -1, {
drawNo,
});
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
}
return { id: matched.id, draw_no: matched.draw_no };
}
@@ -403,10 +420,131 @@ export function ReportsConsole() {
const [exporting, setExporting] = useState<ExportFormat | null>(null);
const [jobRefreshToken, setJobRefreshToken] = useState(0);
const [search, setSearch] = useState<SearchState>(emptySearch);
const [playOptions, setPlayOptions] = useState<PlayOption[]>([]);
const playOptions = useCachedPlayTypeOptions();
const tRef = useTranslationRef(["reports", "common"]);
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
const pageScopedLabel = useCallback(
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
[t],
);
const previewColumns = useMemo<PreviewColumns>(() => {
switch (selectedReport.key) {
case "draw_profit":
return {
primary: t("preview.columns.drawProfit.primary"),
secondary: t("preview.columns.drawProfit.secondary"),
metricA: t("preview.columns.drawProfit.metricA"),
metricB: t("preview.columns.drawProfit.metricB"),
metricC: t("preview.columns.drawProfit.metricC"),
status: t("preview.columns.drawProfit.status"),
extra: t("preview.columns.drawProfit.extra"),
time: t("preview.columns.drawProfit.time"),
};
case "daily_profit":
return {
primary: t("preview.columns.dailyProfit.primary"),
secondary: t("preview.columns.dailyProfit.secondary"),
metricA: t("preview.columns.dailyProfit.metricA"),
metricB: t("preview.columns.dailyProfit.metricB"),
metricC: t("preview.columns.dailyProfit.metricC"),
status: t("preview.columns.dailyProfit.status"),
extra: t("preview.columns.dailyProfit.extra"),
time: t("preview.columns.dailyProfit.time"),
};
case "player_win_loss":
return {
primary: t("preview.columns.playerWinLoss.primary"),
secondary: t("agentColumns.agent", { ns: "common" }),
metricA: t("preview.columns.playerWinLoss.metricA"),
metricB: t("preview.columns.playerWinLoss.metricB"),
metricC: t("preview.columns.playerWinLoss.metricC"),
status: t("preview.columns.playerWinLoss.status"),
extra: t("preview.columns.playerWinLoss.extra"),
time: t("preview.columns.playerWinLoss.time"),
};
case "player_transfer":
return {
primary: t("preview.columns.playerTransfer.primary"),
secondary: t("preview.columns.playerTransfer.secondary"),
metricA: t("preview.columns.playerTransfer.metricA"),
metricB: t("preview.columns.playerTransfer.metricB"),
metricC: t("preview.columns.playerTransfer.metricC"),
status: t("preview.columns.playerTransfer.status"),
extra: t("preview.columns.playerTransfer.extra"),
time: t("preview.columns.playerTransfer.time"),
};
case "hot_number_risk":
return {
primary: t("preview.columns.hotNumberRisk.primary"),
secondary: t("preview.columns.hotNumberRisk.secondary"),
metricA: t("preview.columns.hotNumberRisk.metricA"),
metricB: t("preview.columns.hotNumberRisk.metricB"),
metricC: t("preview.columns.hotNumberRisk.metricC"),
status: t("preview.columns.hotNumberRisk.status"),
extra: t("preview.columns.hotNumberRisk.extra"),
time: t("preview.columns.hotNumberRisk.time"),
};
case "play_dimension":
return {
primary: t("preview.columns.playDimension.primary"),
secondary: t("preview.columns.playDimension.secondary"),
metricA: t("preview.columns.playDimension.metricA"),
metricB: t("preview.columns.playDimension.metricB"),
metricC: t("preview.columns.playDimension.metricC"),
status: t("preview.columns.playDimension.status"),
extra: t("preview.columns.playDimension.extra"),
time: t("preview.columns.playDimension.time"),
};
case "sold_out_number":
return {
primary: t("preview.columns.soldOut.primary"),
secondary: t("preview.columns.soldOut.secondary"),
metricA: t("preview.columns.soldOut.metricA"),
metricB: t("preview.columns.soldOut.metricB"),
metricC: t("preview.columns.soldOut.metricC"),
status: t("preview.columns.soldOut.status"),
extra: t("preview.columns.soldOut.extra"),
time: t("preview.columns.soldOut.time"),
};
case "rebate_commission":
return {
primary: t("preview.columns.rebateCommission.primary"),
secondary: t("preview.columns.rebateCommission.secondary"),
metricA: t("preview.columns.rebateCommission.metricA"),
metricB: t("preview.columns.rebateCommission.metricB"),
metricC: t("preview.columns.rebateCommission.metricC"),
status: t("preview.columns.rebateCommission.status"),
extra: t("preview.columns.rebateCommission.extra"),
time: t("preview.columns.rebateCommission.time"),
};
case "admin_audit":
return {
primary: t("preview.columns.adminAudit.primary"),
secondary: t("preview.columns.adminAudit.secondary"),
metricA: t("preview.columns.adminAudit.metricA"),
metricB: t("preview.columns.adminAudit.metricB"),
metricC: t("preview.columns.adminAudit.metricC"),
status: t("preview.columns.adminAudit.status"),
extra: t("preview.columns.adminAudit.extra"),
time: t("preview.columns.adminAudit.time"),
};
default:
return {
primary: t("preview.columns.primary"),
secondary: t("preview.columns.secondary"),
metricA: t("preview.columns.metricA"),
metricB: t("preview.columns.metricB"),
metricC: t("preview.columns.metricC"),
status: t("preview.columns.status"),
extra: t("preview.columns.extra"),
time: t("preview.columns.time"),
};
}
}, [selectedReport.key, t]);
const exportFileBase = useMemo(() => {
const segments: string[] = [selectedReport.key];
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
@@ -419,29 +557,6 @@ export function ReportsConsole() {
return normalizeFilenamePart(segments.join("-")) || selectedReport.key;
}, [selectedReport.key, filters]);
const loadPlayOptions = useCallback(async () => {
try {
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
setPlayOptions(
getCachedAdminPlayTypes().map((item) => ({
code: item.play_code,
label: optionText(
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item),
item.play_code,
),
})),
);
} catch {
setPlayOptions([]);
}
}, [i18n.language]);
useEffect(() => {
queueMicrotask(() => {
void loadPlayOptions();
});
}, [loadPlayOptions]);
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
setSearch((prev) => ({ ...prev, loading: true }));
try {
@@ -481,7 +596,11 @@ export function ReportsConsole() {
try {
switch (selectedReport.key) {
case "draw_profit": {
const draw = await resolveDraw(filters, t);
const draw = await resolveDraw(filters, {
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
drawNoNotFound: (drawNo) =>
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
});
const summary = await getAdminDrawFinanceSummary(draw.id);
setResult({
key: "draw_profit",
@@ -519,10 +638,10 @@ export function ReportsConsole() {
meta: metaFromList(payload.meta),
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: t("preview.stats.payout"), value: formatPlainMoney(totalPayout, "NPR") },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
{
label: t("preview.stats.houseGross"),
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(totalGross, "NPR"),
tone: totalGross >= 0 ? "good" : "bad",
},
@@ -548,7 +667,7 @@ export function ReportsConsole() {
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{
label: t("preview.stats.houseGross"),
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
"NPR",
@@ -592,17 +711,21 @@ export function ReportsConsole() {
summary: [
{ label: t("preview.stats.records"), value: String(payload.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: t("preview.stats.transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
{ label: t("preview.stats.transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
{ label: pageScopedLabel("transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
{ label: pageScopedLabel("transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
],
});
break;
}
case "hot_number_risk": {
if (!filters.number.trim()) {
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
throw new LotteryApiBizError(tRef.current("validation.drawNoNumberRequired"), -1, null);
}
const draw = await resolveDraw(filters, t);
const draw = await resolveDraw(filters, {
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
drawNoNotFound: (drawNo) =>
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
});
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
const rows: ExportRow[] = [
{
@@ -642,14 +765,18 @@ export function ReportsConsole() {
summary: [
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
{ label: t("preview.stats.usage"), value: detail.pool.usage_ratio == null ? "-" : `${detail.pool.usage_ratio}%`, tone: detail.pool.is_sold_out ? "bad" : "warn" },
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" },
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
],
});
break;
}
case "sold_out_number": {
const draw = await resolveDraw(filters, t);
const draw = await resolveDraw(filters, {
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
drawNoNotFound: (drawNo) =>
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
});
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
const rows = payload.items.map((item) => ({
draw_id: payload.draw_id,
@@ -695,8 +822,8 @@ export function ReportsConsole() {
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: t("preview.stats.bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
{ label: t("preview.stats.payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
],
});
break;
@@ -717,8 +844,8 @@ export function ReportsConsole() {
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: t("preview.stats.rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
{ label: t("preview.stats.orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
{ label: pageScopedLabel("orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
],
});
break;
@@ -761,15 +888,15 @@ export function ReportsConsole() {
}
default:
setResult(null);
setError(t("loadFailed"));
setError(tRef.current("loadFailed"));
}
} catch (err) {
setResult(null);
setError(err instanceof LotteryApiBizError ? err.message : t("loadFailed"));
setError(err instanceof LotteryApiBizError ? err.message : tRef.current("loadFailed"));
} finally {
setLoading(false);
}
}, [canViewReports, filters, page, perPage, selectedReport, t]);
}, [canViewReports, filters, page, perPage, selectedReport]);
useEffect(() => {
queueMicrotask(() => {
@@ -928,7 +1055,7 @@ export function ReportsConsole() {
/>
<div className="mt-2 max-h-64 overflow-auto">
{search.loading ? (
<p className="px-2 py-2 text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
<AdminLoadingInline className="py-2" />
) : null}
{!search.loading && kind === "draw" ? (
search.draws.map((item) => (
@@ -1067,11 +1194,7 @@ export function ReportsConsole() {
}
if (loading) {
return (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.loading", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableLoadingRow colSpan={8} />
);
}
if (error) {
@@ -1148,7 +1271,7 @@ export function ReportsConsole() {
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
<TableCell>v{result.raw.pool.version}</TableCell>
</TableRow>
{result.raw.logs.items.map((item) => (
@@ -1176,7 +1299,7 @@ export function ReportsConsole() {
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
<TableCell>v{item.version}</TableCell>
</TableRow>
));
@@ -1201,7 +1324,10 @@ export function ReportsConsole() {
return result.raw.map((item) => (
<TableRow key={item.player_id}>
<TableCell className="font-medium">{item.username}</TableCell>
<TableCell>ID {item.player_id}</TableCell>
<TableCell className="text-xs">
{adminAgentDisplayLabel(item)}
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
@@ -1305,6 +1431,13 @@ export function ReportsConsole() {
<CardContent className="space-y-4 pt-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{selectedReport.fields.map(renderField)}
{selectedReport.category === "profit" || selectedReport.category === "wallet" ? (
<AdminAgentFilter
id="report-agent-filter"
value={filters.agentNodeId}
onChange={(id) => setFilters((prev) => ({ ...prev, agentNodeId: id }))}
/>
) : null}
</div>
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
<div className="flex shrink-0 gap-2">
@@ -1395,17 +1528,20 @@ export function ReportsConsole() {
</div>
</CardHeader>
<CardContent className="space-y-4 pt-4">
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950">
{t("preview.summaryScopeHint")}
</div>
<Table id="reports-preview-table">
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.primary")}</TableHead>
<TableHead>{t("preview.columns.secondary")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricA")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricC")}</TableHead>
<TableHead>{t("preview.columns.status")}</TableHead>
<TableHead>{t("preview.columns.extra")}</TableHead>
<TableHead>{t("preview.columns.time")}</TableHead>
<TableHead>{previewColumns.primary}</TableHead>
<TableHead>{previewColumns.secondary}</TableHead>
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
<TableHead>{previewColumns.status}</TableHead>
<TableHead>{previewColumns.extra}</TableHead>
<TableHead>{previewColumns.time}</TableHead>
</TableRow>
</TableHeader>
<TableBody>{renderTable()}</TableBody>

View File

@@ -1,7 +1,10 @@
"use client";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDraw } from "@/api/admin-draws";
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display";
@@ -11,6 +14,7 @@ import type { AdminDrawShowData } from "@/types/api/admin-draws";
export function RiskDrawHeader({ drawId }: { drawId: number }) {
const { t } = useTranslation(["risk", "draws"]);
const tRef = useTranslationRef(["risk", "draws"]);
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -21,24 +25,22 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {
setDraw(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("drawInfoLoadFailed");
e instanceof LotteryApiBizError ? e.message : tRef.current("drawInfoLoadFailed");
setError(msg);
setDraw(null);
}
}, [drawId, t]);
}, [drawId]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [drawId]);
if (error) {
return <p className="text-sm text-destructive">{error}</p>;
}
if (!draw) {
return <p className="text-sm text-muted-foreground">{t("loadingDraw")}</p>;
return <AdminLoadingInline className="py-4" label={t("loadingDraw")} />;
}
return (

View File

@@ -1,9 +1,11 @@
"use client";
import { Shield } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDraws } from "@/api/admin-draws";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -13,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
@@ -48,6 +51,7 @@ const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
export function RiskIndexConsole() {
const { t } = useTranslation(["risk", "common"]);
const tRef = useTranslationRef(["risk", "common"]);
const exportLabels = useExportLabels("riskIndex");
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminDrawListData | null>(null);
@@ -81,19 +85,17 @@ export function RiskIndexConsole() {
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("loadDrawListFailed");
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDrawListFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, drawNoQuery, statusFilter, t]);
}, [page, perPage, drawNoQuery, statusFilter]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [page, perPage, drawNoQuery, statusFilter]);
function applySearch(): void {
setDrawNoQuery(drawNoInput.trim());
@@ -174,10 +176,7 @@ export function RiskIndexConsole() {
</CardHeader>
<CardContent className="admin-list-content">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading && (data?.items.length ?? 0) === 0 ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<div className="admin-table-shell">
<div className="admin-table-shell">
<Table id="risk-index-table">
<TableHeader>
<TableRow>
@@ -188,7 +187,9 @@ export function RiskIndexConsole() {
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).length === 0 ? (
{loading && (data?.items.length ?? 0) === 0 ? (
<AdminTableLoadingRow colSpan={4} />
) : (data?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
@@ -222,7 +223,6 @@ export function RiskIndexConsole() {
</TableBody>
</Table>
</div>
)}
<AdminListPaginationFooter
selectId="risk-index-draws-per-page"
total={total}

View File

@@ -1,8 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -11,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
@@ -48,6 +51,7 @@ function riskActionFilterLabel(
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
const { t } = useTranslation(["risk", "common"]);
const tRef = useTranslationRef(["risk", "common"]);
const exportLabels = useExportLabels("riskLockLogs");
useAdminCurrencyCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
@@ -79,19 +83,17 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("loadLogsFailed");
e instanceof LotteryApiBizError ? e.message : tRef.current("loadLogsFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [drawId, page, perPage, appliedAction, appliedNumber, t]);
}, [drawId, page, perPage, appliedAction, appliedNumber]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [drawId, page, perPage, appliedAction, appliedNumber]);
return (
<Card className="admin-list-card">
@@ -157,10 +159,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<>
<>
<div className="admin-table-shell">
<Table id={`risk-lock-logs-table-${drawId}`}>
<TableHeader>
@@ -175,6 +174,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
{(data?.items ?? []).map((row: AdminRiskLockLogRow) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
@@ -214,7 +214,6 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
/>
) : null}
</>
)}
</CardContent>
</Card>
);

View File

@@ -1,14 +1,17 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminRiskPoolDetail } from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -35,6 +38,7 @@ export function RiskPoolDetailConsole({
number4d: string;
}) {
const { t } = useTranslation(["risk", "common"]);
const tRef = useTranslationRef(["risk", "common"]);
const exportLabels = useExportLabels("riskPoolDetail", { number: number4d });
useAdminCurrencyCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
@@ -53,19 +57,17 @@ export function RiskPoolDetailConsole({
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("loadDetailFailed");
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDetailFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [drawId, number4d, page, perPage, t]);
}, [drawId, number4d, page, perPage]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [drawId, number4d, page, perPage]);
if (error && !data) {
return (
@@ -87,7 +89,7 @@ export function RiskPoolDetailConsole({
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (!data) {

View File

@@ -1,8 +1,10 @@
"use client";
import { Eye, Lock, Unlock } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -17,6 +19,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
@@ -81,6 +84,7 @@ export function RiskPoolsConsole({
allowSortChange = false,
}: RiskPoolsConsoleProps) {
const { t } = useTranslation(["risk", "common"]);
const tRef = useTranslationRef(["risk", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [
@@ -115,19 +119,17 @@ export function RiskPoolsConsole({
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("loadPoolsFailed");
e instanceof LotteryApiBizError ? e.message : tRef.current("loadPoolsFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [drawId, filter, number, page, perPage, sort, t]);
}, [drawId, filter, number, page, perPage, sort]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [drawId, filter, number, page, perPage, sort]);
const handleManualStatus = useCallback(
async (row: AdminRiskPoolRow) => {
@@ -240,10 +242,7 @@ export function RiskPoolsConsole({
</CardHeader>
<CardContent className="admin-list-content">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<>
<>
<div className="admin-table-shell">
<Table id={`risk-pools-table-${drawId}`}>
<TableHeader>
@@ -258,6 +257,7 @@ export function RiskPoolsConsole({
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
const acting = actingNumber === row.normalized_number;
@@ -359,7 +359,6 @@ export function RiskPoolsConsole({
/>
) : null}
</>
)}
</CardContent>
</Card>
<ConfirmDialog />

View File

@@ -0,0 +1,92 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminSettings } from "@/api/admin-settings";
/** 系统设置页一次拉取的分组(避免各卡片重复 GET */
export const SYSTEM_SETTINGS_GROUPS = ["draw", "settlement", "frontend", "wallet"] as const;
function mergeItemsToKv(
items: { key: string; value: unknown }[],
into: Record<string, unknown>,
): void {
for (const item of items) {
into[item.key] = item.value;
}
}
type AdminSettingsDataContextValue = {
kv: Record<string, unknown> | null;
loading: boolean;
reload: () => Promise<void>;
patchKv: (updates: Record<string, unknown>) => void;
};
const AdminSettingsDataContext = createContext<AdminSettingsDataContextValue | null>(null);
export function AdminSettingsDataProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation(["config"]);
const [kv, setKv] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const tRef = useRef(t);
tRef.current = t;
const reload = useCallback(async () => {
setLoading(true);
try {
const responses = await Promise.all(
SYSTEM_SETTINGS_GROUPS.map((group) => getAdminSettings(group)),
);
const merged: Record<string, unknown> = {};
for (const res of responses) {
mergeItemsToKv(res.items, merged);
}
setKv(merged);
} catch {
toast.error(tRef.current("system.loadFailed", { ns: "config" }));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const patchKv = useCallback((updates: Record<string, unknown>) => {
setKv((prev) => (prev === null ? { ...updates } : { ...prev, ...updates }));
}, []);
const value = useMemo(
() => ({ kv, loading, reload, patchKv }),
[kv, loading, reload, patchKv],
);
return (
<AdminSettingsDataContext.Provider value={value}>{children}</AdminSettingsDataContext.Provider>
);
}
export function useAdminSettingsData(): AdminSettingsDataContextValue {
const ctx = useContext(AdminSettingsDataContext);
if (ctx === null) {
throw new Error("useAdminSettingsData must be used within AdminSettingsDataProvider");
}
return ctx;
}
export function useOptionalAdminSettingsData(): AdminSettingsDataContextValue | null {
return useContext(AdminSettingsDataContext);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { Button } from "@/components/ui/button";
export function SettingsSectionActions({
dirty,
loading,
saving,
onSave,
onDiscard,
saveLabel,
savingLabel,
discardLabel,
}: {
dirty: boolean;
loading: boolean;
saving: boolean;
onSave: () => void;
onDiscard: () => void;
saveLabel: string;
savingLabel: string;
discardLabel: string;
}) {
return (
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
{saving ? savingLabel : saveLabel}
</Button>
{dirty ? (
<Button type="button" variant="outline" onClick={onDiscard} disabled={saving}>
{discardLabel}
</Button>
) : null}
</div>
);
}

View File

@@ -1,9 +1,11 @@
"use client";
import { Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -68,6 +70,7 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
export function CurrencySettingsPanel() {
const { t } = useTranslation(["config", "adminUsers"]);
const tRef = useTranslationRef(["config", "adminUsers"]);
const exportLabels = useExportLabels("currencies");
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
@@ -96,18 +99,16 @@ export function CurrencySettingsPanel() {
toast.error(
error instanceof LotteryApiBizError
? error.message
: t("currencies.loadFailed", { ns: "config" }),
: tRef.current("currencies.loadFailed", { ns: "config" }),
);
} finally {
setLoading(false);
}
}, [canManage, t]);
}, [canManage]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [canManage]);
function openCreate(): void {
setMode("create");

View File

@@ -0,0 +1,99 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { updateAdminSettingsBatch, type AdminSettingBatchItem } from "@/api/admin-settings";
import { setCachedApplyRebateToPayoutSetting } from "@/lib/admin-settlement-settings-cache";
import { useAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
import { LotteryApiBizError } from "@/types/api/errors";
export function useSettingsSection<TDraft>(options: {
initialDraft: TDraft;
fromKv: (kv: Record<string, unknown>) => TDraft;
buildDirtyItems: (draft: TDraft, saved: TDraft) => AdminSettingBatchItem[];
saveSuccessKey: string;
saveFailedKey: string;
}) {
const { t } = useTranslation(["config"]);
const tRef = useRef(t);
tRef.current = t;
const { kv, loading, patchKv } = useAdminSettingsData();
const [draft, setDraft] = useState(options.initialDraft);
const [saved, setSaved] = useState(options.initialDraft);
const [saving, setSaving] = useState(false);
const hydratedRef = useRef(false);
const { fromKv, buildDirtyItems, saveSuccessKey, saveFailedKey } = options;
const dirty = useMemo(
() => buildDirtyItems(draft, saved).length > 0,
[draft, saved, buildDirtyItems],
);
useEffect(() => {
if (kv === null) {
return;
}
const next = fromKv(kv);
setDraft(next);
setSaved(next);
hydratedRef.current = true;
}, [kv, fromKv]);
const updateField = <K extends keyof TDraft>(field: K, value: TDraft[K]) => {
setDraft((prev) => ({ ...prev, [field]: value }));
};
const discard = () => {
setDraft(saved);
};
const save = async (): Promise<boolean> => {
const items = buildDirtyItems(draft, saved);
if (items.length === 0) {
return true;
}
setSaving(true);
try {
await updateAdminSettingsBatch(items);
const updates: Record<string, unknown> = {};
for (const item of items) {
updates[item.key] = item.value;
if (item.key === SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT) {
setCachedApplyRebateToPayoutSetting(Boolean(item.value));
}
}
patchKv(updates);
setSaved(draft);
toast.success(tRef.current(saveSuccessKey, { ns: "config" }));
return true;
} catch (error) {
toast.error(
error instanceof LotteryApiBizError
? error.message
: tRef.current(saveFailedKey, { ns: "config" }),
);
return false;
} finally {
setSaving(false);
}
};
const sectionLoading = loading || (kv !== null && !hydratedRef.current);
return {
draft,
saved,
loading: sectionLoading,
saving,
dirty,
updateField,
discard,
save,
};
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface CurrencyFormatDraft {
currencyDisplayDecimals: string;
currencyDecimalSeparator: string;
currencyThousandsSeparator: string;
}
const INITIAL: CurrencyFormatDraft = {
currencyDisplayDecimals: "2",
currencyDecimalSeparator: ".",
currencyThousandsSeparator: ",",
};
function fromKv(kv: Record<string, unknown>): CurrencyFormatDraft {
return {
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
};
}
function buildDirtyItems(draft: CurrencyFormatDraft, saved: CurrencyFormatDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
if (draft.currencyDisplayDecimals !== saved.currencyDisplayDecimals) {
items.push({
key: DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
value: Math.max(
0,
Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2),
),
});
}
if (draft.currencyDecimalSeparator !== saved.currencyDecimalSeparator) {
items.push({
key: DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
value: (draft.currencyDecimalSeparator || ".").slice(0, 1),
});
}
if (draft.currencyThousandsSeparator !== saved.currencyThousandsSeparator) {
items.push({
key: DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
value: (draft.currencyThousandsSeparator || ",").slice(0, 1),
});
}
return items;
}
export function CurrencyFormatSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
initialDraft: INITIAL,
fromKv,
buildDirtyItems: buildItems,
saveSuccessKey: "system.saveCurrencyFormatSuccess",
saveFailedKey: "system.saveFailed",
});
const { draft, loading, saving, dirty, updateField, discard, save } = section;
return (
<>
<AdminPageCard
title={t("system.sections.currencyFormat", { ns: "config" })}
description={t("system.sections.currencyFormatDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
</Label>
<Input
id="currency-display-decimals"
type="number"
min="0"
max="12"
step="1"
value={draft.currencyDisplayDecimals}
onChange={(e) => updateField("currencyDisplayDecimals", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
</Label>
<Input
id="currency-decimal-separator"
value={draft.currencyDecimalSeparator}
onChange={(e) => updateField("currencyDecimalSeparator", e.target.value)}
disabled={loading || saving}
maxLength={1}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
</Label>
<Input
id="currency-thousands-separator"
value={draft.currencyThousandsSeparator}
onChange={(e) => updateField("currencyThousandsSeparator", e.target.value)}
disabled={loading || saving}
maxLength={1}
/>
</div>
</div>
<SettingsSectionActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveCurrencyFormatTitle", { ns: "config" }),
description: t("system.confirmSaveCurrencyFormatDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
})
}
onDiscard={discard}
saveLabel={t("actions.save", { ns: "adminUsers" })}
savingLabel={t("saving", { ns: "adminUsers" })}
discardLabel={t("system.discard", { ns: "config" })}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface DrawDraft {
defaultCurrency: string;
drawIntervalMinutes: string;
drawBettingWindowSeconds: string;
drawCloseBeforeDrawSeconds: string;
drawBufferDrawsAhead: string;
requireManualReview: boolean;
cooldownMinutes: string;
}
const INITIAL: DrawDraft = {
defaultCurrency: "NPR",
drawIntervalMinutes: "5",
drawBettingWindowSeconds: "270",
drawCloseBeforeDrawSeconds: "30",
drawBufferDrawsAhead: "8",
requireManualReview: false,
cooldownMinutes: "15",
};
function fromKv(kv: Record<string, unknown>): DrawDraft {
return {
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
};
}
function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
const push = (key: string, value: unknown, changed: boolean) => {
if (changed) {
items.push({ key, value });
}
};
push(
DRAW_KEYS.DEFAULT_CURRENCY,
draft.defaultCurrency.trim().toUpperCase() || "NPR",
draft.defaultCurrency !== saved.defaultCurrency,
);
push(
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
draft.drawIntervalMinutes !== saved.drawIntervalMinutes,
);
push(
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
draft.drawBettingWindowSeconds !== saved.drawBettingWindowSeconds,
);
push(
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
draft.drawCloseBeforeDrawSeconds !== saved.drawCloseBeforeDrawSeconds,
);
push(
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
draft.drawBufferDrawsAhead !== saved.drawBufferDrawsAhead,
);
push(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview, draft.requireManualReview !== saved.requireManualReview);
push(
DRAW_KEYS.COOLDOWN_MINUTES,
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
draft.cooldownMinutes !== saved.cooldownMinutes,
);
return items;
}
export function DrawSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
initialDraft: INITIAL,
fromKv,
buildDirtyItems: buildItems,
saveSuccessKey: "system.saveDrawSuccess",
saveFailedKey: "system.saveFailed",
});
const { draft, loading, saving, dirty, updateField, discard, save } = section;
return (
<>
<AdminPageCard
title={t("system.sections.draw", { ns: "config" })}
description={t("system.sections.drawDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
<Switch
checked={draft.requireManualReview}
disabled={loading || saving}
aria-label={t("system.fields.manualReview", { ns: "config" })}
onCheckedChange={(value) => updateField("requireManualReview", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="default-currency" className="text-sm font-medium">
{t("system.fields.defaultCurrency", { ns: "config" })}
</Label>
<Input
id="default-currency"
value={draft.defaultCurrency}
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
disabled={loading || saving}
maxLength={16}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
</Label>
<Input
id="draw-interval-minutes"
type="number"
min="1"
max="1440"
step="1"
value={draft.drawIntervalMinutes}
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
</Label>
<Input
id="draw-betting-window-seconds"
type="number"
min="10"
step="1"
value={draft.drawBettingWindowSeconds}
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
</Label>
<Input
id="draw-close-before-seconds"
type="number"
min="5"
step="1"
value={draft.drawCloseBeforeDrawSeconds}
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
</Label>
<Input
id="draw-buffer-ahead"
type="number"
min="1"
step="1"
value={draft.drawBufferDrawsAhead}
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
{t("system.fields.cooldownMinutes", { ns: "config" })}
</Label>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
</div>
<SettingsSectionActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveDrawTitle", { ns: "config" }),
description: t("system.confirmSaveDrawDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
})
}
onDiscard={discard}
saveLabel={t("actions.save", { ns: "adminUsers" })}
savingLabel={t("saving", { ns: "adminUsers" })}
discardLabel={t("system.discard", { ns: "config" })}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { FRONTEND_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
interface FrontendDraft {
playRulesHtmlZh: string;
playRulesHtmlEn: string;
playRulesHtmlNe: string;
}
const INITIAL: FrontendDraft = {
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
};
function fromKv(kv: Record<string, unknown>): FrontendDraft {
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
return {
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
};
}
function buildDirtyItems(draft: FrontendDraft, saved: FrontendDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
if (draft.playRulesHtmlZh !== saved.playRulesHtmlZh) {
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_ZH, value: draft.playRulesHtmlZh });
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML, value: draft.playRulesHtmlZh });
}
if (draft.playRulesHtmlEn !== saved.playRulesHtmlEn) {
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_EN, value: draft.playRulesHtmlEn });
}
if (draft.playRulesHtmlNe !== saved.playRulesHtmlNe) {
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_NE, value: draft.playRulesHtmlNe });
}
return items;
}
export function FrontendSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
initialDraft: INITIAL,
fromKv,
buildDirtyItems: buildItems,
saveSuccessKey: "system.saveFrontendSuccess",
saveFailedKey: "system.saveFailed",
});
const { draft, loading, saving, dirty, updateField, discard, save } = section;
return (
<>
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
<div className="grid gap-2">
<Label className="text-sm font-medium">
{t("system.fields.playRulesHtml", { ns: "config" })}
</Label>
<p className="text-xs text-muted-foreground">
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
</p>
<Tabs defaultValue="zh" className="w-full">
<TabsList className="w-full max-w-md">
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
</TabsList>
<TabsContent value="zh" className="mt-3">
<Textarea
id="play-rules-html-zh"
value={draft.playRulesHtmlZh}
onChange={(e) => updateField("playRulesHtmlZh", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="en" className="mt-3">
<Textarea
id="play-rules-html-en"
value={draft.playRulesHtmlEn}
onChange={(e) => updateField("playRulesHtmlEn", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="ne" className="mt-3">
<Textarea
id="play-rules-html-ne"
value={draft.playRulesHtmlNe}
onChange={(e) => updateField("playRulesHtmlNe", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
</Tabs>
<SettingsSectionActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
})
}
onDiscard={discard}
saveLabel={t("actions.save", { ns: "adminUsers" })}
savingLabel={t("saving", { ns: "adminUsers" })}
discardLabel={t("system.discard", { ns: "config" })}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface SettlementDraft {
autoSettlement: boolean;
autoApprove: boolean;
autoPayout: boolean;
applyRebateToPayout: boolean;
}
const INITIAL: SettlementDraft = {
autoSettlement: true,
autoApprove: true,
autoPayout: true,
applyRebateToPayout: false,
};
function fromKv(kv: Record<string, unknown>): SettlementDraft {
return {
autoSettlement: Boolean(kv[SETTLEMENT_KEYS.AUTO_SETTLEMENT] ?? true),
autoApprove: Boolean(kv[SETTLEMENT_KEYS.AUTO_APPROVE] ?? true),
autoPayout: Boolean(kv[SETTLEMENT_KEYS.AUTO_PAYOUT] ?? true),
applyRebateToPayout: Boolean(kv[SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
};
}
function buildDirtyItems(draft: SettlementDraft, saved: SettlementDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
if (draft.autoSettlement !== saved.autoSettlement) {
items.push({ key: SETTLEMENT_KEYS.AUTO_SETTLEMENT, value: draft.autoSettlement });
}
if (draft.autoApprove !== saved.autoApprove) {
items.push({ key: SETTLEMENT_KEYS.AUTO_APPROVE, value: draft.autoApprove });
}
if (draft.autoPayout !== saved.autoPayout) {
items.push({ key: SETTLEMENT_KEYS.AUTO_PAYOUT, value: draft.autoPayout });
}
if (draft.applyRebateToPayout !== saved.applyRebateToPayout) {
items.push({ key: SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT, value: draft.applyRebateToPayout });
}
return items;
}
export function SettlementSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
initialDraft: INITIAL,
fromKv,
buildDirtyItems: buildItems,
saveSuccessKey: "system.saveSettlementSuccess",
saveFailedKey: "system.saveFailed",
});
const { draft, loading, saving, dirty, updateField, discard, save } = section;
return (
<>
<AdminPageCard
title={t("system.sections.settlement", { ns: "config" })}
description={t("system.sections.settlementDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<Switch
checked={draft.autoSettlement}
disabled={loading || saving}
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
onCheckedChange={(value) => updateField("autoSettlement", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
<Switch
checked={draft.autoApprove}
disabled={loading || saving}
aria-label={t("system.fields.autoApprove", { ns: "config" })}
onCheckedChange={(value) => updateField("autoApprove", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
<Switch
checked={draft.autoPayout}
disabled={loading || saving}
aria-label={t("system.fields.autoPayout", { ns: "config" })}
onCheckedChange={(value) => updateField("autoPayout", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1 pr-4">
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
</div>
<Switch
checked={draft.applyRebateToPayout}
disabled={loading || saving}
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
/>
</div>
<SettingsSectionActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveSettlementTitle", { ns: "config" }),
description: t("system.confirmSaveSettlementDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
})
}
onDiscard={discard}
saveLabel={t("actions.save", { ns: "adminUsers" })}
savingLabel={t("saving", { ns: "adminUsers" })}
discardLabel={t("system.discard", { ns: "config" })}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,38 @@
export const DRAW_GROUP = "draw";
export const SETTLEMENT_GROUP = "settlement";
export const FRONTEND_GROUP = "frontend";
export const WALLET_GROUP = "wallet";
export const DRAW_KEYS = {
DEFAULT_CURRENCY: "currency.default_code",
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes",
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
} as const;
export const SETTLEMENT_KEYS = {
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
AUTO_APPROVE: "settlement.auto_approve_on_tick",
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
} as const;
export const FRONTEND_KEYS = {
PLAY_RULES_HTML: "frontend.play_rules_html",
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
} as const;
export const WALLET_KEYS = {
IN_MIN: "wallet.transfer_in_min_minor",
IN_MAX: "wallet.transfer_in_max_minor",
OUT_MIN: "wallet.transfer_out_min_minor",
OUT_MAX: "wallet.transfer_out_max_minor",
} as const;

View File

@@ -1,568 +1,22 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getAdminSettings,
updateAdminSetting,
} from "@/api/admin-settings";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LotteryApiBizError } from "@/types/api/errors";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel";
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
import { useTranslation } from "react-i18next";
const DRAW_GROUP = "draw";
const SETTLEMENT_GROUP = "settlement";
const DRAW_KEYS = {
DEFAULT_CURRENCY: "currency.default_code",
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes",
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
AUTO_APPROVE: "settlement.auto_approve_on_tick",
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
} as const;
const FRONTEND_GROUP = "frontend";
const FRONTEND_KEYS = {
PLAY_RULES_HTML: "frontend.play_rules_html",
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
} as const;
interface RuntimeDraft {
defaultCurrency: string;
drawIntervalMinutes: string;
drawBettingWindowSeconds: string;
drawCloseBeforeDrawSeconds: string;
drawBufferDrawsAhead: string;
requireManualReview: boolean;
cooldownMinutes: string;
currencyDisplayDecimals: string;
currencyDecimalSeparator: string;
currencyThousandsSeparator: string;
autoSettlement: boolean;
autoApprove: boolean;
autoPayout: boolean;
applyRebateToPayout: boolean;
playRulesHtmlZh: string;
playRulesHtmlEn: string;
playRulesHtmlNe: string;
}
const RUNTIME_DRAFT_KEYS = [
"defaultCurrency",
"drawIntervalMinutes",
"drawBettingWindowSeconds",
"drawCloseBeforeDrawSeconds",
"drawBufferDrawsAhead",
"requireManualReview",
"cooldownMinutes",
"currencyDisplayDecimals",
"currencyDecimalSeparator",
"currencyThousandsSeparator",
"autoSettlement",
"autoApprove",
"autoPayout",
"applyRebateToPayout",
] as const satisfies readonly (keyof RuntimeDraft)[];
const FRONTEND_DRAFT_KEYS = [
"playRulesHtmlZh",
"playRulesHtmlEn",
"playRulesHtmlNe",
] as const satisfies readonly (keyof RuntimeDraft)[];
function isSectionDirty<const K extends keyof RuntimeDraft>(
draft: RuntimeDraft,
saved: RuntimeDraft,
keys: readonly K[],
): boolean {
return keys.some((key) => draft[key] !== saved[key]);
}
function applyDraftFields<const K extends keyof RuntimeDraft>(
base: RuntimeDraft,
source: RuntimeDraft,
keys: readonly K[],
): RuntimeDraft {
const next = { ...base };
for (const key of keys) {
next[key] = source[key];
}
return next;
}
function SaveActions({
dirty,
loading,
saving,
onSave,
onDiscard,
saveLabel,
savingLabel,
discardLabel,
}: {
dirty: boolean;
loading: boolean;
saving: boolean;
onSave: () => void;
onDiscard: () => void;
saveLabel: string;
savingLabel: string;
discardLabel: string;
}) {
return (
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
{saving ? savingLabel : saveLabel}
</Button>
{dirty ? (
<Button type="button" variant="outline" onClick={onDiscard}>
{discardLabel}
</Button>
) : null}
</div>
);
}
export function SystemSettingsScreen() {
const { t } = useTranslation(["common", "config", "adminUsers"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [draft, setDraft] = useState<RuntimeDraft>({
defaultCurrency: "NPR",
drawIntervalMinutes: "5",
drawBettingWindowSeconds: "270",
drawCloseBeforeDrawSeconds: "30",
drawBufferDrawsAhead: "8",
requireManualReview: false,
cooldownMinutes: "15",
currencyDisplayDecimals: "2",
currencyDecimalSeparator: ".",
currencyThousandsSeparator: ",",
autoSettlement: true,
autoApprove: true,
autoPayout: true,
applyRebateToPayout: false,
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
});
const [saved, setSaved] = useState<RuntimeDraft>({
defaultCurrency: "NPR",
drawIntervalMinutes: "5",
drawBettingWindowSeconds: "270",
drawCloseBeforeDrawSeconds: "30",
drawBufferDrawsAhead: "8",
requireManualReview: false,
cooldownMinutes: "15",
currencyDisplayDecimals: "2",
currencyDecimalSeparator: ".",
currencyThousandsSeparator: ",",
autoSettlement: true,
autoApprove: true,
autoPayout: true,
applyRebateToPayout: false,
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
});
const [loading, setLoading] = useState(true);
const [savingRuntime, setSavingRuntime] = useState(false);
const [savingFrontend, setSavingFrontend] = useState(false);
const runtimeDirty = useMemo(
() => isSectionDirty(draft, saved, RUNTIME_DRAFT_KEYS),
[draft, saved],
);
const frontendDirty = useMemo(
() => isSectionDirty(draft, saved, FRONTEND_DRAFT_KEYS),
[draft, saved],
);
const anyDirty = runtimeDirty || frontendDirty;
const saving = savingRuntime || savingFrontend;
const load = useCallback(async () => {
setLoading(true);
try {
const [drawRes, settlementRes, frontendRes] = await Promise.all([
getAdminSettings(DRAW_GROUP),
getAdminSettings(SETTLEMENT_GROUP),
getAdminSettings(FRONTEND_GROUP),
]);
const kv: Record<string, unknown> = {};
for (const item of [...drawRes.items, ...settlementRes.items, ...frontendRes.items]) {
kv[item.key] = item.value;
}
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
const nextDraft: RuntimeDraft = {
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
applyRebateToPayout: Boolean(kv[DRAW_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
};
setDraft(nextDraft);
setSaved(nextDraft);
} catch {
toast.error(t("system.loadFailed", { ns: "config" }));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
setDraft((prev) => ({ ...prev, [field]: value }));
};
const discardSection = <const K extends keyof RuntimeDraft>(keys: readonly K[]) => {
setDraft((prev) => applyDraftFields(prev, saved, keys));
};
const handleSaveRuntime = async () => {
setSavingRuntime(true);
try {
await updateAdminSetting(
DRAW_KEYS.DEFAULT_CURRENCY,
draft.defaultCurrency.trim().toUpperCase() || "NPR",
);
await updateAdminSetting(
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
);
await updateAdminSetting(
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
);
await updateAdminSetting(
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
);
await updateAdminSetting(
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
);
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
await updateAdminSetting(
DRAW_KEYS.COOLDOWN_MINUTES,
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
);
await updateAdminSetting(
DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
Math.max(0, Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2)),
);
await updateAdminSetting(
DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
(draft.currencyDecimalSeparator || ".").slice(0, 1),
);
await updateAdminSetting(
DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
(draft.currencyThousandsSeparator || ",").slice(0, 1),
);
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
toast.success(t("system.saveRuntimeSuccess", { ns: "config" }));
setSaved((prev) => applyDraftFields(prev, draft, RUNTIME_DRAFT_KEYS));
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
);
} finally {
setSavingRuntime(false);
}
};
const handleSaveFrontend = async () => {
setSavingFrontend(true);
try {
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
toast.success(t("system.saveFrontendSuccess", { ns: "config" }));
setSaved((prev) => applyDraftFields(prev, draft, FRONTEND_DRAFT_KEYS));
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
);
} finally {
setSavingFrontend(false);
}
};
const saveLabel = t("actions.save", { ns: "adminUsers" });
const savingLabel = t("saving", { ns: "adminUsers" });
const discardLabel = t("system.discard", { ns: "config" });
function SystemSettingsContent() {
const { t } = useTranslation(["config"]);
return (
<div className="flex w-full max-w-none flex-col gap-6">
{anyDirty ? (
<div className="sticky top-0 z-20 -mx-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 shadow-sm backdrop-blur-sm">
<p className="text-sm font-medium text-amber-950 dark:text-amber-100">
{t("system.unsavedChanges", { ns: "config" })}
{runtimeDirty && frontendDirty
? ` · ${t("system.title", { ns: "config" })} / ${t("system.frontendConfig", { ns: "config" })}`
: runtimeDirty
? ` · ${t("system.title", { ns: "config" })}`
: ` · ${t("system.frontendConfig", { ns: "config" })}`}
</p>
</div>
) : null}
<AdminPageCard
title={t("system.title", { ns: "config" })}
description={t("system.description", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
<Switch
checked={draft.requireManualReview}
disabled={loading || saving}
aria-label={t("system.fields.manualReview", { ns: "config" })}
onCheckedChange={(value) => updateDraft("requireManualReview", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="default-currency" className="text-sm font-medium">
{t("system.fields.defaultCurrency", { ns: "config" })}
</Label>
<Input
id="default-currency"
value={draft.defaultCurrency}
onChange={(e) => updateDraft("defaultCurrency", e.target.value.toUpperCase())}
disabled={loading || saving}
maxLength={16}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
</Label>
<Input
id="draw-interval-minutes"
type="number"
min="1"
max="1440"
step="1"
value={draft.drawIntervalMinutes}
onChange={(e) => updateDraft("drawIntervalMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
</Label>
<Input
id="draw-betting-window-seconds"
type="number"
min="10"
step="1"
value={draft.drawBettingWindowSeconds}
onChange={(e) => updateDraft("drawBettingWindowSeconds", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
</Label>
<Input
id="draw-close-before-seconds"
type="number"
min="5"
step="1"
value={draft.drawCloseBeforeDrawSeconds}
onChange={(e) => updateDraft("drawCloseBeforeDrawSeconds", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
</Label>
<Input
id="draw-buffer-ahead"
type="number"
min="1"
step="1"
value={draft.drawBufferDrawsAhead}
onChange={(e) => updateDraft("drawBufferDrawsAhead", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
</Label>
<Input
id="currency-display-decimals"
type="number"
min="0"
max="12"
step="1"
value={draft.currencyDisplayDecimals}
onChange={(e) => updateDraft("currencyDisplayDecimals", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
</Label>
<Input
id="currency-decimal-separator"
value={draft.currencyDecimalSeparator}
onChange={(e) => updateDraft("currencyDecimalSeparator", e.target.value)}
disabled={loading || saving}
maxLength={1}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
</Label>
<Input
id="currency-thousands-separator"
value={draft.currencyThousandsSeparator}
onChange={(e) => updateDraft("currencyThousandsSeparator", e.target.value)}
disabled={loading || saving}
maxLength={1}
/>
</div>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<Switch
checked={draft.autoSettlement}
disabled={loading || saving}
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
onCheckedChange={(value) => updateDraft("autoSettlement", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
<Switch
checked={draft.autoApprove}
disabled={loading || saving}
aria-label={t("system.fields.autoApprove", { ns: "config" })}
onCheckedChange={(value) => updateDraft("autoApprove", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
<Switch
checked={draft.autoPayout}
disabled={loading || saving}
aria-label={t("system.fields.autoPayout", { ns: "config" })}
onCheckedChange={(value) => updateDraft("autoPayout", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1 pr-4">
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
</div>
<Switch
checked={draft.applyRebateToPayout}
disabled={loading || saving}
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
onCheckedChange={(value) => updateDraft("applyRebateToPayout", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid max-w-xs gap-2">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
{t("system.fields.cooldownMinutes", { ns: "config" })}
</Label>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
disabled={loading || saving}
/>
</div>
<SaveActions
dirty={runtimeDirty}
loading={loading}
saving={savingRuntime}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveRuntimeTitle", { ns: "config" }),
description: t("system.confirmSaveRuntimeDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSaveRuntime(),
})
}
onDiscard={() => discardSection(RUNTIME_DRAFT_KEYS)}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
</div>
</AdminPageCard>
<DrawSettingsPanel />
<CurrencyFormatSettingsPanel />
<SettlementSettingsPanel />
<AdminPageCard
title={t("wallet.title", { ns: "config" })}
@@ -571,73 +25,15 @@ export function SystemSettingsScreen() {
<WalletConfigDocScreen embedded />
</AdminPageCard>
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
<div className="grid gap-2">
<Label className="text-sm font-medium">
{t("system.fields.playRulesHtml", { ns: "config" })}
</Label>
<p className="text-xs text-muted-foreground">
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
</p>
<Tabs defaultValue="zh" className="w-full">
<TabsList className="w-full max-w-md">
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
</TabsList>
<TabsContent value="zh" className="mt-3">
<Textarea
id="play-rules-html-zh"
value={draft.playRulesHtmlZh}
onChange={(e) => updateDraft("playRulesHtmlZh", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="en" className="mt-3">
<Textarea
id="play-rules-html-en"
value={draft.playRulesHtmlEn}
onChange={(e) => updateDraft("playRulesHtmlEn", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="ne" className="mt-3">
<Textarea
id="play-rules-html-ne"
value={draft.playRulesHtmlNe}
onChange={(e) => updateDraft("playRulesHtmlNe", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
</Tabs>
<SaveActions
dirty={frontendDirty}
loading={loading}
saving={savingFrontend}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSaveFrontend(),
})
}
onDiscard={() => discardSection(FRONTEND_DRAFT_KEYS)}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
</div>
</AdminPageCard>
<ConfirmDialog />
<FrontendSettingsPanel />
</div>
);
}
export function SystemSettingsScreen() {
return (
<AdminSettingsDataProvider>
<SystemSettingsContent />
</AdminSettingsDataProvider>
);
}

View File

@@ -1,8 +1,10 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -14,6 +16,8 @@ import {
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
@@ -37,6 +41,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
@@ -72,6 +77,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
export function SettlementBatchDetailsConsole({ batchId }: Props) {
const { t } = useTranslation(["settlement", "common"]);
const tRef = useTranslationRef(["settlement", "common"]);
const profile = useAdminProfile();
useAdminCurrencyCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
@@ -84,6 +90,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [acting, setActing] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
const [reviewRemark, setReviewRemark] = useState("");
@@ -95,18 +103,22 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
try {
const [s, d] = await Promise.all([
getAdminSettlementBatch(batchId),
getAdminSettlementBatchDetails(batchId, { page, per_page: perPage }),
getAdminSettlementBatchDetails(batchId, {
page,
per_page: perPage,
agent_node_id: appliedAgentNodeId,
}),
]);
setSummary(s);
setDetails(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setSummary(null);
setDetails(null);
} finally {
setLoading(false);
}
}, [batchId, page, perPage, t]);
}, [batchId, page, perPage, appliedAgentNodeId]);
async function runAction(label: string, action: () => Promise<unknown>): Promise<void> {
setActing(label);
@@ -173,10 +185,9 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
}
}
useEffect(() => {
const t = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(t);
}, [load]);
useAsyncEffect(() => {
void load();
}, [batchId, page, perPage, appliedAgentNodeId]);
return (
<ModuleScaffold>
@@ -322,7 +333,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
</CardContent>
</Card>
) : loading ? (
<p className="text-muted-foreground text-sm">{t("loadingSummary")}</p>
<AdminLoadingState minHeight="6rem" className="py-4" label={t("loadingSummary")} />
) : null}
<Card>
@@ -332,11 +343,30 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<CardContent>
{details ? (
<>
<div className="mb-4 flex flex-wrap items-end gap-3">
<AdminAgentFilter
id="settlement-details-agent-filter"
className="w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<Button
type="button"
size="sm"
onClick={() => {
setAppliedAgentNodeId(agentNodeId);
setPage(1);
}}
>
{t("search", { ns: "common", defaultValue: "Search" })}
</Button>
</div>
<Table id={`settlement-details-table-${batchId}`}>
<TableHeader>
<TableRow>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead>{t("matchedTier")}</TableHead>
<TableHead className="text-center">{t("regularPayout")}</TableHead>
@@ -348,6 +378,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(r.play_code)}</TableCell>
<AdminAgentIdentityCells row={r} />
<AdminPlayerIdentityCells row={r} />
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
<TableCell className="text-center font-mono text-xs tabular-nums">
@@ -379,7 +410,11 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
</>
) : (
<p className="text-muted-foreground text-sm">
{loading ? t("loadingDetails") : t("states.noData", { ns: "common" })}
{loading ? (
<AdminLoadingInline label={t("loadingDetails")} />
) : (
t("states.noData", { ns: "common" })
)}
</p>
)}
</CardContent>

View File

@@ -1,9 +1,11 @@
"use client";
import { Check, Eye, HandCoins, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -12,6 +14,7 @@ import {
postAdminPayoutSettlementBatch,
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -45,6 +48,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money";
@@ -87,6 +91,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
export function SettlementBatchesConsole() {
const { t } = useTranslation(["settlement", "common"]);
const tRef = useTranslationRef(["settlement", "common"]);
const exportLabels = useExportLabels("settlementBatches");
const profile = useAdminProfile();
useAdminCurrencyCatalog();
@@ -99,6 +104,8 @@ export function SettlementBatchesConsole() {
const [appliedDrawNo, setAppliedDrawNo] = useState("");
const [draftStatus, setDraftStatus] = useState(STATUS_ALL);
const [appliedStatus, setAppliedStatus] = useState(STATUS_ALL);
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [actingId, setActingId] = useState<number | null>(null);
@@ -117,24 +124,25 @@ export function SettlementBatchesConsole() {
appliedStatus === STATUS_ALL || appliedStatus.trim() === ""
? undefined
: appliedStatus.trim(),
agent_node_id: appliedAgentNodeId,
});
setData(d);
} catch (e) {
setError(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
useEffect(() => {
const t = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(t);
}, [load]);
useAsyncEffect(() => {
void load();
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
const applyFilters = () => {
setAppliedDrawNo(draftDrawNo);
setAppliedStatus(draftStatus);
setAppliedAgentNodeId(agentNodeId);
setPage(1);
};
@@ -193,6 +201,12 @@ export function SettlementBatchesConsole() {
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
</div>
<div className="admin-list-toolbar">
<AdminAgentFilter
id="settlement-batches-agent-filter"
className="admin-list-field sm:w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<div className="admin-list-field">
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
{t("drawNo")}
@@ -234,10 +248,7 @@ export function SettlementBatchesConsole() {
</CardHeader>
<CardContent className="admin-list-content pt-0">
{error ? <p className="text-destructive text-sm">{error}</p> : null}
{loading && !data ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : (
<div className="admin-table-shell">
<div className="admin-table-shell">
<Table id="settlement-batches-table">
<TableHeader>
<TableRow>
@@ -253,6 +264,7 @@ export function SettlementBatchesConsole() {
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? <AdminTableLoadingRow colSpan={9} /> : null}
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.id}</TableCell>
@@ -333,7 +345,6 @@ export function SettlementBatchesConsole() {
</TableBody>
</Table>
</div>
)}
{data ? (
<AdminListPaginationFooter
selectId="settlement-batches-per-page"

View File

@@ -1,13 +1,17 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminTicketItems } from "@/api/admin-tickets";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -21,6 +25,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
@@ -58,6 +63,7 @@ const TICKET_STATUS_OPTIONS = [
type TicketFilters = {
siteCode: string;
agentNodeId: number | undefined;
playerQuery: string;
drawNo: string;
numberKeyword: string;
@@ -68,6 +74,7 @@ type TicketFilters = {
const emptyTicketFilters: TicketFilters = {
siteCode: "",
agentNodeId: undefined,
playerQuery: "",
drawNo: "",
numberKeyword: "",
@@ -101,6 +108,7 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
export function PlayerTicketsConsole(): React.ReactElement {
const { t } = useTranslation(["tickets", "common"]);
const tRef = useTranslationRef(["tickets", "common"]);
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
const playCodeLabel = useAdminPlayCodeLabel();
const exportLabels = useExportLabels("tickets");
@@ -131,6 +139,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
per_page: perPage,
...query,
site_code: applied.siteCode.trim() || undefined,
agent_node_id: applied.agentNodeId,
draw_no: applied.drawNo.trim() || undefined,
status: applied.statuses.length > 0 ? applied.statuses : undefined,
number: applied.numberKeyword.trim() || undefined,
@@ -139,24 +148,23 @@ export function PlayerTicketsConsole(): React.ReactElement {
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [applied, page, perPage, t]);
}, [applied, page, perPage]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [applied, page, perPage]);
const runSearch = () => {
setErr(null);
setApplied({
...draft,
siteCode: draft.siteCode.trim(),
agentNodeId: draft.agentNodeId,
playerQuery: draft.playerQuery.trim(),
drawNo: draft.drawNo.trim(),
numberKeyword: draft.numberKeyword.trim(),
@@ -222,6 +230,12 @@ export function PlayerTicketsConsole(): React.ReactElement {
</Select>
</div>
) : null}
<AdminAgentFilter
id="tickets-agent-filter"
className="admin-list-field sm:w-[14rem]"
value={draft.agentNodeId}
onChange={(id) => setDraft((current) => ({ ...current, agentNodeId: id }))}
/>
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
<Label htmlFor="pt-player" className="sm:shrink-0">
{t("playerId")}
@@ -344,17 +358,14 @@ export function PlayerTicketsConsole(): React.ReactElement {
) : null}
{err ? <p className="text-sm text-destructive">{err}</p> : null}
{loading ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
{loading || data ? (
<>
<div className="admin-table-shell">
<Table id="tickets-table">
<TableHeader>
<TableRow>
<TableHead>{t("ticketNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead>{t("orderNo")}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
@@ -370,9 +381,11 @@ export function PlayerTicketsConsole(): React.ReactElement {
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
{loading && !data ? (
<AdminTableLoadingRow colSpan={16} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={15} className="text-muted-foreground">
<TableCell colSpan={16} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -384,6 +397,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
return (
<TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
@@ -413,19 +427,21 @@ export function PlayerTicketsConsole(): React.ReactElement {
</TableBody>
</Table>
</div>
<AdminListPaginationFooter
selectId="player-tickets-per-page"
total={data.total}
page={data.page}
lastPage={Math.max(1, data.last_page)}
perPage={data.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
{data ? (
<AdminListPaginationFooter
selectId="player-tickets-per-page"
total={data.total}
page={data.page}
lastPage={Math.max(1, data.last_page)}
perPage={data.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>

View File

@@ -1,8 +1,10 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { Copy, RotateCcw, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
@@ -15,6 +17,8 @@ import {
} from "@/api/admin-wallet";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -24,6 +28,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
@@ -125,6 +130,7 @@ function statusLabelT(status: string, t: (key: string) => string): string {
}
type TransferFilters = {
agentNodeId: number | undefined;
playerId: string;
playerAccount: string;
transferNo: string;
@@ -136,6 +142,7 @@ type TransferFilters = {
};
const emptyTransferFilters: TransferFilters = {
agentNodeId: undefined,
playerId: "",
playerAccount: "",
transferNo: "",
@@ -147,6 +154,7 @@ const emptyTransferFilters: TransferFilters = {
};
type TxnFilters = {
agentNodeId: number | undefined;
playerId: string;
playerAccount: string;
txnNo: string;
@@ -159,6 +167,7 @@ type TxnFilters = {
};
const emptyTxnFilters: TxnFilters = {
agentNodeId: undefined,
playerId: "",
playerAccount: "",
txnNo: "",
@@ -306,6 +315,7 @@ function TransferOrderRowActions({
export function TransferOrdersPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const tRef = useTranslationRef(["wallet", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
@@ -386,21 +396,20 @@ export function TransferOrdersPanel(): React.ReactElement {
created_from: applied.createdFrom.trim() || undefined,
created_to: applied.createdTo.trim() || undefined,
status: applied.statusCsv.trim() || undefined,
agent_node_id: applied.agentNodeId,
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, applied, t]);
}, [page, perPage, applied]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [page, perPage, applied]);
const runSearch = () => {
setApplied({ ...draft });
@@ -421,6 +430,11 @@ export function TransferOrdersPanel(): React.ReactElement {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<AdminAgentFilter
id="transfer-agent-filter"
value={draft.agentNodeId}
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
/>
<div className="grid gap-1.5">
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
<Input
@@ -531,11 +545,7 @@ export function TransferOrdersPanel(): React.ReactElement {
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
{(loading && !data) || data ? (
<>
<div className="rounded-md border">
<Table id="wallet-transfer-orders-table" className="table-fixed">
@@ -543,6 +553,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead className="w-14">{t("direction")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
@@ -554,9 +565,11 @@ export function TransferOrdersPanel(): React.ReactElement {
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
{loading && !data ? (
<AdminTableLoadingRow colSpan={13} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-muted-foreground">
<TableCell colSpan={13} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -569,6 +582,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell>{row.direction}</TableCell>
<TableCell className="tabular-nums">
@@ -605,19 +619,21 @@ export function TransferOrdersPanel(): React.ReactElement {
</TableBody>
</Table>
</div>
<AdminListPaginationFooter
selectId="wallet-transfer-orders-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
{data ? (
<AdminListPaginationFooter
selectId="wallet-transfer-orders-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
@@ -629,6 +645,7 @@ export function TransferOrdersPanel(): React.ReactElement {
export function WalletTxnsPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const tRef = useTranslationRef(["wallet", "common"]);
const exportLabels = useExportLabels("walletTransactions");
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
@@ -660,21 +677,20 @@ export function WalletTxnsPanel(): React.ReactElement {
created_to: applied.createdTo.trim() || undefined,
biz_type: applied.bizType.trim() || undefined,
status: applied.statusCsv.trim() || undefined,
agent_node_id: applied.agentNodeId,
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, applied, t]);
}, [page, perPage, applied]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
useAsyncEffect(() => {
void load();
}, [page, perPage, applied]);
const runSearch = () => {
setApplied({ ...draft });
@@ -694,6 +710,11 @@ export function WalletTxnsPanel(): React.ReactElement {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<AdminAgentFilter
id="wallet-txn-agent-filter"
value={draft.agentNodeId}
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
/>
<div className="grid gap-1.5">
<Label htmlFor="tx-no">{t("txnNo")}</Label>
<Input
@@ -835,11 +856,7 @@ export function WalletTxnsPanel(): React.ReactElement {
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
{(loading && !data) || data ? (
<>
<div className="rounded-md border">
<Table id="wallet-transactions-table" className="table-fixed">
@@ -847,6 +864,7 @@ export function WalletTxnsPanel(): React.ReactElement {
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
@@ -856,9 +874,11 @@ export function WalletTxnsPanel(): React.ReactElement {
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
{loading && !data ? (
<AdminTableLoadingRow colSpan={11} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
<TableCell colSpan={11} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -871,6 +891,7 @@ export function WalletTxnsPanel(): React.ReactElement {
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
<TableCell className="tabular-nums text-xs">
@@ -891,19 +912,21 @@ export function WalletTxnsPanel(): React.ReactElement {
</TableBody>
</Table>
</div>
<AdminListPaginationFooter
selectId="wallet-transactions-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
{data ? (
<AdminListPaginationFooter
selectId="wallet-transactions-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
@@ -913,6 +936,7 @@ export function WalletTxnsPanel(): React.ReactElement {
export function PlayerWalletPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const tRef = useTranslationRef(["wallet", "common"]);
const exportLabels = useExportLabels("playerWallets");
useAdminCurrencyCatalog();
const [playerId, setPlayerId] = useState("");
@@ -923,7 +947,7 @@ export function PlayerWalletPanel(): React.ReactElement {
const query = useCallback(async () => {
const id = Number(playerId.trim());
if (Number.isNaN(id) || id < 1) {
setErr(t("invalidPlayerId"));
setErr(tRef.current("invalidPlayerId"));
setResult(null);
return;
}
@@ -933,12 +957,12 @@ export function PlayerWalletPanel(): React.ReactElement {
const d = await getAdminPlayerWallets(id);
setResult(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : t("queryFailed"));
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("queryFailed"));
setResult(null);
} finally {
setLoading(false);
}
}, [playerId, t]);
}, [playerId]);
return (
<Card>

View File

@@ -0,0 +1,90 @@
import type { AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
export type AdminAgentContext = {
id: number;
admin_site_id: number;
path: string;
code: string;
name: string;
depth: number;
};
export type AgentNodeRow = {
id: number;
admin_site_id: number;
parent_id: number | null;
path: string;
depth: number;
code: string;
name: string;
status: number;
is_root: boolean;
children?: AgentNodeRow[];
};
export type AgentTreeData = {
admin_site_id: number;
tree: AgentNodeRow[];
};
export type AgentNodeCreatePayload = {
parent_id: number;
code: string;
name: string;
status?: number;
};
export type AgentNodeUpdatePayload = {
name?: string;
status?: number;
};
export type AgentRoleListData = {
agent_node_id: number;
items: AdminRoleRow[];
};
export type AgentRoleCreatePayload = {
slug: string;
name: string;
description?: string | null;
status?: number;
permission_slugs?: string[];
};
export type AgentAdminUserListData = {
agent_node_id: number;
items: AdminUserPermissionRow[];
};
export type AgentAdminUserCreatePayload = {
username: string;
nickname: string;
email?: string | null;
password: string;
status?: number;
role_ids?: number[];
};
export type AgentAdminUserRoleSyncPayload = {
role_ids: number[];
};
export type AgentDelegationGrantRow = {
menu_action_id: number;
permission_code: string;
name: string;
can_delegate: boolean;
};
export type AgentDelegationGrantsData = {
child_agent_id: number;
grants: AgentDelegationGrantRow[];
};
export type AgentDelegationGrantSyncPayload = {
grants: Array<{
menu_action_id: number;
can_delegate?: boolean;
}>;
};

View File

@@ -14,6 +14,8 @@ export type AdminAuthLoginRequest = {
captcha_code: string;
};
import type { AdminAgentContext } from "@/types/api/admin-agent";
/** 登录成功后缓存于会话localStorage的管理员摘要 */
export type AdminProfile = {
id: number;
@@ -24,6 +26,11 @@ export type AdminProfile = {
permissions?: string[];
/** 当前管理员可见的后台菜单,由 Laravel 注册表统一下发。 */
navigation?: AdminNavItem[];
/** 代理账号绑定节点;超管为 null */
agent?: AdminAgentContext | null;
is_super_admin?: boolean;
/** 当前代理可下放给下级的 prd.* 上限(未配置 grants 时与操作权限一致) */
delegation_ceiling?: string[];
};
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */

View File

@@ -26,6 +26,15 @@ export type AdminDashboardAnalyticsPlayRow = {
approx_house_gross_minor: number;
};
export type AdminDashboardAnalyticsAgentRow = {
agent_node_id: number;
agent_code: string;
agent_name: string;
total_bet_minor: number;
total_payout_minor: number;
approx_house_gross_minor: number;
};
export type AdminDashboardAnalyticsChartMeta = {
chart_date_from: string;
chart_date_to: string;
@@ -45,6 +54,7 @@ export type AdminDashboardAnalyticsData = {
daily_series: AdminReportDailyProfitRow[];
chart_meta: AdminDashboardAnalyticsChartMeta;
play_breakdown: AdminDashboardAnalyticsPlayRow[];
agent_breakdown: AdminDashboardAnalyticsAgentRow[];
};
export type AdminDashboardAnalyticsQuery = {

View File

@@ -9,6 +9,9 @@ export type AdminPlayerWalletRow = {
export type AdminPlayerRow = {
id: number;
agent_node_id?: number | null;
agent_code?: string | null;
agent_name?: string | null;
site_code: string;
site_player_id: string;
username: string | null;

View File

@@ -7,6 +7,9 @@ export type AdminReportDailyProfitRow = {
export type AdminReportPlayerWinLossRow = {
player_id: number;
agent_node_id?: number | null;
agent_code?: string | null;
agent_name?: string | null;
username: string;
total_bet_minor: number;
total_payout_minor: number;
@@ -45,4 +48,5 @@ export type AdminReportQueryParams = {
date_to?: string;
player_id?: number;
play_code?: string;
agent_node_id?: number;
};

View File

@@ -62,6 +62,9 @@ export type AdminSettlementBatchShowData = {
export type AdminSettlementDetailRow = {
id: number;
ticket_item_id: number;
agent_node_id?: number | null;
agent_code?: string | null;
agent_name?: string | null;
ticket_no: string | null;
play_code: string | null;
currency_code: string | null;

View File

@@ -1,6 +1,9 @@
export type AdminTicketItemRow = {
id: number;
ticket_no: string;
agent_node_id?: number | null;
agent_code?: string | null;
agent_name?: string | null;
player_id: number;
site_code: string | null;
site_player_id: string | null;

View File

@@ -41,6 +41,10 @@ export type AdminRoleRow = {
status: number;
is_system: boolean;
sort_order: number;
scope_type?: string;
owner_agent_id?: number | null;
delegated_from_role_id?: number | null;
is_read_only_template?: boolean;
permission_slugs: string[];
user_count: number;
};

View File

@@ -2,6 +2,9 @@
export type AdminTransferOrderItem = {
id: number;
transfer_no: string;
agent_node_id?: number | null;
agent_code?: string | null;
agent_name?: string | null;
player_id: number;
site_code: string | null;
site_player_id: string | null;
@@ -34,6 +37,9 @@ export type AdminTransferOrderListData = {
export type AdminWalletTxnItem = {
id: number;
txn_no: string;
agent_node_id?: number | null;
agent_code?: string | null;
agent_name?: string | null;
player_id: number;
site_code: string | null;
site_player_id: string | null;