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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -178,7 +178,18 @@
"loadFailed": "Failed to load system settings", "loadFailed": "Failed to load system settings",
"saveSuccess": "System settings saved", "saveSuccess": "System settings saved",
"saveRuntimeSuccess": "Draw and settlement parameters 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", "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", "saveFailed": "Failed to save system settings",
"unsavedChanges": "Unsaved changes", "unsavedChanges": "Unsaved changes",
"frontendConfig": "Front-end configuration", "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.", "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?", "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.", "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?", "confirmSaveFrontendTitle": "Save front-end display settings?",
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed." "confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed."
}, },

View File

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

View File

@@ -2,10 +2,13 @@
"title": "Reconcile", "title": "Reconcile",
"createTitle": "Create reconcile job", "createTitle": "Create reconcile job",
"createDesc": "Manually check abnormal transfers by date range and optional player. Scheduled reconciliation still runs automatically.", "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", "reconcileType": "Reconcile type",
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)", "reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
"reconcileTypeHint": "Only wallet transfer is currently supported.", "reconcileTypeHint": "Only wallet transfer is currently supported.",
"dateRange": "Reconcile date range", "dateRange": "Reconcile date range",
"dateRangeHint": "Start with a shorter period to spot concentrated issues before widening the search.",
"createTask": "Create reconcile job", "createTask": "Create reconcile job",
"submitting": "Submitting…", "submitting": "Submitting…",
"loadFailed": "Failed to load", "loadFailed": "Failed to load",
@@ -20,13 +23,21 @@
"createSuccess": "Reconcile job created", "createSuccess": "Reconcile job created",
"createFailed": "Failed to create job", "createFailed": "Failed to create job",
"noCreatePermission": "Current account cannot create reconcile jobs.", "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", "jobsTitle": "Reconcile jobs",
"jobsDesc": "Use the action on the right to open paginated item details.", "jobsDesc": "Use the action on the right to open paginated item details.",
"refresh": "Refresh", "refresh": "Refresh",
"jobNo": "Job no.", "jobNo": "Job no.",
"type": "Type", "type": "Type",
"status": "Status", "status": "Status",
"itemCount": "Items",
"mismatchCount": "Mismatches",
"matchedCount": "Matched",
"period": "Period", "period": "Period",
"finishedAt": "Finished at",
"createdAt": "Created at", "createdAt": "Created at",
"operate": "Action", "operate": "Action",
"view": "View", "view": "View",
@@ -34,6 +45,7 @@
"sideARef": "Lottery ref", "sideARef": "Lottery ref",
"sideBRef": "Main site ref", "sideBRef": "Main site ref",
"differenceAmount": "Difference (cent)", "differenceAmount": "Difference (cent)",
"detectedAt": "Detected at",
"noDetails": "No details", "noDetails": "No details",
"playerSearch": "Player (optional)", "playerSearch": "Player (optional)",
"playerSearchPlaceholder": "Search by player ID / username / nickname", "playerSearchPlaceholder": "Search by player ID / username / nickname",

View File

@@ -84,15 +84,109 @@
"subtitle": "Results appear below. Export as CSV or Excel.", "subtitle": "Results appear below. Export as CSV or Excel.",
"empty": "No data. Adjust filters and try again.", "empty": "No data. Adjust filters and try again.",
"exportableRows": "rows exportable", "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": { "columns": {
"primary": "", "primary": "Primary",
"secondary": "", "secondary": "Secondary",
"metricA": "", "metricA": "Metric A",
"metricB": "", "metricB": "Metric B",
"metricC": "", "metricC": "Metric C",
"status": "", "status": "Status",
"extra": "", "extra": "Extra",
"time": "" "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": { "stats": {
"records": "Records", "records": "Records",
@@ -179,7 +273,7 @@
}, },
"daily_profit": { "daily_profit": {
"title": "Daily P&L summary", "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": { "player_win_loss": {
"title": "Player win/loss report", "title": "Player win/loss report",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,15 +84,109 @@
"subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。", "subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。",
"empty": "暂无数据,请调整筛选条件后重试。", "empty": "暂无数据,请调整筛选条件后重试。",
"exportableRows": "行可导出", "exportableRows": "行可导出",
"summaryScopeHint": "上方统计卡除“记录数”外,默认按当前预览页汇总;需要全量口径请使用“导出 CSV/Excel全量”。",
"scope": {
"currentPage": "当前页"
},
"columns": { "columns": {
"primary": "", "primary": "主字段",
"secondary": "", "secondary": "辅助字段",
"metricA": "", "metricA": "指标 A",
"metricB": "", "metricB": "指标 B",
"metricC": "", "metricC": "指标 C",
"status": "", "status": "状态",
"extra": "", "extra": "补充信息",
"time": "" "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": { "stats": {
"records": "记录数", "records": "记录数",
@@ -179,7 +273,7 @@
}, },
"daily_profit": { "daily_profit": {
"title": "每日盈亏汇总", "title": "每日盈亏汇总",
"summary": "按自然日汇总投注、派奖、退款、盈亏和净额。" "summary": "按业务日汇总投注、派彩与平台盈亏,当前不包含退款与单独净额字段。"
}, },
"player_win_loss": { "player_win_loss": {
"title": "玩家输赢报表", "title": "玩家输赢报表",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,15 @@ import { getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
const DEFAULT_DECIMAL_PLACES = 2; 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 { export function getAdminCurrencyDecimalPlaces(currencyCode: string | null | undefined): number {
const code = currencyCode?.trim().toUpperCase(); const code = currencyCode?.trim().toUpperCase();
if (!code) { if (!code) {
@@ -23,11 +32,12 @@ export function formatAdminMinorUnits(
currencyCode = "NPR", currencyCode = "NPR",
decimalPlaces?: number, decimalPlaces?: number,
): string { ): string {
const safeMinor = coerceAdminMinor(minor);
const resolvedDecimalPlaces = const resolvedDecimalPlaces =
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0 typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
? decimalPlaces ? decimalPlaces
: getAdminCurrencyDecimalPlaces(currencyCode); : getAdminCurrencyDecimalPlaces(currencyCode);
const major = minor / 10 ** resolvedDecimalPlaces; const major = safeMinor / 10 ** resolvedDecimalPlaces;
return `${currencyCode} ${major.toLocaleString(undefined, { return `${currencyCode} ${major.toLocaleString(undefined, {
minimumFractionDigits: resolvedDecimalPlaces, minimumFractionDigits: resolvedDecimalPlaces,
maximumFractionDigits: resolvedDecimalPlaces, maximumFractionDigits: resolvedDecimalPlaces,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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