From b15e377187f5ad0b8a2b5ee0aac17a6d9eeedf91 Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 2 Jun 2026 14:37:08 +0800 Subject: [PATCH] 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. --- src/api/admin-agents.ts | 113 ++ src/api/admin-draws.ts | 1 + src/api/admin-player.ts | 1 + src/api/admin-settings.ts | 11 + src/api/admin-settlement.ts | 2 + src/api/admin-tickets.ts | 1 + src/api/admin-wallet.ts | 4 + src/app/admin/(shell)/agents/page.tsx | 18 + src/app/globals.css | 16 + src/components/admin/admin-agent-columns.tsx | 57 + src/components/admin/admin-agent-filter.tsx | 83 ++ src/components/admin/admin-breadcrumb.tsx | 17 +- src/components/admin/admin-loading-state.tsx | 94 ++ src/components/admin/admin-sidebar-nav.tsx | 256 ++++ src/components/admin/admin-sidebar.tsx | 148 +-- src/components/admin/login-form.tsx | 6 +- src/components/ui/loading-dots.tsx | 61 + src/hooks/use-admin-site-code-options.ts | 76 +- src/hooks/use-async-effect.ts | 21 + src/hooks/use-cached-play-type-options.ts | 43 + src/hooks/use-translation-ref.ts | 12 + src/i18n/index.ts | 8 +- src/i18n/locales/en/agents.json | 61 + src/i18n/locales/en/common.json | 16 +- src/i18n/locales/en/config.json | 17 + src/i18n/locales/en/dashboard.json | 3 + src/i18n/locales/en/reconcile.json | 12 + src/i18n/locales/en/reports.json | 112 +- src/i18n/locales/ne/agents.json | 61 + src/i18n/locales/ne/common.json | 16 +- src/i18n/locales/ne/dashboard.json | 3 + src/i18n/locales/ne/reconcile.json | 12 + src/i18n/locales/ne/reports.json | 112 +- src/i18n/locales/zh/agents.json | 61 + src/i18n/locales/zh/common.json | 16 +- src/i18n/locales/zh/config.json | 17 + src/i18n/locales/zh/dashboard.json | 3 + src/i18n/locales/zh/reconcile.json | 12 + src/i18n/locales/zh/reports.json | 112 +- src/lib/admin-agent-tree.ts | 23 + src/lib/admin-nav-groups.ts | 48 + src/lib/admin-nav-label.ts | 1 + src/lib/admin-play-types.ts | 8 + src/lib/admin-prd.ts | 17 + src/lib/admin-settlement-settings-cache.ts | 44 + src/lib/money.ts | 12 +- src/lib/page-metadata.ts | 2 + src/modules/_config/admin-nav-icons.tsx | 4 + src/modules/_config/admin-nav.ts | 14 +- .../admin-roles/admin-roles-console.tsx | 25 +- .../admin-users/admin-users-console.tsx | 25 +- src/modules/agents/agents-console.tsx | 1084 +++++++++++++++++ src/modules/audit/audit-logs-console.tsx | 28 +- .../config/doc/odds-config-doc-screen.tsx | 43 +- .../config/doc/play-config-doc-screen.tsx | 25 +- .../config/doc/rebate-config-doc-screen.tsx | 82 +- .../config/doc/risk-cap-doc-screen.tsx | 25 +- .../config/doc/wallet-config-doc-screen.tsx | 112 +- src/modules/config/risk-cap-runtime-panel.tsx | 40 +- .../config/use-odds-config-workspace.ts | 28 +- .../dashboard/dashboard-analytics-panel.tsx | 107 ++ src/modules/dashboard/dashboard-console.tsx | 85 +- src/modules/dashboard/dashboard-visuals.tsx | 157 ++- .../dashboard/use-dashboard-analytics.ts | 55 +- src/modules/draws/draw-detail-console.tsx | 23 +- src/modules/draws/draw-finance-console.tsx | 22 +- src/modules/draws/draw-publish-console.tsx | 23 +- src/modules/draws/draw-results-console.tsx | 23 +- src/modules/draws/draw-review-console.tsx | 23 +- src/modules/draws/draws-index-console.tsx | 38 +- .../integration/integration-sites-console.tsx | 20 +- src/modules/jackpot/jackpot-pools-console.tsx | 20 +- .../jackpot/jackpot-records-console.tsx | 62 +- src/modules/players/players-console.tsx | 42 +- src/modules/reconcile/reconcile-console.tsx | 365 ++++-- src/modules/reports/report-jobs-panel.tsx | 24 +- src/modules/reports/reports-console.tsx | 280 +++-- src/modules/risk/risk-draw-header.tsx | 20 +- src/modules/risk/risk-index-console.tsx | 28 +- src/modules/risk/risk-lock-logs-console.tsx | 25 +- src/modules/risk/risk-pool-detail-console.tsx | 20 +- src/modules/risk/risk-pools-console.tsx | 25 +- .../settings/admin-settings-data-context.tsx | 92 ++ .../components/settings-section-actions.tsx | 36 + .../settings/currency-settings-panel.tsx | 17 +- .../settings/hooks/use-settings-section.ts | 99 ++ .../panels/currency-format-settings-panel.tsx | 148 +++ .../settings/panels/draw-settings-panel.tsx | 234 ++++ .../panels/frontend-settings-panel.tsx | 138 +++ .../panels/settlement-settings-panel.tsx | 149 +++ src/modules/settings/settings-keys.ts | 38 + .../settings/system-settings-screen.tsx | 646 +--------- .../settlement-batch-details-console.tsx | 55 +- .../settlement/settlement-batches-console.tsx | 35 +- .../tickets/player-tickets-console.tsx | 72 +- src/modules/wallet/wallet-console.tsx | 140 ++- src/types/api/admin-agent.ts | 90 ++ src/types/api/admin-auth.ts | 7 + src/types/api/admin-dashboard-analytics.ts | 10 + src/types/api/admin-player.ts | 3 + src/types/api/admin-reports.ts | 4 + src/types/api/admin-settlement.ts | 3 + src/types/api/admin-tickets.ts | 3 + src/types/api/admin-user.ts | 4 + src/types/api/admin-wallet.ts | 6 + 105 files changed, 5305 insertions(+), 1596 deletions(-) create mode 100644 src/api/admin-agents.ts create mode 100644 src/app/admin/(shell)/agents/page.tsx create mode 100644 src/components/admin/admin-agent-columns.tsx create mode 100644 src/components/admin/admin-agent-filter.tsx create mode 100644 src/components/admin/admin-loading-state.tsx create mode 100644 src/components/admin/admin-sidebar-nav.tsx create mode 100644 src/components/ui/loading-dots.tsx create mode 100644 src/hooks/use-async-effect.ts create mode 100644 src/hooks/use-cached-play-type-options.ts create mode 100644 src/hooks/use-translation-ref.ts create mode 100644 src/i18n/locales/en/agents.json create mode 100644 src/i18n/locales/ne/agents.json create mode 100644 src/i18n/locales/zh/agents.json create mode 100644 src/lib/admin-agent-tree.ts create mode 100644 src/lib/admin-nav-groups.ts create mode 100644 src/lib/admin-settlement-settings-cache.ts create mode 100644 src/modules/agents/agents-console.tsx create mode 100644 src/modules/settings/admin-settings-data-context.tsx create mode 100644 src/modules/settings/components/settings-section-actions.tsx create mode 100644 src/modules/settings/hooks/use-settings-section.ts create mode 100644 src/modules/settings/panels/currency-format-settings-panel.tsx create mode 100644 src/modules/settings/panels/draw-settings-panel.tsx create mode 100644 src/modules/settings/panels/frontend-settings-panel.tsx create mode 100644 src/modules/settings/panels/settlement-settings-panel.tsx create mode 100644 src/modules/settings/settings-keys.ts create mode 100644 src/types/api/admin-agent.ts diff --git a/src/api/admin-agents.ts b/src/api/admin-agents.ts new file mode 100644 index 0000000..72aede4 --- /dev/null +++ b/src/api/admin-agents.ts @@ -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 { + return adminRequest.get(`${A}/agent-nodes/tree`, { + params: adminSiteId ? { admin_site_id: adminSiteId } : undefined, + }); +} + +export async function postAgentNode(body: AgentNodeCreatePayload): Promise { + return adminRequest.post(`${A}/agent-nodes`, body); +} + +export async function putAgentNode( + agentNodeId: number, + body: AgentNodeUpdatePayload, +): Promise { + return adminRequest.put(`${A}/agent-nodes/${agentNodeId}`, body); +} + +export async function deleteAgentNode(agentNodeId: number): Promise { + return adminRequest.delete(`${A}/agent-nodes/${agentNodeId}`); +} + +export async function getAgentNodeRoles(agentNodeId: number): Promise { + return adminRequest.get(`${A}/agent-nodes/${agentNodeId}/roles`); +} + +export async function postAgentRole( + agentNodeId: number, + body: AgentRoleCreatePayload, +): Promise { + return adminRequest.post(`${A}/agent-nodes/${agentNodeId}/roles`, body); +} + +export async function putAgentRole( + roleId: number, + body: { name?: string; description?: string | null; status?: number }, +): Promise { + return adminRequest.put(`${A}/agent-roles/${roleId}`, body); +} + +export async function putAgentRolePermissions( + roleId: number, + permissionSlugs: string[], +): Promise { + return adminRequest.put(`${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 { + return adminRequest.get(`${A}/agent-nodes/${agentNodeId}/admin-users`); +} + +export async function postAgentAdminUser( + agentNodeId: number, + body: AgentAdminUserCreatePayload, +): Promise { + return adminRequest.post( + `${A}/agent-nodes/${agentNodeId}/admin-users`, + body, + ); +} + +export async function putAgentAdminUserRoles( + adminUserId: number, + body: AgentAdminUserRoleSyncPayload, +): Promise { + return adminRequest.put( + `${A}/agent-admin-users/${adminUserId}/roles`, + body, + ); +} + +export async function getAgentDelegationGrants( + agentNodeId: number, +): Promise { + return adminRequest.get( + `${A}/agent-nodes/${agentNodeId}/delegation-grants`, + ); +} + +export async function putAgentDelegationGrants( + agentNodeId: number, + body: AgentDelegationGrantSyncPayload, +): Promise { + return adminRequest.put( + `${A}/agent-nodes/${agentNodeId}/delegation-grants`, + body, + ); +} diff --git a/src/api/admin-draws.ts b/src/api/admin-draws.ts index be94763..2acb434 100644 --- a/src/api/admin-draws.ts +++ b/src/api/admin-draws.ts @@ -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 { diff --git a/src/api/admin-player.ts b/src/api/admin-player.ts index ab63791..da06c9d 100644 --- a/src/api/admin-player.ts +++ b/src/api/admin-player.ts @@ -17,6 +17,7 @@ export async function getAdminPlayers(params?: { keyword?: string; status?: number; site_code?: string; + agent_node_id?: number; }): Promise { return adminRequest.get(`${A}/players`, { params }); } diff --git a/src/api/admin-settings.ts b/src/api/admin-settings.ts index 36f9ccd..b3e73d0 100644 --- a/src/api/admin-settings.ts +++ b/src/api/admin-settings.ts @@ -27,3 +27,14 @@ export async function updateAdminSetting( ): Promise { return adminRequest.put(`${A}/settings/${key}`, { value }); } + +export type AdminSettingBatchItem = { + key: string; + value: unknown; +}; + +export async function updateAdminSettingsBatch( + items: AdminSettingBatchItem[], +): Promise { + return adminRequest.put(`${A}/settings/batch`, { items }); +} diff --git a/src/api/admin-settlement.ts b/src/api/admin-settlement.ts index 8afab8d..0daeaa5 100644 --- a/src/api/admin-settlement.ts +++ b/src/api/admin-settlement.ts @@ -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 + + + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 5a11a1c..29d4258 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} + diff --git a/src/components/admin/admin-agent-columns.tsx b/src/components/admin/admin-agent-columns.tsx new file mode 100644 index 0000000..9a7df2d --- /dev/null +++ b/src/components/admin/admin-agent-columns.tsx @@ -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 ( + + {t("agentColumns.agent")} + + ); +} + +export function AdminAgentCell({ row, className }: CellProps): React.ReactElement { + return ( + + {cellText(row.agent_name)} + {row.agent_code ? ( + {row.agent_code} + ) : null} + + ); +} + +export function AdminAgentIdentityHeads({ className }: { className?: string }): React.ReactElement { + return ; +} + +export function AdminAgentIdentityCells({ row, className }: CellProps): React.ReactElement { + return ; +} diff --git a/src/components/admin/admin-agent-filter.tsx b/src/components/admin/admin-agent-filter.tsx new file mode 100644 index 0000000..59d0fca --- /dev/null +++ b/src/components/admin/admin-agent-filter.tsx @@ -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([]); + 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 ( +
+ + +
+ ); +} diff --git a/src/components/admin/admin-breadcrumb.tsx b/src/components/admin/admin-breadcrumb.tsx index 13a2d4e..43e0af8 100644 --- a/src/components/admin/admin-breadcrumb.tsx +++ b/src/components/admin/admin-breadcrumb.tsx @@ -35,6 +35,10 @@ const SETTINGS_ROUTE_LABELS: Record = { currencies: "currencies.title", }; +const TOP_ROUTE_LABELS: Record = { + agents: "agents.title", +}; + const CONFIG_ROUTE_LABELS: Record = { "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, }); diff --git a/src/components/admin/admin-loading-state.tsx b/src/components/admin/admin-loading-state.tsx new file mode 100644 index 0000000..4380c7e --- /dev/null +++ b/src/components/admin/admin-loading-state.tsx @@ -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 ( +
+ +
+ ); +} + +/** 下拉、弹层等紧凑区域 */ +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 ( +
+ +
+ ); +} + +/** 表格内加载行(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 ( + + +
+ +
+
+
+ ); +} diff --git a/src/components/admin/admin-sidebar-nav.tsx b/src/components/admin/admin-sidebar-nav.tsx new file mode 100644 index 0000000..d0bb3b6 --- /dev/null +++ b/src/components/admin/admin-sidebar-nav.tsx @@ -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 { + const open = Object.fromEntries( + ADMIN_NAV_GROUP_ORDER.map((g) => [g, true]), + ) as Record; + + 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 ( + + } + className={cn(NAV_BTN, NAV_ACTIVE)} + > + + {label} + + + ); +} + +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 ( + + } + className={cn(SUB_NAV, NAV_ACTIVE)} + > + {label} + + + ); +} + +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 ( + + + + {groupLabel} + + + {open ? ( + + {items.map((item) => ( + + ))} + + ) : null} + + ); +} + +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>(() => + 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 ( + + {overview?.items.map((item) => ( + + ))} + {collapsible.map(({ group, items: groupItems }) => ( + + setOpenGroups((prev) => ({ + ...prev, + [group]: !(prev[group] ?? true), + })) + } + t={t} + /> + ))} + + ); +} + +export function AdminSidebarNavSkeleton(): ReactElement { + const { t } = useTranslation("common"); + const widths = ["68%", "74%", "58%", "70%", "62%"] as const; + + return ( + + +
+ + +
+
+ {widths.map((width, i) => ( + +
+ + + +
+
+ ))} + {t("auth.checking")} +
+ ); +} diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 617509a..b9baaba 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -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 ( - -
- - -
-
- ); -} - function AdminSidebarSkeleton(): ReactElement { - const { t } = useTranslation("common"); - return ( - - + + -
+
N lotto
- +
-
+
- - - {t("sidebar.workspace", { defaultValue: "Workspace" })} - - - - {SIDEBAR_NAV_SKELETON_WIDTHS.map((width, i) => ( - - ))} - - - +
+ +
- - {t("auth.checking")} - ); } -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 ; - } const visibleNav = useMemo( () => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"), [profile?.navigation], ); + if (shellAuthPending) { + return ; + } + return ( - - + + } - 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" > -
+
N lotto
+ {profile?.agent ? ( +

+ {profile.agent.name} + · {profile.agent.code} +

+ ) : null} - +
-
+
- - {t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })} - - - {visibleNav.map((item) => { - const Icon = resolveAdminNavIcon(item.segment); - return ( - - } - 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" - > - - {adminNavLabel(item.segment, t, item.label)} - - - ); - })} - - - +
+ +
diff --git a/src/components/admin/login-form.tsx b/src/components/admin/login-form.tsx index a507405..44a9dd5 100644 --- a/src/components/admin/login-form.tsx +++ b/src/components/admin/login-form.tsx @@ -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; diff --git a/src/components/ui/loading-dots.tsx b/src/components/ui/loading-dots.tsx new file mode 100644 index 0000000..d42e559 --- /dev/null +++ b/src/components/ui/loading-dots.tsx @@ -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 ( + + {[0, 1, 2].map((index) => ( + + ))} + {label ? ( + {label} + ) : null} + + ); +} diff --git a/src/hooks/use-admin-site-code-options.ts b/src/hooks/use-admin-site-code-options.ts index cdf166e..c22a7e6 100644 --- a/src/hooks/use-admin-site-code-options.ts +++ b/src/hooks/use-admin-site-code-options.ts @@ -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 | null = null; + +export function clearCachedAdminSiteCodeOptions(): void { + cachedSites = null; + inflightSites = null; +} + +async function fetchSiteCodeOptions(): Promise { + 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([]); - const [loading, setLoading] = useState(false); + const [sites, setSites] = useState(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, diff --git a/src/hooks/use-async-effect.ts b/src/hooks/use-async-effect.ts new file mode 100644 index 0000000..97012f2 --- /dev/null +++ b/src/hooks/use-async-effect.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect, useRef, type DependencyList } from "react"; + +/** + * 在依赖变化时执行异步副作用;factory 始终用最新闭包,但不必把 `t` 等不稳定引用放进 deps。 + */ +export function useAsyncEffect( + factory: () => void | Promise, + 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); +} diff --git a/src/hooks/use-cached-play-type-options.ts b/src/hooks/use-cached-play-type-options.ts new file mode 100644 index 0000000..8421cde --- /dev/null +++ b/src/hooks/use-cached-play-type-options.ts @@ -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([]); + + 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; +} diff --git a/src/hooks/use-translation-ref.ts b/src/hooks/use-translation-ref.ts new file mode 100644 index 0000000..b67be64 --- /dev/null +++ b/src/hooks/use-translation-ref.ts @@ -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) { + const { t } = useTranslation(ns, options); + const tRef = useRef(t); + tRef.current = t; + return tRef; +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 186166b..c70d2e7 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -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>>; diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json new file mode 100644 index 0000000..18b37f3 --- /dev/null +++ b/src/i18n/locales/en/agents.json @@ -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}}" + } +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f1aeb5e..701f812 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -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…", diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index a770516..b4a6797 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -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." }, diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json index 35127ca..9b4d91e 100644 --- a/src/i18n/locales/en/dashboard.json +++ b/src/i18n/locales/en/dashboard.json @@ -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", diff --git a/src/i18n/locales/en/reconcile.json b/src/i18n/locales/en/reconcile.json index 97075c4..6987b74 100644 --- a/src/i18n/locales/en/reconcile.json +++ b/src/i18n/locales/en/reconcile.json @@ -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", diff --git a/src/i18n/locales/en/reports.json b/src/i18n/locales/en/reports.json index 6c1da86..ad3cefa 100644 --- a/src/i18n/locales/en/reports.json +++ b/src/i18n/locales/en/reports.json @@ -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", diff --git a/src/i18n/locales/ne/agents.json b/src/i18n/locales/ne/agents.json new file mode 100644 index 0000000..addfe2e --- /dev/null +++ b/src/i18n/locales/ne/agents.json @@ -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}}" + } +} diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json index 201e25b..232782d 100644 --- a/src/i18n/locales/ne/common.json +++ b/src/i18n/locales/ne/common.json @@ -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": "लगइन स्थिति जाँच हुँदैछ…", diff --git a/src/i18n/locales/ne/dashboard.json b/src/i18n/locales/ne/dashboard.json index 8bb2d84..65326ef 100644 --- a/src/i18n/locales/ne/dashboard.json +++ b/src/i18n/locales/ne/dashboard.json @@ -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": "साइटव्यापी अहिले बेट छैन", diff --git a/src/i18n/locales/ne/reconcile.json b/src/i18n/locales/ne/reconcile.json index 05bfb47..dc28da4 100644 --- a/src/i18n/locales/ne/reconcile.json +++ b/src/i18n/locales/ne/reconcile.json @@ -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 बाट खोज्नुहोस्", diff --git a/src/i18n/locales/ne/reports.json b/src/i18n/locales/ne/reports.json index 82f1473..4bd8fac 100644 --- a/src/i18n/locales/ne/reports.json +++ b/src/i18n/locales/ne/reports.json @@ -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": "खेलाडी जित/हार रिपोर्ट", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json new file mode 100644 index 0000000..1956793 --- /dev/null +++ b/src/i18n/locales/zh/agents.json @@ -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}} 的角色" + } +} diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 0836e05..6fcc900 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -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": "正在校验登录状态…", diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index feac2d9..bfe0a4e 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -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,不影响开奖与结算逻辑。" }, diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json index 2f9483f..f328e5d 100644 --- a/src/i18n/locales/zh/dashboard.json +++ b/src/i18n/locales/zh/dashboard.json @@ -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": "全站暂无投注", diff --git a/src/i18n/locales/zh/reconcile.json b/src/i18n/locales/zh/reconcile.json index f86cad0..3260f12 100644 --- a/src/i18n/locales/zh/reconcile.json +++ b/src/i18n/locales/zh/reconcile.json @@ -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 / 用户名 / 昵称搜索", diff --git a/src/i18n/locales/zh/reports.json b/src/i18n/locales/zh/reports.json index 1158b0b..d721c6d 100644 --- a/src/i18n/locales/zh/reports.json +++ b/src/i18n/locales/zh/reports.json @@ -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": "玩家输赢报表", diff --git a/src/lib/admin-agent-tree.ts b/src/lib/admin-agent-tree.ts new file mode 100644 index 0000000..54e6929 --- /dev/null +++ b/src/lib/admin-agent-tree.ts @@ -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; +} diff --git a/src/lib/admin-nav-groups.ts b/src/lib/admin-nav-groups.ts new file mode 100644 index 0000000..a64f3be --- /dev/null +++ b/src/lib/admin-nav-groups.ts @@ -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 = { + 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(); + + 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)!, + })); +} diff --git a/src/lib/admin-nav-label.ts b/src/lib/admin-nav-label.ts index 71feda1..7558640 100644 --- a/src/lib/admin-nav-label.ts +++ b/src/lib/admin-nav-label.ts @@ -21,6 +21,7 @@ const NAV_SEGMENT_I18N_KEYS: Record = { audit: "audit", settings: "settings", integration: "integration", + agents: "agents", config: "config", }; diff --git a/src/lib/admin-play-types.ts b/src/lib/admin-play-types.ts index a622988..4a9dc3e 100644 --- a/src/lib/admin-play-types.ts +++ b/src/lib/admin-play-types.ts @@ -44,6 +44,14 @@ export function getAdminPlayTypesLoadPromise( return inflightLoad; } +/** 确保玩法目录已加载并返回缓存列表(全局去重,配置页勿直接 getAdminPlayTypes) */ +export async function ensureAdminPlayTypesLoaded( + loader: () => Promise<{ items: AdminPlayTypeRow[] }>, +): Promise { + await getAdminPlayTypesLoadPromise(loader); + return getCachedAdminPlayTypes(); +} + /** 解析玩法显示名;无配置时回退 play_code */ export function resolveAdminPlayTypeDisplayName( playCode: string | null | undefined, diff --git a/src/lib/admin-prd.ts b/src/lib/admin-prd.ts index cf45841..98a9639 100644 --- a/src/lib/admin-prd.ts +++ b/src/lib/admin-prd.ts @@ -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; diff --git a/src/lib/admin-settlement-settings-cache.ts b/src/lib/admin-settlement-settings-cache.ts new file mode 100644 index 0000000..45c140a --- /dev/null +++ b/src/lib/admin-settlement-settings-cache.ts @@ -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 | 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 { + 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; +} diff --git a/src/lib/money.ts b/src/lib/money.ts index 16621d5..c5ae68a 100644 --- a/src/lib/money.ts +++ b/src/lib/money.ts @@ -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, diff --git a/src/lib/page-metadata.ts b/src/lib/page-metadata.ts index 5b8ba82..ba69020 100644 --- a/src/lib/page-metadata.ts +++ b/src/lib/page-metadata.ts @@ -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> = { dashboard: enDashboard, @@ -33,6 +34,7 @@ const EN_FLAT: Record> = { config: enConfig, common: enCommon, auth: enAuth, + agents: enAgents, }; function getByPath(obj: Record, path: string): string | undefined { diff --git a/src/modules/_config/admin-nav-icons.tsx b/src/modules/_config/admin-nav-icons.tsx index 4c8eaa8..7db1ae9 100644 --- a/src/modules/_config/admin-nav-icons.tsx +++ b/src/modules/_config/admin-nav-icons.tsx @@ -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 = { dashboard: LayoutDashboard, + agents: Network, players: Users, draws: CalendarClock, rules_plays: SlidersHorizontal, @@ -39,6 +42,7 @@ export const adminNavIconBySegment: Record admin_users: ShieldCheck, admin_roles: ShieldCheck, currencies: CircleDollarSign, + integration: Globe, settings: Settings, }; diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 03f2ee4..8187394 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -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[]; }; diff --git a/src/modules/admin-roles/admin-roles-console.tsx b/src/modules/admin-roles/admin-roles-console.tsx index 3d0b6a7..835bb29 100644 --- a/src/modules/admin-roles/admin-roles-console.tsx +++ b/src/modules/admin-roles/admin-roles-console.tsx @@ -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 { {err ?

{err}

: null} - {loading && roles.length === 0 ? ( -

{t("states.loading", { ns: "common" })}

- ) : null}
@@ -347,7 +346,9 @@ export function AdminRolesConsole(): React.ReactElement { - {roles.length === 0 ? ( + {loading && roles.length === 0 ? ( + + ) : roles.length === 0 ? ( {t("states.noData", { ns: "common" })} diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx index 3e689fa..03c3694 100644 --- a/src/modules/admin-users/admin-users-console.tsx +++ b/src/modules/admin-users/admin-users-console.tsx @@ -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 { {err ?

{err}

: null} - {loading && items.length === 0 ? ( -

{t("states.loading", { ns: "common" })}

- ) : null}
@@ -377,7 +376,9 @@ export function AdminUsersConsole(): React.ReactElement { - {items.length === 0 ? ( + {loading && items.length === 0 ? ( + + ) : items.length === 0 ? ( {t("states.noData", { ns: "common" })} diff --git a/src/modules/agents/agents-console.tsx b/src/modules/agents/agents-console.tsx new file mode 100644 index 0000000..88f93ec --- /dev/null +++ b/src/modules/agents/agents-console.tsx @@ -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; + onToggleExpand: (nodeId: number) => void; + onSelect: (node: AgentNodeRow) => void; +}): React.ReactElement { + return ( +
    + {nodes.map((node) => ( +
  • +
    + {node.children && node.children.length > 0 ? ( + + ) : ( + + )} + +
    + {node.children && node.children.length > 0 && expandedIds.has(node.id) ? ( + + ) : null} +
  • + ))} +
+ ); +} + +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(null); + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [treeKeyword, setTreeKeyword] = useState(""); + const [expandedNodeIds, setExpandedNodeIds] = useState>(new Set()); + + const [roles, setRoles] = useState([]); + const [users, setUsers] = useState([]); + const [catalog, setCatalog] = useState(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([]); + const [roleSaving, setRoleSaving] = useState(false); + + const [permDialogOpen, setPermDialogOpen] = useState(false); + const [permRoleId, setPermRoleId] = useState(null); + const [draftPerms, setDraftPerms] = useState([]); + 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([]); + const [userSaving, setUserSaving] = useState(false); + + const [delegationGrants, setDelegationGrants] = useState([]); + 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 ; + } + + return ( +
+ +
+

{t("title")}

+ {isSuperAdmin && siteOptions.length > 0 ? ( + + ) : null} +
+ + {err ?

{err}

: null} + +
+ +
+
+ + setTreeKeyword(e.target.value)} + className="pl-8" + placeholder={t("treeSearch", { defaultValue: "搜索代理编码/名称" })} + /> +
+
+ + +
+
+ + { + 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)} + /> + +
+ + + {!selected ? ( +

{t("selectNode")}

+ ) : ( + +
+
+

{t("status")}

+
+ + {selected.status === 1 + ? t("common:status.enabled", { defaultValue: "Enabled" }) + : t("common:status.disabled", { defaultValue: "Disabled" })} + + {selected.is_root ? ( + {t("isRoot", { defaultValue: "Root" })} + ) : null} +
+
+
+

{t("childrenCount", { defaultValue: "直属下级" })}

+

{selectedChildrenCount}

+
+
+

{t("descendantsCount", { defaultValue: "全部下级" })}

+

{selectedDescendantCount}

+
+
+

{t("nodeCode", { defaultValue: "节点编码" })}

+

{selected.code}

+
+
+ +
+ + {t("tabs.overview")} + {canViewRoles ? {t("tabs.roles")} : null} + {canViewUsers ? {t("tabs.users")} : null} + {canManageDelegation ? ( + {t("tabs.delegation")} + ) : null} + + {canManageNode && !selected.is_root ? ( + + ) : null} + {canManageNode ? ( + + ) : null} +
+ + +
+
+

+ {t("code")}: {selected.code} +

+

+ {t("depth")}: {selected.depth} +

+

+ {t("path")}:{" "} + {selected.path} +

+
+
+

{t("quickActions", { defaultValue: "常用操作" })}

+ {canManageNode ? ( + + ) : null} + {canManageNode && !selected.is_root ? ( + + ) : null} + {canManageNode && !selected.is_root ? ( + + ) : null} + {canViewRoles ? ( + + ) : null} + {canViewUsers ? ( + + ) : null} +
+
+
+ + {canViewRoles ? ( + +
+ {canManageRoles ? ( + + ) : null} +
+
+ + + {t("roles.slug")} + {t("name")} + {t("roles.userCount")} + + + + + {roles.map((role) => ( + + {role.slug} + {role.name} + {role.user_count} + + {canManageRoles && !role.is_read_only_template ? ( + { + 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 ? ( + + {t("roles.readOnlyTemplate")} + + ) : null} + + + ))} + +
+ + ) : null} + + {canViewUsers ? ( + +
+ {canManageUsers ? ( + + ) : null} +
+ + + + {t("users.username")} + {t("name")} + {t("users.roles")} + + + + {users.map((user) => ( + + {user.username} + {user.nickname} + {user.roles.join(", ") || "—"} + + ))} + +
+
+ ) : null} + + {canManageDelegation ? ( + +

{t("delegation.hint")}

+ {delegationGrants.length === 0 ? ( +

{t("delegation.empty")}

+ ) : ( + + + + {t("delegation.permission")} + {t("delegation.canDelegate")} + + + + {delegationGrants.map((grant) => ( + + +
{grant.name}
+
+ {grant.permission_code} +
+
+ + { + setDelegationGrants((prev) => + prev.map((row) => + row.menu_action_id === grant.menu_action_id + ? { ...row, can_delegate: checked === true } + : row, + ), + ); + }} + /> + +
+ ))} +
+
+ )} +
+ +
+
+ ) : null} + + )} + +
+ + + + + + {nodeDialogMode === "create" ? t("createChild") : t("editNode")} + + + {nodeDialogMode === "create" ? ( +
+ + setNodeCode(e.target.value)} + autoComplete="off" + /> +
+ ) : null} +
+ + setNodeName(e.target.value)} + /> +
+
+ setNodeStatus(v ? 1 : 0)} /> + +
+ + + + +
+
+ + + + + {t("roles.create")} + +
+ + setRoleSlug(e.target.value)} /> +
+
+ + setRoleName(e.target.value)} /> +
+

{t("roles.permissionSubsetHint")}

+
+ {assignablePermissionSlugs.map((slug) => ( + + ))} +
+ + + + +
+
+ + + + + {t("roles.permissions")} + +
+ {assignablePermissionSlugs.map((slug) => ( + + ))} +
+ + + + +
+
+ + + + + {t("users.create")} + +
+ + setUserUsername(e.target.value)} /> +
+
+ + setUserNickname(e.target.value)} /> +
+
+ + setUserPassword(e.target.value)} + /> +
+ {roles.length > 0 ? ( +
+ +
+ {roles.map((role) => ( + + ))} +
+
+ ) : null} + + + + +
+
+
+ ); +} diff --git a/src/modules/audit/audit-logs-console.tsx b/src/modules/audit/audit-logs-console.tsx index 2509f29..54d0c54 100644 --- a/src/modules/audit/audit-logs-console.tsx +++ b/src/modules/audit/audit-logs-console.tsx @@ -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(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 {
{err ?

{err}

: null} - {loading && !data ? ( -

{t("states.loading", { ns: "common" })}

- ) : null} - - {data ? ( + {(loading && !data) || data ? ( <>
@@ -219,7 +217,9 @@ export function AuditLogsConsole(): React.ReactElement { - {data.items.length === 0 ? ( + {loading && !data ? ( + + ) : !data || data.items.length === 0 ? ( {t("empty")} diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx index 96a4e26..9e6581e 100644 --- a/src/modules/config/doc/odds-config-doc-screen.tsx +++ b/src/modules/config/doc/odds-config-doc-screen.tsx @@ -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 ?

{resolvedError}

: null} {resolvedLoadingDetail || resolvedLoadingTypes ? ( -

- {t("odds.loadingDetails", { ns: "config" })} -

+ ) : resolvedPlayCode ? (
diff --git a/src/modules/config/doc/play-config-doc-screen.tsx b/src/modules/config/doc/play-config-doc-screen.tsx index e033152..4b3c07e 100644 --- a/src/modules/config/doc/play-config-doc-screen.tsx +++ b/src/modules/config/doc/play-config-doc-screen.tsx @@ -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 ?

{error}

: null} {loadingDetail ? ( -

{t("states.loading", { ns: "common" })}

+ ) : (
diff --git a/src/modules/config/doc/rebate-config-doc-screen.tsx b/src/modules/config/doc/rebate-config-doc-screen.tsx index 70ce689..c5a3ef2 100644 --- a/src/modules/config/doc/rebate-config-doc-screen.tsx +++ b/src/modules/config/doc/rebate-config-doc-screen.tsx @@ -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 ? ( -

{t("states.loading", { ns: "common" })}

+ ) : null} ); diff --git a/src/modules/config/doc/risk-cap-doc-screen.tsx b/src/modules/config/doc/risk-cap-doc-screen.tsx index 86af5d5..a011a3c 100644 --- a/src/modules/config/doc/risk-cap-doc-screen.tsx +++ b/src/modules/config/doc/risk-cap-doc-screen.tsx @@ -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 ? ( -

{t("riskCap.loadingDetails", { ns: "config" })}

+ ) : specialRows.length === 0 ? (

{t("riskCap.noDetailRows", { ns: "config" })}

) : ( diff --git a/src/modules/config/doc/wallet-config-doc-screen.tsx b/src/modules/config/doc/wallet-config-doc-screen.tsx index 7957998..16d0c0d 100644 --- a/src/modules/config/doc/wallet-config-doc-screen.tsx +++ b/src/modules/config/doc/wallet-config-doc-screen.tsx @@ -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): 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({ inMin: "", @@ -57,55 +59,81 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree outMax: "", }); const [saved, setSaved] = useState({ 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 = {}; 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 = {}; + 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" })} {dirty && ( - )} diff --git a/src/modules/config/risk-cap-runtime-panel.tsx b/src/modules/config/risk-cap-runtime-panel.tsx index 8f259cd..6c4d5f8 100644 --- a/src/modules/config/risk-cap-runtime-panel.tsx +++ b/src/modules/config/risk-cap-runtime-panel.tsx @@ -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([]); const [drawsLoading, setDrawsLoading] = useState(true); const [drawId, setDrawId] = useState(""); @@ -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() {
{poolsLoading ? ( - - - {t("states.loading", { ns: "common" })} - - + ) : pools.length === 0 ? ( diff --git a/src/modules/config/use-odds-config-workspace.ts b/src/modules/config/use-odds-config-workspace.ts index 7648a0a..da99698 100644 --- a/src/modules/config/use-odds-config-workspace.ts +++ b/src/modules/config/use-odds-config-workspace.ts @@ -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([]); const [list, setList] = useState([]); const [detail, setDetail] = useState(null); @@ -52,6 +53,7 @@ export function useOddsConfigWorkspace( const [loadingDetail, setLoadingDetail] = useState(false); const [error, setError] = useState(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(() => { diff --git a/src/modules/dashboard/dashboard-analytics-panel.tsx b/src/modules/dashboard/dashboard-analytics-panel.tsx index 6b6e00a..e714d2c 100644 --- a/src/modules/dashboard/dashboard-analytics-panel.tsx +++ b/src/modules/dashboard/dashboard-analytics-panel.tsx @@ -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 ( + + + {t("analytics.agentRanking")} +

+ {t(`analytics.rankingMetrics.${rankingMetric}`)} +

+
+ + {loading ? ( + + ) : topAgentRows.length > 0 ? ( +
+ {topAgentRows.map((row, idx) => { + const v = metricValue(row); + const pct = (Math.abs(v) / maxAbs) * 100; + const color = barColor(row); + return ( +
+
+
+ + #{idx + 1} + +
+

{row.agent_name || "-"}

+

{row.agent_code || ""}

+
+
+
+ {formatRowValue(row)} +
+
+ +
+
+
+
+ ); + })} +
+ ) : ( +

{t("analytics.noAgentData")}

+ )} + + + ); +} + /** 单列堆叠布局(兼容旧用法) */ export function DashboardAnalyticsPanel({ enabled, diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx index 07cf29d..62e5e8d 100644 --- a/src/modules/dashboard/dashboard-console.tsx +++ b/src/modules/dashboard/dashboard-console.tsx @@ -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([]); const [abnormalTransferTotal, setAbnormalTransferTotal] = useState(null); const [hotTab, setHotTab] = useState("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={} accent={ @@ -542,6 +534,7 @@ export function DashboardConsole(): ReactElement { {showAnalytics ? (
@@ -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 ; @@ -1049,29 +1106,7 @@ export function PlatformLifetimePayoutSnapshot({ return (
-
- {cells.map((cell) => ( -
-

{cell.label}

-

- {formatMoney(cell.amount, currency)} -

-
- ))} -
+ {!hasPayout ? (

{t("platformNoPayoutYet")} diff --git a/src/modules/dashboard/use-dashboard-analytics.ts b/src/modules/dashboard/use-dashboard-analytics.ts index 3ea9018..355228b 100644 --- a/src/modules/dashboard/use-dashboard-analytics.ts +++ b/src/modules/dashboard/use-dashboard-analytics.ts @@ -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("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, diff --git a/src/modules/draws/draw-detail-console.tsx b/src/modules/draws/draw-detail-console.tsx index 37a597f..51c4031 100644 --- a/src/modules/draws/draw-detail-console.tsx +++ b/src/modules/draws/draw-detail-console.tsx @@ -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): Promise { 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

{t("states.loading", { ns: "common" })}

; + return ; } if (error || !data) { diff --git a/src/modules/draws/draw-finance-console.tsx b/src/modules/draws/draw-finance-console.tsx index 14af6df..095c996 100644 --- a/src/modules/draws/draw-finance-console.tsx +++ b/src/modules/draws/draw-finance-console.tsx @@ -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 { 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

{t("states.loading", { ns: "common" })}

; + return ; } if (err || !data) { diff --git a/src/modules/draws/draw-publish-console.tsx b/src/modules/draws/draw-publish-console.tsx index 84a944d..c8ba78e 100644 --- a/src/modules/draws/draw-publish-console.tsx +++ b/src/modules/draws/draw-publish-console.tsx @@ -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

{t("states.loading", { ns: "common" })}

; + return ; } if (error || !data) { diff --git a/src/modules/draws/draw-results-console.tsx b/src/modules/draws/draw-results-console.tsx index 95af0c5..1d39c56 100644 --- a/src/modules/draws/draw-results-console.tsx +++ b/src/modules/draws/draw-results-console.tsx @@ -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

{t("states.loading", { ns: "common" })}

; + return ; } if (error || !data) { diff --git a/src/modules/draws/draw-review-console.tsx b/src/modules/draws/draw-review-console.tsx index 7df3a2a..f169921 100644 --- a/src/modules/draws/draw-review-console.tsx +++ b/src/modules/draws/draw-review-console.tsx @@ -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

{t("states.loading", { ns: "common" })}

; + return ; } if (error || !data) { diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index 20b175e..c24251a 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -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(undefined); + const [appliedAgentNodeId, setAppliedAgentNodeId] = useState(undefined); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(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 { 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() {
+
) : null} +
{t("table.id", { ns: "common" })} {t("site")} + {t("sitePlayerId")} {t("username")} {t("nickname")} @@ -398,9 +411,11 @@ export function PlayersConsole(): React.ReactElement { - {items.length === 0 && !loading ? ( + {loading && items.length === 0 ? ( + + ) : items.length === 0 ? ( - + {t("states.noData", { ns: "common" })} @@ -413,6 +428,7 @@ export function PlayersConsole(): React.ReactElement { {row.site_code} + {row.site_player_id} diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx index 6fc557c..83c3074 100644 --- a/src/modules/reconcile/reconcile-console.tsx +++ b/src/modules/reconcile/reconcile-console.tsx @@ -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 | null | undefined, key: string): number { + const raw = summary?.[key]; + return typeof raw === "number" && Number.isFinite(raw) ? raw : 0; +} + +function renderPeriodRange( + row: Pick, + 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 (
@@ -225,28 +243,157 @@ export function ReconcileConsole(): React.ReactElement { {t("createTitle")} + {t("createDesc")} -
-
- - +
+
+
+
+ +
+
+
{t("scopeTitle")}
+

{t("scopeDescription")}

+
+
+
+
+ + +

{t("reconcileTypeHint")}

+
+
+ { + setDateFrom(from); + setDateTo(to); + }} + /> +

{t("dateRangeHint")}

+
+
-
- { - setDateFrom(from); - setDateTo(to); - }} - /> + +
+
+
+ +
+
+
{t("playerScopeTitle")}
+

{t("playerSearchHint")}

+
+
+ +
+ + setPlayerSearch(e.target.value)} + placeholder={t("playerSearchPlaceholder")} + /> +
+ + {selectedPlayer ? ( +
+
+
+ {selectedPlayer.site_player_id} + {selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""} + {selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""} +
+
+ {t("playerSelected")} · {selectedPlayer.site_code} +
+
+ +
+ ) : null} + + {playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? ( +
+
+ {playerLoading ? ( + + ) : playerResults.length === 0 ? ( +
{t("playerNoResults")}
+ ) : ( +
+ {playerResults.map((player) => { + const active = selectedPlayer?.id === player.id; + return ( + + ); + })} +
+ )} +
+
+ ) : ( +
+ {t("playerAllPlayersHint")} +
+ )} +
+
+ +
+
+ {selectedPlayer + ? t("createSummaryPlayer", { + player: selectedPlayer.site_player_id, + from: dateFrom || "—", + to: dateTo || "—", + }) + : t("createSummaryAll", { + from: dateFrom || "—", + to: dateTo || "—", + })}
-
- - setPlayerSearch(e.target.value)} - placeholder={t("playerSearchPlaceholder")} - /> - {selectedPlayer ? ( -
-
-
- {selectedPlayer.site_player_id} - {selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""} - {selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""} -
-
- -
- ) : null} - {playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? ( -
-
- {playerLoading ? ( -
{t("loadingPlayers")}
- ) : playerResults.length === 0 ? ( -
{t("playerNoResults")}
- ) : ( -
- {playerResults.map((player) => { - const active = selectedPlayer?.id === player.id; - return ( - - ); - })} -
- )} -
-
- ) : null} -
) : ( @@ -349,6 +420,7 @@ export function ReconcileConsole(): React.ReactElement {
{t("jobsTitle")} + {t("jobsDesc")}
@@ -502,29 +609,47 @@ export function ReconcileConsole(): React.ReactElement { {t("table.id", { ns: "common" })}{t("sideARef")}{t("sideBRef")} - {t("differenceAmount")} + {t("differenceAmount")}{t("status")} + {t("detectedAt")} {items.items.length === 0 ? ( - + {t("noDetails")} ) : ( items.items.map((r) => ( - + {r.id} {r.side_a_ref ?? "—"} {r.side_b_ref ?? "—"} - {r.difference_amount} + + + {r.difference_amount} + + {itemStatusLabel(r.status, t)} + + {formatTs(r.created_at)} + )) )} diff --git a/src/modules/reports/report-jobs-panel.tsx b/src/modules/reports/report-jobs-panel.tsx index 3bcb981..69b3613 100644 --- a/src/modules/reports/report-jobs-panel.tsx +++ b/src/modules/reports/report-jobs-panel.tsx @@ -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([]); 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 { if (!canExport || job.status !== "completed") { @@ -111,11 +113,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel {loading ? ( - - - {t("states.loading", { ns: "common" })} - - + ) : jobs.length === 0 ? ( diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx index e71a126..a3957d2 100644 --- a/src/modules/reports/reports-console.tsx +++ b/src/modules/reports/reports-console.tsx @@ -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 { 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(null); const [jobRefreshToken, setJobRefreshToken] = useState(0); const [search, setSearch] = useState(emptySearch); - const [playOptions, setPlayOptions] = useState([]); + 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(() => { + 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() { />
{search.loading ? ( -

{t("states.loading", { ns: "common" })}

+ ) : null} {!search.loading && kind === "draw" ? ( search.draws.map((item) => ( @@ -1067,11 +1194,7 @@ export function ReportsConsole() { } if (loading) { return ( - - - {t("states.loading", { ns: "common" })} - - + ); } if (error) { @@ -1148,7 +1271,7 @@ export function ReportsConsole() { {formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)} {formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)} {result.raw.pool.is_sold_out ? t("yes") : t("no")} - {result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`} + {formatUsagePercent(result.raw.pool.usage_ratio)} v{result.raw.pool.version} {result.raw.logs.items.map((item) => ( @@ -1176,7 +1299,7 @@ export function ReportsConsole() { {formatPlainMoney(item.locked_amount, null)} {formatPlainMoney(item.remaining_amount, null)} {item.is_sold_out ? t("yes") : t("no")} - {item.usage_ratio == null ? "-" : `${item.usage_ratio}%`} + {formatUsagePercent(item.usage_ratio)} v{item.version} )); @@ -1201,7 +1324,10 @@ export function ReportsConsole() { return result.raw.map((item) => ( {item.username} - ID {item.player_id} + + {adminAgentDisplayLabel(item)} + ID {item.player_id} + {formatPlainMoney(item.total_bet_minor, "NPR")} {formatPlainMoney(item.total_payout_minor, "NPR")} {formatPlainMoney(item.net_win_loss_minor, "NPR")} @@ -1305,6 +1431,13 @@ export function ReportsConsole() {
{selectedReport.fields.map(renderField)} + {selectedReport.category === "profit" || selectedReport.category === "wallet" ? ( + setFilters((prev) => ({ ...prev, agentNodeId: id }))} + /> + ) : null}
@@ -1395,17 +1528,20 @@ export function ReportsConsole() {
+
+ {t("preview.summaryScopeHint")} +
- {t("preview.columns.primary")} - {t("preview.columns.secondary")} - {t("preview.columns.metricA")} - {t("preview.columns.metricB")} - {t("preview.columns.metricC")} - {t("preview.columns.status")} - {t("preview.columns.extra")} - {t("preview.columns.time")} + {previewColumns.primary} + {previewColumns.secondary} + {previewColumns.metricA} + {previewColumns.metricB} + {previewColumns.metricC} + {previewColumns.status} + {previewColumns.extra} + {previewColumns.time} {renderTable()} diff --git a/src/modules/risk/risk-draw-header.tsx b/src/modules/risk/risk-draw-header.tsx index 8a797c3..799068b 100644 --- a/src/modules/risk/risk-draw-header.tsx +++ b/src/modules/risk/risk-draw-header.tsx @@ -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(null); const [error, setError] = useState(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

{error}

; } if (!draw) { - return

{t("loadingDraw")}

; + return ; } return ( diff --git a/src/modules/risk/risk-index-console.tsx b/src/modules/risk/risk-index-console.tsx index 5731291..c4fdbe5 100644 --- a/src/modules/risk/risk-index-console.tsx +++ b/src/modules/risk/risk-index-console.tsx @@ -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(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() { {error ?

{error}

: null} - {loading && (data?.items.length ?? 0) === 0 ? ( -

{t("states.loading", { ns: "common" })}

- ) : ( -
+
@@ -188,7 +187,9 @@ export function RiskIndexConsole() { - {(data?.items ?? []).length === 0 ? ( + {loading && (data?.items.length ?? 0) === 0 ? ( + + ) : (data?.items ?? []).length === 0 ? ( {t("states.noData", { ns: "common" })} @@ -222,7 +223,6 @@ export function RiskIndexConsole() {
- )} { - queueMicrotask(() => { - void load(); - }); - }, [load]); + useAsyncEffect(() => { + void load(); + }, [drawId, page, perPage, appliedAction, appliedNumber]); return ( @@ -157,10 +159,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) { {error ?

{error}

: null} - {loading && !data ? ( -

{t("states.loading", { ns: "common" })}

- ) : ( - <> + <>
@@ -175,6 +174,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) { + {loading && !data ? : null} {(data?.items ?? []).map((row: AdminRiskLockLogRow) => ( @@ -214,7 +214,6 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) { /> ) : null} - )} ); diff --git a/src/modules/risk/risk-pool-detail-console.tsx b/src/modules/risk/risk-pool-detail-console.tsx index 92e7b1b..8623f93 100644 --- a/src/modules/risk/risk-pool-detail-console.tsx +++ b/src/modules/risk/risk-pool-detail-console.tsx @@ -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

{t("states.loading", { ns: "common" })}

; + return ; } if (!data) { diff --git a/src/modules/risk/risk-pools-console.tsx b/src/modules/risk/risk-pools-console.tsx index e62dbfd..98ee9e2 100644 --- a/src/modules/risk/risk-pools-console.tsx +++ b/src/modules/risk/risk-pools-console.tsx @@ -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({ {error ?

{error}

: null} - {loading && !data ? ( -

{t("states.loading", { ns: "common" })}

- ) : ( - <> + <>
@@ -258,6 +257,7 @@ export function RiskPoolsConsole({ + {loading && !data ? : 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} - )} diff --git a/src/modules/settings/admin-settings-data-context.tsx b/src/modules/settings/admin-settings-data-context.tsx new file mode 100644 index 0000000..d02c79d --- /dev/null +++ b/src/modules/settings/admin-settings-data-context.tsx @@ -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, +): void { + for (const item of items) { + into[item.key] = item.value; + } +} + +type AdminSettingsDataContextValue = { + kv: Record | null; + loading: boolean; + reload: () => Promise; + patchKv: (updates: Record) => void; +}; + +const AdminSettingsDataContext = createContext(null); + +export function AdminSettingsDataProvider({ children }: { children: ReactNode }) { + const { t } = useTranslation(["config"]); + const [kv, setKv] = useState | 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 = {}; + 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) => { + setKv((prev) => (prev === null ? { ...updates } : { ...prev, ...updates })); + }, []); + + const value = useMemo( + () => ({ kv, loading, reload, patchKv }), + [kv, loading, reload, patchKv], + ); + + return ( + {children} + ); +} + +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); +} diff --git a/src/modules/settings/components/settings-section-actions.tsx b/src/modules/settings/components/settings-section-actions.tsx new file mode 100644 index 0000000..266a2a8 --- /dev/null +++ b/src/modules/settings/components/settings-section-actions.tsx @@ -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 ( +
+ + {dirty ? ( + + ) : null} +
+ ); +} diff --git a/src/modules/settings/currency-settings-panel.tsx b/src/modules/settings/currency-settings-panel.tsx index 288a3ee..5171020 100644 --- a/src/modules/settings/currency-settings-panel.tsx +++ b/src/modules/settings/currency-settings-panel.tsx @@ -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"); diff --git a/src/modules/settings/hooks/use-settings-section.ts b/src/modules/settings/hooks/use-settings-section.ts new file mode 100644 index 0000000..315e28a --- /dev/null +++ b/src/modules/settings/hooks/use-settings-section.ts @@ -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(options: { + initialDraft: TDraft; + fromKv: (kv: Record) => 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 = (field: K, value: TDraft[K]) => { + setDraft((prev) => ({ ...prev, [field]: value })); + }; + + const discard = () => { + setDraft(saved); + }; + + const save = async (): Promise => { + const items = buildDirtyItems(draft, saved); + if (items.length === 0) { + return true; + } + + setSaving(true); + try { + await updateAdminSettingsBatch(items); + const updates: Record = {}; + 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, + }; +} diff --git a/src/modules/settings/panels/currency-format-settings-panel.tsx b/src/modules/settings/panels/currency-format-settings-panel.tsx new file mode 100644 index 0000000..c1ecf2c --- /dev/null +++ b/src/modules/settings/panels/currency-format-settings-panel.tsx @@ -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): 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 ( + <> + +
+
+
+ + updateField("currencyDisplayDecimals", e.target.value)} + disabled={loading || saving} + /> +
+
+ + updateField("currencyDecimalSeparator", e.target.value)} + disabled={loading || saving} + maxLength={1} + /> +
+
+ + updateField("currencyThousandsSeparator", e.target.value)} + disabled={loading || saving} + maxLength={1} + /> +
+
+ + + 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" })} + /> +
+
+ + + ); +} diff --git a/src/modules/settings/panels/draw-settings-panel.tsx b/src/modules/settings/panels/draw-settings-panel.tsx new file mode 100644 index 0000000..0ed16e9 --- /dev/null +++ b/src/modules/settings/panels/draw-settings-panel.tsx @@ -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): 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 ( + <> + +
+
+ + updateField("requireManualReview", value)} + /> +
+ +
+ +
+
+ + updateField("defaultCurrency", e.target.value.toUpperCase())} + disabled={loading || saving} + maxLength={16} + /> +
+
+ + updateField("drawIntervalMinutes", e.target.value)} + disabled={loading || saving} + /> +
+
+ + updateField("drawBettingWindowSeconds", e.target.value)} + disabled={loading || saving} + /> +
+
+ + updateField("drawCloseBeforeDrawSeconds", e.target.value)} + disabled={loading || saving} + /> +
+
+ + updateField("drawBufferDrawsAhead", e.target.value)} + disabled={loading || saving} + /> +
+
+ + updateField("cooldownMinutes", e.target.value)} + disabled={loading || saving} + /> +
+
+ + + 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" })} + /> +
+ + + + ); +} diff --git a/src/modules/settings/panels/frontend-settings-panel.tsx b/src/modules/settings/panels/frontend-settings-panel.tsx new file mode 100644 index 0000000..ae41d3e --- /dev/null +++ b/src/modules/settings/panels/frontend-settings-panel.tsx @@ -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): 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 ( + <> + +
+ +

+ {t("system.fields.playRulesHtmlDesc", { ns: "config" })} +

+ + + {t("play.locales.zh", { ns: "config" })} + {t("play.locales.en", { ns: "config" })} + {t("play.locales.ne", { ns: "config" })} + + +