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:
113
src/api/admin-agents.ts
Normal file
113
src/api/admin-agents.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export type AdminDrawListQuery = {
|
||||
per_page?: number;
|
||||
draw_no?: string;
|
||||
status?: string;
|
||||
agent_node_id?: number;
|
||||
};
|
||||
|
||||
export async function getAdminDraws(q: AdminDrawListQuery = {}): Promise<AdminDrawListData> {
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function getAdminPlayers(params?: {
|
||||
keyword?: string;
|
||||
status?: number;
|
||||
site_code?: string;
|
||||
agent_node_id?: number;
|
||||
}): Promise<AdminPlayerListData> {
|
||||
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
|
||||
}
|
||||
|
||||
@@ -27,3 +27,14 @@ export async function updateAdminSetting(
|
||||
): Promise<AdminSettingItem> {
|
||||
return adminRequest.put<AdminSettingItem>(`${A}/settings/${key}`, { value });
|
||||
}
|
||||
|
||||
export type AdminSettingBatchItem = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export async function updateAdminSettingsBatch(
|
||||
items: AdminSettingBatchItem[],
|
||||
): Promise<AdminSettingListResponse> {
|
||||
return adminRequest.put<AdminSettingListResponse>(`${A}/settings/batch`, { items });
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AdminSettlementBatchListQuery = {
|
||||
per_page?: number;
|
||||
draw_no?: string;
|
||||
status?: string;
|
||||
agent_node_id?: number;
|
||||
};
|
||||
|
||||
export async function getAdminSettlementBatches(
|
||||
@@ -33,6 +34,7 @@ export async function getAdminSettlementBatch(batchId: number): Promise<AdminSet
|
||||
export type AdminSettlementBatchDetailsQuery = {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
agent_node_id?: number;
|
||||
};
|
||||
|
||||
export async function getAdminSettlementBatchDetails(
|
||||
|
||||
@@ -11,6 +11,7 @@ export type TicketItemsListQuery = {
|
||||
player_id?: number;
|
||||
player_account?: string;
|
||||
site_code?: string;
|
||||
agent_node_id?: number;
|
||||
draw_no?: string;
|
||||
status?: string[];
|
||||
number?: string;
|
||||
|
||||
@@ -22,6 +22,8 @@ export type TransferOrderListQuery = {
|
||||
status?: string;
|
||||
/** 仅异常:processing / failed / pending_reconcile */
|
||||
abnormal?: boolean;
|
||||
site_code?: string;
|
||||
agent_node_id?: number;
|
||||
};
|
||||
|
||||
export async function getAdminTransferOrders(
|
||||
@@ -45,6 +47,8 @@ export type WalletTransactionListQuery = {
|
||||
biz_type?: string;
|
||||
status?: string;
|
||||
abnormal?: boolean;
|
||||
site_code?: string;
|
||||
agent_node_id?: number;
|
||||
};
|
||||
|
||||
export async function getAdminWalletTransactions(
|
||||
|
||||
18
src/app/admin/(shell)/agents/page.tsx
Normal file
18
src/app/admin/(shell)/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--animate-loading-dot-bounce: loading-dot-bounce 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -208,3 +209,18 @@
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-dot-bounce {
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
35% {
|
||||
transform: translateY(-48%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
src/components/admin/admin-agent-columns.tsx
Normal file
57
src/components/admin/admin-agent-columns.tsx
Normal 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} />;
|
||||
}
|
||||
83
src/components/admin/admin-agent-filter.tsx
Normal file
83
src/components/admin/admin-agent-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,10 @@ const SETTINGS_ROUTE_LABELS: Record<string, string> = {
|
||||
currencies: "currencies.title",
|
||||
};
|
||||
|
||||
const TOP_ROUTE_LABELS: Record<string, string> = {
|
||||
agents: "agents.title",
|
||||
};
|
||||
|
||||
const CONFIG_ROUTE_LABELS: Record<string, string> = {
|
||||
"integration-sites": "integrationSites.title",
|
||||
plays: "nav.items.plays",
|
||||
@@ -59,7 +63,7 @@ type BreadcrumbCrumb = {
|
||||
};
|
||||
|
||||
export function AdminBreadcrumb() {
|
||||
const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports"]);
|
||||
const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports", "agents"]);
|
||||
const pathname = usePathname();
|
||||
const profile = useAdminProfile();
|
||||
const navItems = profile?.navigation ?? [];
|
||||
@@ -98,11 +102,14 @@ export function AdminBreadcrumb() {
|
||||
isCurrent: pathname === navItem.href || segments.length === 2,
|
||||
});
|
||||
} else {
|
||||
const topKey = TOP_ROUTE_LABELS[businessSegment];
|
||||
breadcrumbs.push({
|
||||
label: t(`nav.${businessSegment}`, {
|
||||
ns: "common",
|
||||
defaultValue: titleCase(businessSegment),
|
||||
}),
|
||||
label: topKey
|
||||
? t(topKey, { ns: "agents", defaultValue: titleCase(businessSegment) })
|
||||
: t(`nav.${businessSegment}`, {
|
||||
ns: "common",
|
||||
defaultValue: titleCase(businessSegment),
|
||||
}),
|
||||
href: `${ADMIN_BASE}/${businessSegment}`,
|
||||
isCurrent: segments.length === 2,
|
||||
});
|
||||
|
||||
94
src/components/admin/admin-loading-state.tsx
Normal file
94
src/components/admin/admin-loading-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
src/components/admin/admin-sidebar-nav.tsx
Normal file
256
src/components/admin/admin-sidebar-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AdminSidebarNav,
|
||||
AdminSidebarNavSkeleton,
|
||||
} from "@/components/admin/admin-sidebar-nav";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
@@ -18,63 +18,28 @@ import {
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { adminNavLabel } from "@/lib/admin-nav-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
|
||||
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||
import { useAdminProfile, useAdminSessionStore } from "@/stores/admin-session";
|
||||
|
||||
/** 与常见导航项文字宽度接近,避免整齐灰条 */
|
||||
const SIDEBAR_NAV_SKELETON_WIDTHS = ["68%", "82%", "58%", "74%", "64%", "78%", "55%", "70%", "62%"] as const;
|
||||
|
||||
function SidebarNavSkeletonRow({
|
||||
labelWidth,
|
||||
delayMs,
|
||||
}: {
|
||||
labelWidth: string;
|
||||
delayMs: number;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex h-8 w-full items-center gap-2 rounded-md px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1.5"
|
||||
style={{ animationDelay: `${delayMs}ms` }}
|
||||
>
|
||||
<span
|
||||
className="size-4 shrink-0 rounded-[4px] bg-white/12 motion-safe:animate-pulse"
|
||||
style={{ animationDelay: `${delayMs}ms` }}
|
||||
/>
|
||||
<span
|
||||
className="h-2.5 shrink-0 rounded-full bg-white/12 motion-safe:animate-pulse group-data-[collapsible=icon]:hidden"
|
||||
style={{ width: labelWidth, animationDelay: `${delayMs + 40}ms` }}
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminSidebarSkeleton(): ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" className="overflow-hidden">
|
||||
<SidebarHeader className="flex h-14 shrink-0 items-center gap-0 border-b border-sidebar-border p-0 px-2">
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="flex shrink-0 flex-col gap-0 border-b border-sidebar-border px-2 py-2">
|
||||
<SidebarMenu className="h-full w-full">
|
||||
<SidebarMenuItem className="h-full">
|
||||
<div className="flex h-12 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
|
||||
<div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
className="h-auto max-h-11 w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
className="h-auto max-h-10 w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="relative overflow-hidden">
|
||||
<SidebarContent className="relative min-h-0 overflow-hidden p-0">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
@@ -82,81 +47,64 @@ function AdminSidebarSkeleton(): ReactElement {
|
||||
alt=""
|
||||
className="h-full w-full object-cover object-bottom"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-sidebar to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||
<div className="absolute inset-0 bg-sidebar/20" />
|
||||
</div>
|
||||
<SidebarGroup className="relative z-10">
|
||||
<SidebarGroupLabel className="text-sidebar-foreground/55">
|
||||
{t("sidebar.workspace", { defaultValue: "Workspace" })}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className={cn("gap-0.5", "motion-safe:opacity-90")}>
|
||||
{SIDEBAR_NAV_SKELETON_WIDTHS.map((width, i) => (
|
||||
<SidebarNavSkeletonRow key={i} labelWidth={width} delayMs={i * 55} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
|
||||
<AdminSidebarNavSkeleton />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<SidebarSeparator />
|
||||
<SidebarRail />
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
{t("auth.checking")}
|
||||
</span>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function isActive(pathname: string, item: { href: string; activeMatchPrefix?: string; segment?: string }): boolean {
|
||||
const { href, activeMatchPrefix, segment } = item;
|
||||
const prefix = activeMatchPrefix ?? href;
|
||||
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
|
||||
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
|
||||
}
|
||||
// Keep "settings" independent from its child routes like /admin/settings/currencies.
|
||||
if (segment === "settings") {
|
||||
return pathname === href;
|
||||
}
|
||||
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
export function AdminAppSidebar() {
|
||||
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "audit", "reports"]);
|
||||
const pathname = usePathname();
|
||||
const shellAuthPending = useAdminSessionStore((s) => s.shellAuthPending);
|
||||
const profile = useAdminProfile();
|
||||
|
||||
if (shellAuthPending) {
|
||||
return <AdminSidebarSkeleton />;
|
||||
}
|
||||
const visibleNav = useMemo(
|
||||
() => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"),
|
||||
[profile?.navigation],
|
||||
);
|
||||
|
||||
if (shellAuthPending) {
|
||||
return <AdminSidebarSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" className="overflow-hidden">
|
||||
<SidebarHeader className="flex h-14 shrink-0 items-center gap-0 border-b border-sidebar-border p-0 px-2">
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="flex shrink-0 flex-col gap-0 border-b border-sidebar-border px-2 py-2">
|
||||
<SidebarMenu className="h-full w-full">
|
||||
<SidebarMenuItem className="h-full">
|
||||
<SidebarMenuButton
|
||||
render={<Link href={ADMIN_BASE} />}
|
||||
className="h-full min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
|
||||
className="h-10 min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<div className="flex h-12 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
|
||||
<div className="flex h-10 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
className="h-auto max-h-11 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
className="h-auto max-h-10 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{profile?.agent ? (
|
||||
<p
|
||||
className="truncate px-1 pb-1 text-[11px] leading-tight font-medium text-sidebar-foreground/60 group-data-[collapsible=icon]:hidden"
|
||||
title={profile.agent.name}
|
||||
>
|
||||
{profile.agent.name}
|
||||
<span className="text-sidebar-foreground/40"> · {profile.agent.code}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="relative overflow-hidden">
|
||||
<SidebarContent className="relative min-h-0 overflow-hidden p-0">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
@@ -164,32 +112,12 @@ export function AdminAppSidebar() {
|
||||
alt=""
|
||||
className="h-full w-full object-cover object-bottom"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-sidebar to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||
<div className="absolute inset-0 bg-sidebar/20" />
|
||||
</div>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visibleNav.map((item) => {
|
||||
const Icon = resolveAdminNavIcon(item.segment);
|
||||
return (
|
||||
<SidebarMenuItem key={item.segment}>
|
||||
<SidebarMenuButton
|
||||
tooltip={adminNavLabel(item.segment, t, item.label)}
|
||||
isActive={isActive(pathname, item)}
|
||||
render={<Link href={item.href} />}
|
||||
className="font-medium text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-active:bg-red-600 data-active:text-white data-active:shadow-sm"
|
||||
>
|
||||
<Icon data-icon="inline-start" aria-hidden />
|
||||
<span>{adminNavLabel(item.segment, t, item.label)}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
|
||||
<AdminSidebarNav items={visibleNav} />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<SidebarSeparator />
|
||||
<SidebarRail />
|
||||
|
||||
@@ -5,6 +5,7 @@ import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
@@ -23,6 +24,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function LoginForm() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const tRef = useTranslationRef(["auth", "common"]);
|
||||
const router = useRouter();
|
||||
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||||
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
||||
@@ -42,7 +44,7 @@ export function LoginForm() {
|
||||
try {
|
||||
const data = await getAdminCaptcha();
|
||||
if (!data) {
|
||||
toast.error(t("captchaLoadFailed"));
|
||||
toast.error(tRef.current("captchaLoadFailed"));
|
||||
setCaptchaKey(null);
|
||||
setCaptchaSrc(null);
|
||||
|
||||
@@ -54,7 +56,7 @@ export function LoginForm() {
|
||||
} finally {
|
||||
setLoadingCaptcha(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
61
src/components/ui/loading-dots.tsx
Normal file
61
src/components/ui/loading-dots.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,50 @@ import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
|
||||
export type AdminSiteCodeOption = {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
let cachedSites: AdminSiteCodeOption[] | null = null;
|
||||
let inflightSites: Promise<AdminSiteCodeOption[]> | null = null;
|
||||
|
||||
export function clearCachedAdminSiteCodeOptions(): void {
|
||||
cachedSites = null;
|
||||
inflightSites = null;
|
||||
}
|
||||
|
||||
async function fetchSiteCodeOptions(): Promise<AdminSiteCodeOption[]> {
|
||||
if (cachedSites !== null) {
|
||||
return cachedSites;
|
||||
}
|
||||
if (inflightSites !== null) {
|
||||
return inflightSites;
|
||||
}
|
||||
|
||||
inflightSites = getAdminIntegrationSites()
|
||||
.then((data) => {
|
||||
cachedSites = data.items.map((row) => ({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
}));
|
||||
return cachedSites;
|
||||
})
|
||||
.catch(() => {
|
||||
cachedSites = [];
|
||||
return [];
|
||||
})
|
||||
.finally(() => {
|
||||
inflightSites = null;
|
||||
});
|
||||
|
||||
return inflightSites;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接入站点下拉(已按当前管理员站点权限过滤)。
|
||||
* 接入站点下拉(已按当前管理员站点权限过滤;模块级缓存避免多页重复 GET)。
|
||||
*/
|
||||
export function useAdminSiteCodeOptions(): {
|
||||
sites: AdminSiteCodeOption[];
|
||||
@@ -24,24 +60,21 @@ export function useAdminSiteCodeOptions(): {
|
||||
const profile = useAdminProfile();
|
||||
const canLoad = adminHasAnyPermission(profile?.permissions, PRD_INTEGRATION_ACCESS_ANY);
|
||||
|
||||
const [sites, setSites] = useState<AdminSiteCodeOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sites, setSites] = useState<AdminSiteCodeOption[]>(cachedSites ?? []);
|
||||
const [loading, setLoading] = useState(canLoad && cachedSites === null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!canLoad) {
|
||||
setSites([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminIntegrationSites();
|
||||
setSites(
|
||||
data.items.map((row) => ({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
})),
|
||||
);
|
||||
clearCachedAdminSiteCodeOptions();
|
||||
const next = await fetchSiteCodeOptions();
|
||||
setSites(next);
|
||||
} catch {
|
||||
setSites([]);
|
||||
} finally {
|
||||
@@ -49,11 +82,24 @@ export function useAdminSiteCodeOptions(): {
|
||||
}
|
||||
}, [canLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void reload();
|
||||
});
|
||||
}, [reload]);
|
||||
useAsyncEffect(() => {
|
||||
if (!canLoad) {
|
||||
setSites([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (cachedSites !== null) {
|
||||
setSites(cachedSites);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
const next = await fetchSiteCodeOptions();
|
||||
setSites(next);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [canLoad]);
|
||||
|
||||
return {
|
||||
sites,
|
||||
|
||||
21
src/hooks/use-async-effect.ts
Normal file
21
src/hooks/use-async-effect.ts
Normal 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);
|
||||
}
|
||||
43
src/hooks/use-cached-play-type-options.ts
Normal file
43
src/hooks/use-cached-play-type-options.ts
Normal 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;
|
||||
}
|
||||
12
src/hooks/use-translation-ref.ts
Normal file
12
src/hooks/use-translation-ref.ts
Normal 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;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import enTickets from "@/i18n/locales/en/tickets.json";
|
||||
import enReconcile from "@/i18n/locales/en/reconcile.json";
|
||||
import enReports from "@/i18n/locales/en/reports.json";
|
||||
import enWallet from "@/i18n/locales/en/wallet.json";
|
||||
import enAgents from "@/i18n/locales/en/agents.json";
|
||||
import neAudit from "@/i18n/locales/ne/audit.json";
|
||||
import neAdminUsers from "@/i18n/locales/ne/adminUsers.json";
|
||||
import neAuth from "@/i18n/locales/ne/auth.json";
|
||||
@@ -39,6 +40,7 @@ import neTickets from "@/i18n/locales/ne/tickets.json";
|
||||
import neReconcile from "@/i18n/locales/ne/reconcile.json";
|
||||
import neReports from "@/i18n/locales/ne/reports.json";
|
||||
import neWallet from "@/i18n/locales/ne/wallet.json";
|
||||
import neAgents from "@/i18n/locales/ne/agents.json";
|
||||
import zhAudit from "@/i18n/locales/zh/audit.json";
|
||||
import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json";
|
||||
import zhAuth from "@/i18n/locales/zh/auth.json";
|
||||
@@ -54,12 +56,13 @@ import zhTickets from "@/i18n/locales/zh/tickets.json";
|
||||
import zhReconcile from "@/i18n/locales/zh/reconcile.json";
|
||||
import zhReports from "@/i18n/locales/zh/reports.json";
|
||||
import zhWallet from "@/i18n/locales/zh/wallet.json";
|
||||
import zhAgents from "@/i18n/locales/zh/agents.json";
|
||||
|
||||
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
|
||||
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
|
||||
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh";
|
||||
|
||||
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "config"] as const;
|
||||
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "agents", "config"] as const;
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
@@ -78,6 +81,7 @@ const resources = {
|
||||
audit: enAudit,
|
||||
settlement: enSettlement,
|
||||
wallet: enWallet,
|
||||
agents: enAgents,
|
||||
},
|
||||
ne: {
|
||||
common: neCommon,
|
||||
@@ -95,6 +99,7 @@ const resources = {
|
||||
audit: neAudit,
|
||||
settlement: neSettlement,
|
||||
wallet: neWallet,
|
||||
agents: neAgents,
|
||||
},
|
||||
zh: {
|
||||
common: zhCommon,
|
||||
@@ -112,6 +117,7 @@ const resources = {
|
||||
audit: zhAudit,
|
||||
settlement: zhSettlement,
|
||||
wallet: zhWallet,
|
||||
agents: zhAgents,
|
||||
},
|
||||
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;
|
||||
|
||||
|
||||
61
src/i18n/locales/en/agents.json
Normal file
61
src/i18n/locales/en/agents.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,11 @@
|
||||
"display": "Player",
|
||||
"sitePlayerId": "Player ID"
|
||||
},
|
||||
"agentColumns": {
|
||||
"agent": "Agent",
|
||||
"filter": "Agent",
|
||||
"filterAll": "All agents"
|
||||
},
|
||||
"toolbar": {
|
||||
"defaultAdmin": "Administrator",
|
||||
"notifications": "Notifications",
|
||||
@@ -155,10 +160,19 @@
|
||||
"settings": "Settings",
|
||||
"account": "Account settings",
|
||||
"integration": "Integration sites",
|
||||
"agents": "Agents",
|
||||
"config": "Operations config"
|
||||
},
|
||||
"sidebar": {
|
||||
"workspace": "Workspace"
|
||||
"workspace": "Workspace",
|
||||
"group": {
|
||||
"overview": "Overview",
|
||||
"agent": "Agent organization",
|
||||
"operations": "Operations",
|
||||
"finance": "Finance & reports",
|
||||
"rules": "Rules & parameters",
|
||||
"platform": "Platform"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"checking": "Checking sign-in status…",
|
||||
|
||||
@@ -178,7 +178,18 @@
|
||||
"loadFailed": "Failed to load system settings",
|
||||
"saveSuccess": "System settings saved",
|
||||
"saveRuntimeSuccess": "Draw and settlement parameters saved",
|
||||
"saveDrawSuccess": "Draw parameters saved",
|
||||
"saveCurrencyFormatSuccess": "Currency display format saved",
|
||||
"saveSettlementSuccess": "Settlement automation saved",
|
||||
"saveFrontendSuccess": "Front-end display settings saved",
|
||||
"sections": {
|
||||
"draw": "Draw schedule and review",
|
||||
"drawDescription": "Controls draw timing, close window, manual review, and cooldown. Only changed fields in this block are submitted.",
|
||||
"currencyFormat": "Currency display format",
|
||||
"currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).",
|
||||
"settlement": "Settlement automation",
|
||||
"settlementDescription": "Controls whether tick auto-runs settlement, approval, and payout. Only changed fields in this block are submitted."
|
||||
},
|
||||
"saveFailed": "Failed to save system settings",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"frontendConfig": "Front-end configuration",
|
||||
@@ -217,6 +228,12 @@
|
||||
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
|
||||
"confirmSaveRuntimeTitle": "Save draw and settlement parameters?",
|
||||
"confirmSaveRuntimeDescription": "This updates draw review, schedule timing, cooldown, and auto settlement/approval/payout. Play-rules HTML is not changed.",
|
||||
"confirmSaveDrawTitle": "Save draw parameters?",
|
||||
"confirmSaveDrawDescription": "This updates draw review, schedule timing, and cooldown in this block only.",
|
||||
"confirmSaveCurrencyFormatTitle": "Save currency display format?",
|
||||
"confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.",
|
||||
"confirmSaveSettlementTitle": "Save settlement automation?",
|
||||
"confirmSaveSettlementDescription": "This updates auto settlement, approval, and payout switches.",
|
||||
"confirmSaveFrontendTitle": "Save front-end display settings?",
|
||||
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed."
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"granularityDay": "By day",
|
||||
"playBreakdown": "Play breakdown",
|
||||
"playRanking": "Top 5 plays",
|
||||
"agentRanking": "Top 5 agents",
|
||||
"rankingMetricLabel": "Ranking metric",
|
||||
"rankingMetrics": {
|
||||
"bet": "By bet amount",
|
||||
@@ -37,6 +38,7 @@
|
||||
},
|
||||
"periodDistribution": "Period structure",
|
||||
"noPlayData": "No play data in this period",
|
||||
"noAgentData": "No agent data in this period",
|
||||
"periods": {
|
||||
"today": "Today",
|
||||
"last_7_days": "Last 7 days",
|
||||
@@ -90,6 +92,7 @@
|
||||
"batchPendingDraws": "Draws involved",
|
||||
"batchPendingDrawsCount": "{{count}} draws pending",
|
||||
"platformLockedAndCap": "Site locked {{locked}} / cap {{cap}}",
|
||||
"platformCapNotConfigured": "Site locked {{locked}} · cap not configured",
|
||||
"platformOrderAndTicket": "Site-wide {{orders}} orders · {{tickets}} lines",
|
||||
"platformBetTotal": "Lifetime bet",
|
||||
"platformNoFinanceActivity": "No bets site-wide yet",
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
"title": "Reconcile",
|
||||
"createTitle": "Create reconcile job",
|
||||
"createDesc": "Manually check abnormal transfers by date range and optional player. Scheduled reconciliation still runs automatically.",
|
||||
"scopeTitle": "Define the reconcile scope",
|
||||
"scopeDescription": "Choose the business type and date range first, then decide whether to narrow it to one player.",
|
||||
"reconcileType": "Reconcile type",
|
||||
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
|
||||
"reconcileTypeHint": "Only wallet transfer is currently supported.",
|
||||
"dateRange": "Reconcile date range",
|
||||
"dateRangeHint": "Start with a shorter period to spot concentrated issues before widening the search.",
|
||||
"createTask": "Create reconcile job",
|
||||
"submitting": "Submitting…",
|
||||
"loadFailed": "Failed to load",
|
||||
@@ -20,13 +23,21 @@
|
||||
"createSuccess": "Reconcile job created",
|
||||
"createFailed": "Failed to create job",
|
||||
"noCreatePermission": "Current account cannot create reconcile jobs.",
|
||||
"playerScopeTitle": "Optionally narrow to one player",
|
||||
"playerAllPlayersHint": "If no player is selected, the reconcile job will cover all players in the chosen date range.",
|
||||
"createSummaryAll": "A manual reconcile will run for all players from {{from}} to {{to}}.",
|
||||
"createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.",
|
||||
"jobsTitle": "Reconcile jobs",
|
||||
"jobsDesc": "Use the action on the right to open paginated item details.",
|
||||
"refresh": "Refresh",
|
||||
"jobNo": "Job no.",
|
||||
"type": "Type",
|
||||
"status": "Status",
|
||||
"itemCount": "Items",
|
||||
"mismatchCount": "Mismatches",
|
||||
"matchedCount": "Matched",
|
||||
"period": "Period",
|
||||
"finishedAt": "Finished at",
|
||||
"createdAt": "Created at",
|
||||
"operate": "Action",
|
||||
"view": "View",
|
||||
@@ -34,6 +45,7 @@
|
||||
"sideARef": "Lottery ref",
|
||||
"sideBRef": "Main site ref",
|
||||
"differenceAmount": "Difference (cent)",
|
||||
"detectedAt": "Detected at",
|
||||
"noDetails": "No details",
|
||||
"playerSearch": "Player (optional)",
|
||||
"playerSearchPlaceholder": "Search by player ID / username / nickname",
|
||||
|
||||
@@ -84,15 +84,109 @@
|
||||
"subtitle": "Results appear below. Export as CSV or Excel.",
|
||||
"empty": "No data. Adjust filters and try again.",
|
||||
"exportableRows": "rows exportable",
|
||||
"summaryScopeHint": "Except for the total record count, the stat cards above summarize the current preview page. Use full CSV/Excel export for full-range numbers.",
|
||||
"scope": {
|
||||
"currentPage": "Current page"
|
||||
},
|
||||
"columns": {
|
||||
"primary": "",
|
||||
"secondary": "",
|
||||
"metricA": "",
|
||||
"metricB": "",
|
||||
"metricC": "",
|
||||
"status": "",
|
||||
"extra": "",
|
||||
"time": ""
|
||||
"primary": "Primary",
|
||||
"secondary": "Secondary",
|
||||
"metricA": "Metric A",
|
||||
"metricB": "Metric B",
|
||||
"metricC": "Metric C",
|
||||
"status": "Status",
|
||||
"extra": "Extra",
|
||||
"time": "Time",
|
||||
"drawProfit": {
|
||||
"primary": "Draw / Batch",
|
||||
"secondary": "Draw / Settlement status",
|
||||
"metricA": "Orders / Tickets",
|
||||
"metricB": "Tickets / Winners",
|
||||
"metricC": "Bet / House P&L",
|
||||
"status": "Payout / Jackpot",
|
||||
"extra": "Batch count",
|
||||
"time": "Finished"
|
||||
},
|
||||
"dailyProfit": {
|
||||
"primary": "Business date",
|
||||
"secondary": "Note",
|
||||
"metricA": "Bet",
|
||||
"metricB": "Payout",
|
||||
"metricC": "House P&L",
|
||||
"status": "Refund",
|
||||
"extra": "Net",
|
||||
"time": "Updated"
|
||||
},
|
||||
"playerWinLoss": {
|
||||
"primary": "Player",
|
||||
"secondary": "Player ID",
|
||||
"metricA": "Bet",
|
||||
"metricB": "Payout",
|
||||
"metricC": "Net win/loss",
|
||||
"status": "Tier",
|
||||
"extra": "Note",
|
||||
"time": "Time"
|
||||
},
|
||||
"playerTransfer": {
|
||||
"primary": "Transfer no.",
|
||||
"secondary": "Player",
|
||||
"metricA": "Direction",
|
||||
"metricB": "Status",
|
||||
"metricC": "Amount",
|
||||
"status": "External ref",
|
||||
"extra": "Failure reason",
|
||||
"time": "Created"
|
||||
},
|
||||
"hotNumberRisk": {
|
||||
"primary": "Number / Log",
|
||||
"secondary": "Draw / Action",
|
||||
"metricA": "Cap / Amount",
|
||||
"metricB": "Locked / Play",
|
||||
"metricC": "Remaining / Ticket",
|
||||
"status": "Sold out / Player",
|
||||
"extra": "Usage / Reason",
|
||||
"time": "Version / Time"
|
||||
},
|
||||
"playDimension": {
|
||||
"primary": "Play",
|
||||
"secondary": "Dimension",
|
||||
"metricA": "Bet",
|
||||
"metricB": "Payout",
|
||||
"metricC": "House P&L",
|
||||
"status": "Share",
|
||||
"extra": "Note",
|
||||
"time": "Time"
|
||||
},
|
||||
"soldOut": {
|
||||
"primary": "Number",
|
||||
"secondary": "Draw",
|
||||
"metricA": "Cap",
|
||||
"metricB": "Locked",
|
||||
"metricC": "Remaining",
|
||||
"status": "Sold out",
|
||||
"extra": "Usage",
|
||||
"time": "Version"
|
||||
},
|
||||
"rebateCommission": {
|
||||
"primary": "Play",
|
||||
"secondary": "Orders",
|
||||
"metricA": "Rebate",
|
||||
"metricB": "Ticket items",
|
||||
"metricC": "Commission",
|
||||
"status": "Rule hit",
|
||||
"extra": "Note",
|
||||
"time": "Time"
|
||||
},
|
||||
"adminAudit": {
|
||||
"primary": "Log ID",
|
||||
"secondary": "Operator type",
|
||||
"metricA": "Operator ID",
|
||||
"metricB": "Module",
|
||||
"metricC": "Action",
|
||||
"status": "Target type",
|
||||
"extra": "IP",
|
||||
"time": "Time"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"records": "Records",
|
||||
@@ -179,7 +273,7 @@
|
||||
},
|
||||
"daily_profit": {
|
||||
"title": "Daily P&L summary",
|
||||
"summary": "Summarize bets, payouts, refunds, P&L, and net amount by date."
|
||||
"summary": "Summarize bet amount, payout, and house P&L by business date. Refund and standalone net amount are not included yet."
|
||||
},
|
||||
"player_win_loss": {
|
||||
"title": "Player win/loss report",
|
||||
|
||||
61
src/i18n/locales/ne/agents.json
Normal file
61
src/i18n/locales/ne/agents.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,11 @@
|
||||
"display": "खेलाडी",
|
||||
"sitePlayerId": "खेलाडी ID"
|
||||
},
|
||||
"agentColumns": {
|
||||
"agent": "एजेन्ट",
|
||||
"filter": "एजेन्ट",
|
||||
"filterAll": "सबै एजेन्ट"
|
||||
},
|
||||
"toolbar": {
|
||||
"defaultAdmin": "प्रशासक",
|
||||
"notifications": "सूचना",
|
||||
@@ -155,10 +160,19 @@
|
||||
"settings": "सेटिङ",
|
||||
"account": "खाता सेटिङ",
|
||||
"integration": "मुख्य साइट एकीकरण",
|
||||
"agents": "एजेन्ट व्यवस्थापन",
|
||||
"config": "सञ्चालन कन्फिगरेसन"
|
||||
},
|
||||
"sidebar": {
|
||||
"workspace": "कार्यस्थान"
|
||||
"workspace": "कार्यस्थान",
|
||||
"group": {
|
||||
"overview": "सारांश",
|
||||
"agent": "एजेन्ट संगठन",
|
||||
"operations": "दैनिक सञ्चालन",
|
||||
"finance": "वित्त र रिपोर्ट",
|
||||
"rules": "नियम र प्यारामिटर",
|
||||
"platform": "प्लेटफर्म"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"checking": "लगइन स्थिति जाँच हुँदैछ…",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"granularityDay": "दैनिक",
|
||||
"playBreakdown": "प्ले विभाजन",
|
||||
"playRanking": "शीर्ष ५ प्ले",
|
||||
"agentRanking": "शीर्ष ५ एजेन्ट",
|
||||
"rankingMetricLabel": "रैंकिङ मेट्रिक",
|
||||
"rankingMetrics": {
|
||||
"bet": "बेट रकम",
|
||||
@@ -37,6 +38,7 @@
|
||||
},
|
||||
"periodDistribution": "अवधि संरचना",
|
||||
"noPlayData": "यस अवधिमा प्ले डाटा छैन",
|
||||
"noAgentData": "यस अवधिमा एजेन्ट डाटा छैन",
|
||||
"periods": {
|
||||
"today": "आज",
|
||||
"last_7_days": "पछिल्लो ७ दिन",
|
||||
@@ -90,6 +92,7 @@
|
||||
"batchPendingDraws": "सम्बन्धित ड्रअ",
|
||||
"batchPendingDrawsCount": "{{count}} ड्रअ पेन्डिङ",
|
||||
"platformLockedAndCap": "साइट लक {{locked}} / क्याप {{cap}}",
|
||||
"platformCapNotConfigured": "साइट लक {{locked}} · क्याप कन्फिगर गरिएको छैन",
|
||||
"platformOrderAndTicket": "साइटव्यापी {{orders}} अर्डर · {{tickets}} लाइन",
|
||||
"platformBetTotal": "जम्मा बेट",
|
||||
"platformNoFinanceActivity": "साइटव्यापी अहिले बेट छैन",
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
"title": "मिलान",
|
||||
"createTitle": "म्यानुअल मिलान कार्य",
|
||||
"createDesc": "मिति दायरा र वैकल्पिक खेलाडी चयनबाट असामान्य ट्रान्सफर म्यानुअल रूपमा जाँच गर्नुहोस्। scheduled reconciliation स्वतः चलिरहन्छ।",
|
||||
"scopeTitle": "पहिले मिलानको दायरा तय गर्नुहोस्",
|
||||
"scopeDescription": "पहिले व्यवसाय प्रकार र मिति दायरा रोज्नुहोस्, त्यसपछि आवश्यक परे एक खेलाडीमा सीमित गर्नुहोस्।",
|
||||
"reconcileType": "मिलान प्रकार",
|
||||
"reconcileTypeFixed": "वालेट ट्रान्सफर (मुख्य साइट ⇄ लटरी)",
|
||||
"reconcileTypeHint": "हाल वालेट ट्रान्सफर मात्र समर्थित छ।",
|
||||
"dateRange": "मिलान मिति दायरा",
|
||||
"dateRangeHint": "पहिले छोटो समयावधि रोजेर समस्या कहाँ केन्द्रित छ हेर्नुहोस्, त्यसपछि आवश्यक परे दायरा बढाउनुहोस्।",
|
||||
"createTask": "मिलान कार्य सिर्जना",
|
||||
"submitting": "पेश हुँदैछ…",
|
||||
"loadFailed": "लोड असफल भयो",
|
||||
@@ -16,13 +19,21 @@
|
||||
"createSuccess": "मिलान कार्य सिर्जना भयो",
|
||||
"createFailed": "कार्य सिर्जना असफल भयो",
|
||||
"noCreatePermission": "हालको खातासँग मिलान कार्य सिर्जना गर्ने अनुमति छैन।",
|
||||
"playerScopeTitle": "आवश्यक परे एक खेलाडीमा सीमित गर्नुहोस्",
|
||||
"playerAllPlayersHint": "खेलाडी नछानेमा, छनोट गरिएको मिति दायराभित्र सबै खेलाडीका लागि मिलान चलाइनेछ।",
|
||||
"createSummaryAll": "{{from}} देखि {{to}} सम्म सबै खेलाडीका लागि म्यानुअल मिलान चलाइनेछ।",
|
||||
"createSummaryPlayer": "खेलाडी {{player}} का लागि {{from}} देखि {{to}} सम्म म्यानुअल मिलान चलाइनेछ।",
|
||||
"jobsTitle": "मिलान कार्यहरू",
|
||||
"jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।",
|
||||
"refresh": "रिफ्रेस",
|
||||
"jobNo": "कार्य नं.",
|
||||
"type": "प्रकार",
|
||||
"status": "स्थिति",
|
||||
"itemCount": "विवरण संख्या",
|
||||
"mismatchCount": "असंगति",
|
||||
"matchedCount": "मेल खाएका",
|
||||
"period": "अवधि",
|
||||
"finishedAt": "समाप्त समय",
|
||||
"createdAt": "सिर्जना समय",
|
||||
"operate": "कार्य",
|
||||
"view": "हेर्नुहोस्",
|
||||
@@ -30,6 +41,7 @@
|
||||
"sideARef": "लटरी साइड सन्दर्भ",
|
||||
"sideBRef": "मुख्य साइट सन्दर्भ",
|
||||
"differenceAmount": "अन्तर (cent)",
|
||||
"detectedAt": "फेला परेको समय",
|
||||
"noDetails": "विवरण छैन",
|
||||
"playerSearch": "खेलाडी (वैकल्पिक)",
|
||||
"playerSearchPlaceholder": "player ID / username / nickname बाट खोज्नुहोस्",
|
||||
|
||||
@@ -84,15 +84,109 @@
|
||||
"subtitle": "तल तालिकामा नतिजा देखिन्छ।",
|
||||
"empty": "डाटा छैन।",
|
||||
"exportableRows": "पङ्क्ति निर्यात योग्य",
|
||||
"summaryScopeHint": "कुल रेकर्ड बाहेक माथिका कार्डहरू हालको पूर्वावलोकन पृष्ठको योग हुन्। पूर्ण दायराको संख्या चाहिँ पूर्ण CSV/Excel निर्यात प्रयोग गर्नुहोस्।",
|
||||
"scope": {
|
||||
"currentPage": "हालको पृष्ठ"
|
||||
},
|
||||
"columns": {
|
||||
"primary": "",
|
||||
"secondary": "",
|
||||
"metricA": "",
|
||||
"metricB": "",
|
||||
"metricC": "",
|
||||
"status": "",
|
||||
"extra": "",
|
||||
"time": ""
|
||||
"primary": "मुख्य",
|
||||
"secondary": "सहायक",
|
||||
"metricA": "सूचक A",
|
||||
"metricB": "सूचक B",
|
||||
"metricC": "सूचक C",
|
||||
"status": "स्थिति",
|
||||
"extra": "थप",
|
||||
"time": "समय",
|
||||
"drawProfit": {
|
||||
"primary": "ड्र / ब्याच",
|
||||
"secondary": "ड्र / सेटलमेन्ट स्थिति",
|
||||
"metricA": "अर्डर / टिकट",
|
||||
"metricB": "टिकट / विजेता",
|
||||
"metricC": "बेट / हाउस P&L",
|
||||
"status": "पेआउट / ज्याकपोट",
|
||||
"extra": "ब्याच संख्या",
|
||||
"time": "समाप्त"
|
||||
},
|
||||
"dailyProfit": {
|
||||
"primary": "व्यावसायिक मिति",
|
||||
"secondary": "टिप्पणी",
|
||||
"metricA": "बेट",
|
||||
"metricB": "पेआउट",
|
||||
"metricC": "हाउस P&L",
|
||||
"status": "रिफन्ड",
|
||||
"extra": "नेट",
|
||||
"time": "अपडेट"
|
||||
},
|
||||
"playerWinLoss": {
|
||||
"primary": "खेलाडी",
|
||||
"secondary": "खेलाडी ID",
|
||||
"metricA": "बेट",
|
||||
"metricB": "पेआउट",
|
||||
"metricC": "नेट जित/हार",
|
||||
"status": "स्तर",
|
||||
"extra": "टिप्पणी",
|
||||
"time": "समय"
|
||||
},
|
||||
"playerTransfer": {
|
||||
"primary": "ट्रान्सफर नं.",
|
||||
"secondary": "खेलाडी",
|
||||
"metricA": "दिशा",
|
||||
"metricB": "स्थिति",
|
||||
"metricC": "रकम",
|
||||
"status": "बाह्य सन्दर्भ",
|
||||
"extra": "असफल कारण",
|
||||
"time": "सिर्जना"
|
||||
},
|
||||
"hotNumberRisk": {
|
||||
"primary": "नम्बर / लग",
|
||||
"secondary": "ड्र / कार्य",
|
||||
"metricA": "क्याप / रकम",
|
||||
"metricB": "लक / खेल",
|
||||
"metricC": "बाँकी / टिकट",
|
||||
"status": "सोल्ड आउट / खेलाडी",
|
||||
"extra": "प्रयोग / कारण",
|
||||
"time": "संस्करण / समय"
|
||||
},
|
||||
"playDimension": {
|
||||
"primary": "खेल",
|
||||
"secondary": "आयाम",
|
||||
"metricA": "बेट",
|
||||
"metricB": "पेआउट",
|
||||
"metricC": "हाउस P&L",
|
||||
"status": "अनुपात",
|
||||
"extra": "टिप्पणी",
|
||||
"time": "समय"
|
||||
},
|
||||
"soldOut": {
|
||||
"primary": "नम्बर",
|
||||
"secondary": "ड्र",
|
||||
"metricA": "क्याप",
|
||||
"metricB": "लक",
|
||||
"metricC": "बाँकी",
|
||||
"status": "सोल्ड आउट",
|
||||
"extra": "प्रयोग",
|
||||
"time": "संस्करण"
|
||||
},
|
||||
"rebateCommission": {
|
||||
"primary": "खेल",
|
||||
"secondary": "अर्डर",
|
||||
"metricA": "रिबेट",
|
||||
"metricB": "टिकट आइटम",
|
||||
"metricC": "कमिसन",
|
||||
"status": "नियम मिलान",
|
||||
"extra": "टिप्पणी",
|
||||
"time": "समय"
|
||||
},
|
||||
"adminAudit": {
|
||||
"primary": "लग ID",
|
||||
"secondary": "अपरेटर प्रकार",
|
||||
"metricA": "अपरेटर ID",
|
||||
"metricB": "मोड्युल",
|
||||
"metricC": "कार्य",
|
||||
"status": "लक्ष्य प्रकार",
|
||||
"extra": "IP",
|
||||
"time": "समय"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"records": "रेकर्ड",
|
||||
@@ -179,7 +273,7 @@
|
||||
},
|
||||
"daily_profit": {
|
||||
"title": "दैनिक P&L सारांश",
|
||||
"summary": "मिति अनुसार बेट, पेआउट, रिफन्ड, P&L र नेट रकम सारांश गर्नुहोस्।"
|
||||
"summary": "व्यावसायिक मितिअनुसार बेट रकम, पेआउट र हाउस P&L सारांश गर्नुहोस्। रिफन्ड र छुट्टै नेट रकम अहिले समावेश छैन।"
|
||||
},
|
||||
"player_win_loss": {
|
||||
"title": "खेलाडी जित/हार रिपोर्ट",
|
||||
|
||||
61
src/i18n/locales/zh/agents.json
Normal file
61
src/i18n/locales/zh/agents.json
Normal 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}} 的角色"
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,11 @@
|
||||
"display": "玩家",
|
||||
"sitePlayerId": "玩家 ID"
|
||||
},
|
||||
"agentColumns": {
|
||||
"agent": "所属代理",
|
||||
"filter": "代理",
|
||||
"filterAll": "全部代理"
|
||||
},
|
||||
"toolbar": {
|
||||
"defaultAdmin": "管理员",
|
||||
"notifications": "通知",
|
||||
@@ -155,10 +160,19 @@
|
||||
"settings": "系统设置",
|
||||
"account": "账号设置",
|
||||
"integration": "接入站点",
|
||||
"agents": "代理管理",
|
||||
"config": "运营配置"
|
||||
},
|
||||
"sidebar": {
|
||||
"workspace": "工作台"
|
||||
"workspace": "工作台",
|
||||
"group": {
|
||||
"overview": "总览",
|
||||
"agent": "代理组织",
|
||||
"operations": "日常运营",
|
||||
"finance": "资金与报表",
|
||||
"rules": "规则与参数",
|
||||
"platform": "平台管理"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"checking": "正在校验登录状态…",
|
||||
|
||||
@@ -178,7 +178,18 @@
|
||||
"loadFailed": "系统设置加载失败",
|
||||
"saveSuccess": "系统设置已保存",
|
||||
"saveRuntimeSuccess": "开奖与结算参数已保存",
|
||||
"saveDrawSuccess": "开奖参数已保存",
|
||||
"saveCurrencyFormatSuccess": "金额显示格式已保存",
|
||||
"saveSettlementSuccess": "结算自动化参数已保存",
|
||||
"saveFrontendSuccess": "前端展示配置已保存",
|
||||
"sections": {
|
||||
"draw": "开奖节奏与审核",
|
||||
"drawDescription": "控制期号节奏、封盘与开奖后人工审核、冷静期。仅保存本区块内修改过的项。",
|
||||
"currencyFormat": "金额显示格式",
|
||||
"currencyFormatDescription": "全站金额展示的小数位与分隔符,与币种主数据无关。",
|
||||
"settlement": "结算自动化",
|
||||
"settlementDescription": "控制 tick 是否自动结算、审核与派彩。修改后只提交本区块变更项。"
|
||||
},
|
||||
"saveFailed": "系统设置保存失败",
|
||||
"unsavedChanges": "有未保存的更改",
|
||||
"frontendConfig": "前端配置",
|
||||
@@ -217,6 +228,12 @@
|
||||
"confirmSaveDescription": "将更新开奖审核、冷静期、自动结算/审核/派彩及玩法规则展示,可能影响全站运行。",
|
||||
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
|
||||
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
|
||||
"confirmSaveDrawTitle": "确认保存开奖参数?",
|
||||
"confirmSaveDrawDescription": "将更新开奖审核、期号节奏与冷静期等本区块字段。",
|
||||
"confirmSaveCurrencyFormatTitle": "确认保存金额显示格式?",
|
||||
"confirmSaveCurrencyFormatDescription": "将更新小数位与千分位/小数分隔符。",
|
||||
"confirmSaveSettlementTitle": "确认保存结算自动化?",
|
||||
"confirmSaveSettlementDescription": "将更新自动结算、审核与派彩相关开关。",
|
||||
"confirmSaveFrontendTitle": "确认保存前端展示配置?",
|
||||
"confirmSaveFrontendDescription": "将更新玩家端玩法规则页面 HTML,不影响开奖与结算逻辑。"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"granularityDay": "按天",
|
||||
"playBreakdown": "玩法拆解 Top",
|
||||
"playRanking": "玩法排行榜 Top 5",
|
||||
"agentRanking": "代理排行榜 Top 5",
|
||||
"rankingMetricLabel": "排行维度",
|
||||
"rankingMetrics": {
|
||||
"bet": "按投注金额",
|
||||
@@ -37,6 +38,7 @@
|
||||
},
|
||||
"periodDistribution": "区间结构对比",
|
||||
"noPlayData": "该区间暂无玩法数据",
|
||||
"noAgentData": "该区间暂无代理数据",
|
||||
"periods": {
|
||||
"today": "今日",
|
||||
"last_7_days": "近 7 天",
|
||||
@@ -90,6 +92,7 @@
|
||||
"batchPendingDraws": "涉及期数",
|
||||
"batchPendingDrawsCount": "{{count}} 期待审",
|
||||
"platformLockedAndCap": "全站已占用 {{locked}} / 封顶 {{cap}}",
|
||||
"platformCapNotConfigured": "全站已占用 {{locked}} · 尚未配置封顶",
|
||||
"platformOrderAndTicket": "全站 {{orders}} 单 · {{tickets}} 笔",
|
||||
"platformBetTotal": "累计投注",
|
||||
"platformNoFinanceActivity": "全站暂无投注",
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
"title": "对账",
|
||||
"createTitle": "人工发起对账",
|
||||
"createDesc": "用于按日期范围并可选指定玩家,人工核对异常转账。系统定时对账仍会自动执行。",
|
||||
"scopeTitle": "先定义对账范围",
|
||||
"scopeDescription": "先确定要核对的业务类型和日期区间,再决定是否缩小到单个玩家。",
|
||||
"reconcileType": "对账类型",
|
||||
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
|
||||
"reconcileTypeHint": "当前仅支持钱包划转。",
|
||||
"dateRange": "对账日期范围",
|
||||
"dateRangeHint": "建议优先选较短时间段,先看异常是否集中,再按需扩大范围。",
|
||||
"createTask": "创建对账任务",
|
||||
"submitting": "提交中…",
|
||||
"loadFailed": "加载失败",
|
||||
@@ -20,13 +23,21 @@
|
||||
"createSuccess": "已创建对账任务",
|
||||
"createFailed": "创建失败",
|
||||
"noCreatePermission": "当前账号无新建对账任务权限。",
|
||||
"playerScopeTitle": "再决定是否指定玩家",
|
||||
"playerAllPlayersHint": "不选择玩家时,会按日期范围对全量玩家做一次人工对账。",
|
||||
"createSummaryAll": "将对 {{from}} 至 {{to}} 的全量玩家发起人工对账。",
|
||||
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
|
||||
"jobsTitle": "对账任务",
|
||||
"jobsDesc": "在右侧操作中查看差异明细与分页。",
|
||||
"refresh": "刷新",
|
||||
"jobNo": "任务号",
|
||||
"type": "类型",
|
||||
"status": "状态",
|
||||
"itemCount": "明细数",
|
||||
"mismatchCount": "异常数",
|
||||
"matchedCount": "一致数",
|
||||
"period": "对账周期",
|
||||
"finishedAt": "完成时间",
|
||||
"createdAt": "创建时间",
|
||||
"operate": "操作",
|
||||
"view": "查看",
|
||||
@@ -34,6 +45,7 @@
|
||||
"sideARef": "彩票侧引用",
|
||||
"sideBRef": "主站侧引用",
|
||||
"differenceAmount": "差额(分)",
|
||||
"detectedAt": "发现时间",
|
||||
"noDetails": "无明细",
|
||||
"playerSearch": "指定玩家(可选)",
|
||||
"playerSearchPlaceholder": "输入玩家 ID / 用户名 / 昵称搜索",
|
||||
|
||||
@@ -84,15 +84,109 @@
|
||||
"subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。",
|
||||
"empty": "暂无数据,请调整筛选条件后重试。",
|
||||
"exportableRows": "行可导出",
|
||||
"summaryScopeHint": "上方统计卡除“记录数”外,默认按当前预览页汇总;需要全量口径请使用“导出 CSV/Excel(全量)”。",
|
||||
"scope": {
|
||||
"currentPage": "当前页"
|
||||
},
|
||||
"columns": {
|
||||
"primary": "",
|
||||
"secondary": "",
|
||||
"metricA": "",
|
||||
"metricB": "",
|
||||
"metricC": "",
|
||||
"status": "",
|
||||
"extra": "",
|
||||
"time": ""
|
||||
"primary": "主字段",
|
||||
"secondary": "辅助字段",
|
||||
"metricA": "指标 A",
|
||||
"metricB": "指标 B",
|
||||
"metricC": "指标 C",
|
||||
"status": "状态",
|
||||
"extra": "补充信息",
|
||||
"time": "时间",
|
||||
"drawProfit": {
|
||||
"primary": "期号 / 批次",
|
||||
"secondary": "期状态 / 结算状态",
|
||||
"metricA": "订单 / 票数",
|
||||
"metricB": "票数 / 中奖数",
|
||||
"metricC": "下注 / 平台盈亏",
|
||||
"status": "派彩 / Jackpot",
|
||||
"extra": "结算批次数",
|
||||
"time": "完成时间"
|
||||
},
|
||||
"dailyProfit": {
|
||||
"primary": "业务日",
|
||||
"secondary": "说明",
|
||||
"metricA": "下注",
|
||||
"metricB": "派彩",
|
||||
"metricC": "平台盈亏",
|
||||
"status": "退款",
|
||||
"extra": "净额",
|
||||
"time": "更新时间"
|
||||
},
|
||||
"playerWinLoss": {
|
||||
"primary": "玩家",
|
||||
"secondary": "玩家 ID",
|
||||
"metricA": "下注",
|
||||
"metricB": "派彩",
|
||||
"metricC": "净输赢",
|
||||
"status": "层级",
|
||||
"extra": "备注",
|
||||
"time": "时间"
|
||||
},
|
||||
"playerTransfer": {
|
||||
"primary": "转账单号",
|
||||
"secondary": "玩家",
|
||||
"metricA": "方向",
|
||||
"metricB": "状态",
|
||||
"metricC": "金额",
|
||||
"status": "外部流水",
|
||||
"extra": "失败原因",
|
||||
"time": "创建时间"
|
||||
},
|
||||
"hotNumberRisk": {
|
||||
"primary": "号码 / 日志",
|
||||
"secondary": "期号 / 动作",
|
||||
"metricA": "封顶 / 金额",
|
||||
"metricB": "已占用 / 玩法",
|
||||
"metricC": "剩余 / 注单",
|
||||
"status": "售罄 / 玩家",
|
||||
"extra": "使用率 / 原因",
|
||||
"time": "版本 / 时间"
|
||||
},
|
||||
"playDimension": {
|
||||
"primary": "玩法",
|
||||
"secondary": "维度",
|
||||
"metricA": "下注",
|
||||
"metricB": "派彩",
|
||||
"metricC": "平台盈亏",
|
||||
"status": "占比",
|
||||
"extra": "备注",
|
||||
"time": "时间"
|
||||
},
|
||||
"soldOut": {
|
||||
"primary": "号码",
|
||||
"secondary": "期号",
|
||||
"metricA": "封顶",
|
||||
"metricB": "已占用",
|
||||
"metricC": "剩余",
|
||||
"status": "是否售罄",
|
||||
"extra": "使用率",
|
||||
"time": "版本"
|
||||
},
|
||||
"rebateCommission": {
|
||||
"primary": "玩法",
|
||||
"secondary": "订单数",
|
||||
"metricA": "回水",
|
||||
"metricB": "注单数",
|
||||
"metricC": "佣金",
|
||||
"status": "配置命中",
|
||||
"extra": "备注",
|
||||
"time": "时间"
|
||||
},
|
||||
"adminAudit": {
|
||||
"primary": "日志 ID",
|
||||
"secondary": "操作者类型",
|
||||
"metricA": "操作者 ID",
|
||||
"metricB": "模块",
|
||||
"metricC": "动作",
|
||||
"status": "目标类型",
|
||||
"extra": "IP",
|
||||
"time": "时间"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"records": "记录数",
|
||||
@@ -179,7 +273,7 @@
|
||||
},
|
||||
"daily_profit": {
|
||||
"title": "每日盈亏汇总",
|
||||
"summary": "按自然日汇总投注、派奖、退款、盈亏和净额。"
|
||||
"summary": "按业务日汇总投注、派彩与平台盈亏,当前不包含退款与单独净额字段。"
|
||||
},
|
||||
"player_win_loss": {
|
||||
"title": "玩家输赢报表",
|
||||
|
||||
23
src/lib/admin-agent-tree.ts
Normal file
23
src/lib/admin-agent-tree.ts
Normal 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;
|
||||
}
|
||||
48
src/lib/admin-nav-groups.ts
Normal file
48
src/lib/admin-nav-groups.ts
Normal 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)!,
|
||||
}));
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
||||
audit: "audit",
|
||||
settings: "settings",
|
||||
integration: "integration",
|
||||
agents: "agents",
|
||||
config: "config",
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ export function getAdminPlayTypesLoadPromise(
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
/** 确保玩法目录已加载并返回缓存列表(全局去重,配置页勿直接 getAdminPlayTypes) */
|
||||
export async function ensureAdminPlayTypesLoaded(
|
||||
loader: () => Promise<{ items: AdminPlayTypeRow[] }>,
|
||||
): Promise<AdminPlayTypeRow[]> {
|
||||
await getAdminPlayTypesLoadPromise(loader);
|
||||
return getCachedAdminPlayTypes();
|
||||
}
|
||||
|
||||
/** 解析玩法显示名;无配置时回退 play_code */
|
||||
export function resolveAdminPlayTypeDisplayName(
|
||||
playCode: string | null | undefined,
|
||||
|
||||
@@ -131,3 +131,20 @@ export const PRD_PAYOUT_ACCESS_ANY = [
|
||||
|
||||
/** 接入站点配置页 */
|
||||
export const PRD_INTEGRATION_ACCESS_ANY = [PRD_INTEGRATION_VIEW, PRD_INTEGRATION_MANAGE] as const;
|
||||
|
||||
/** 代理管理 */
|
||||
export const PRD_AGENT_VIEW = "prd.agent.view" as const;
|
||||
export const PRD_AGENT_MANAGE = "prd.agent.manage" as const;
|
||||
export const PRD_AGENT_ROLE_VIEW = "prd.agent.role.view" as const;
|
||||
export const PRD_AGENT_ROLE_MANAGE = "prd.agent.role.manage" as const;
|
||||
export const PRD_AGENT_USER_VIEW = "prd.agent.user.view" as const;
|
||||
export const PRD_AGENT_USER_MANAGE = "prd.agent.user.manage" as const;
|
||||
|
||||
export const PRD_AGENTS_ACCESS_ANY = [
|
||||
PRD_AGENT_VIEW,
|
||||
PRD_AGENT_MANAGE,
|
||||
PRD_AGENT_ROLE_VIEW,
|
||||
PRD_AGENT_ROLE_MANAGE,
|
||||
PRD_AGENT_USER_VIEW,
|
||||
PRD_AGENT_USER_MANAGE,
|
||||
] as const;
|
||||
|
||||
44
src/lib/admin-settlement-settings-cache.ts
Normal file
44
src/lib/admin-settlement-settings-cache.ts
Normal 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;
|
||||
}
|
||||
@@ -2,6 +2,15 @@ import { getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
|
||||
|
||||
const DEFAULT_DECIMAL_PLACES = 2;
|
||||
|
||||
/** 接口缺字段或非数字时按 0 处理,避免仪表盘出现 NPRNaN */
|
||||
export function coerceAdminMinor(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
export function getAdminCurrencyDecimalPlaces(currencyCode: string | null | undefined): number {
|
||||
const code = currencyCode?.trim().toUpperCase();
|
||||
if (!code) {
|
||||
@@ -23,11 +32,12 @@ export function formatAdminMinorUnits(
|
||||
currencyCode = "NPR",
|
||||
decimalPlaces?: number,
|
||||
): string {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const resolvedDecimalPlaces =
|
||||
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
|
||||
? decimalPlaces
|
||||
: getAdminCurrencyDecimalPlaces(currencyCode);
|
||||
const major = minor / 10 ** resolvedDecimalPlaces;
|
||||
const major = safeMinor / 10 ** resolvedDecimalPlaces;
|
||||
return `${currencyCode} ${major.toLocaleString(undefined, {
|
||||
minimumFractionDigits: resolvedDecimalPlaces,
|
||||
maximumFractionDigits: resolvedDecimalPlaces,
|
||||
|
||||
@@ -15,6 +15,7 @@ import enSettlement from "@/i18n/locales/en/settlement.json";
|
||||
import enCommon from "@/i18n/locales/en/common.json";
|
||||
import enTickets from "@/i18n/locales/en/tickets.json";
|
||||
import enWallet from "@/i18n/locales/en/wallet.json";
|
||||
import enAgents from "@/i18n/locales/en/agents.json";
|
||||
|
||||
const EN_FLAT: Record<string, Record<string, unknown>> = {
|
||||
dashboard: enDashboard,
|
||||
@@ -33,6 +34,7 @@ const EN_FLAT: Record<string, Record<string, unknown>> = {
|
||||
config: enConfig,
|
||||
common: enCommon,
|
||||
auth: enAuth,
|
||||
agents: enAgents,
|
||||
};
|
||||
|
||||
function getByPath(obj: Record<string, unknown>, path: string): string | undefined {
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
CalendarClock,
|
||||
CircleDollarSign,
|
||||
FileSpreadsheet,
|
||||
Globe,
|
||||
Landmark,
|
||||
LayoutDashboard,
|
||||
LogIn,
|
||||
Network,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Settings,
|
||||
@@ -23,6 +25,7 @@ import type { AdminNavItem } from "@/modules/_config/admin-nav";
|
||||
export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon> =
|
||||
{
|
||||
dashboard: LayoutDashboard,
|
||||
agents: Network,
|
||||
players: Users,
|
||||
draws: CalendarClock,
|
||||
rules_plays: SlidersHorizontal,
|
||||
@@ -39,6 +42,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
admin_users: ShieldCheck,
|
||||
admin_roles: ShieldCheck,
|
||||
currencies: CircleDollarSign,
|
||||
integration: Globe,
|
||||
settings: Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
export const ADMIN_BASE = "/admin" as const;
|
||||
|
||||
export type AdminNavGroup =
|
||||
| "overview"
|
||||
| "agent"
|
||||
| "operations"
|
||||
| "finance"
|
||||
| "rules"
|
||||
| "platform";
|
||||
|
||||
export type AdminNavSegment =
|
||||
| "dashboard"
|
||||
| "agents"
|
||||
| "players"
|
||||
| "draws"
|
||||
| "rules_plays"
|
||||
@@ -18,12 +27,15 @@ export type AdminNavSegment =
|
||||
| "audit"
|
||||
| "admin_users"
|
||||
| "admin_roles"
|
||||
| "currencies";
|
||||
| "currencies"
|
||||
| "integration";
|
||||
|
||||
export type AdminNavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
segment: AdminNavSegment;
|
||||
nav_group?: AdminNavGroup;
|
||||
platform_only?: boolean;
|
||||
activeMatchPrefix?: string;
|
||||
requiredAny?: readonly string[];
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -59,6 +62,7 @@ function permissionLabel(slug: string, fallback: string, t: (key: string) => str
|
||||
|
||||
export function AdminRolesConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
|
||||
@@ -118,19 +122,17 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
setCatalog(catalogData);
|
||||
setRoles(roleData.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("roleLoadFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("roleLoadFailed");
|
||||
setErr(msg);
|
||||
setRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function isDirectGroupOpen(key: string): boolean {
|
||||
return directMenuExpanded[key] === true;
|
||||
@@ -329,9 +331,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && roles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table id="admin-roles-table">
|
||||
<TableHeader>
|
||||
@@ -347,7 +346,9 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.length === 0 ? (
|
||||
{loading && roles.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -51,6 +54,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AdminUsersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const exportLabels = useExportLabels("adminUsers");
|
||||
const profile = useAdminProfile();
|
||||
@@ -112,7 +116,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
setTotal(listData.meta.total);
|
||||
setLastPage(Math.max(1, listData.meta.last_page));
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||
setErr(msg);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
@@ -120,13 +124,11 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, t]);
|
||||
}, [page, perPage, query]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, query]);
|
||||
|
||||
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
||||
setFormCreateRoles((prev) => {
|
||||
@@ -360,9 +362,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table id="admin-users-table">
|
||||
<TableHeader>
|
||||
@@ -377,7 +376,9 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
{loading && items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={7} />
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
|
||||
1084
src/modules/agents/agents-console.tsx
Normal file
1084
src/modules/agents/agents-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -26,6 +29,7 @@ import type { AdminAuditLogListData } from "@/types/api/admin-audit";
|
||||
|
||||
export function AuditLogsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["audit", "common"]);
|
||||
const tRef = useTranslationRef(["audit", "common"]);
|
||||
const exportLabels = useExportLabels("auditLogs");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminAuditLogListData | null>(null);
|
||||
@@ -69,18 +73,16 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate, t]);
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||
|
||||
const meta = data?.meta;
|
||||
|
||||
@@ -200,11 +202,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="audit-logs-table">
|
||||
@@ -219,7 +217,9 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={6} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("empty")}
|
||||
|
||||
@@ -32,11 +32,14 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||
import { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
||||
@@ -106,6 +109,7 @@ export function OddsConfigDocScreen({
|
||||
onVersionIdChange,
|
||||
}: OddsConfigDocScreenProps) {
|
||||
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
@@ -144,15 +148,16 @@ export function OddsConfigDocScreen({
|
||||
const refreshTypes = useCallback(async () => {
|
||||
setLoadingTypes(true);
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setTypes([]);
|
||||
} finally {
|
||||
setLoadingTypes(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
@@ -161,23 +166,21 @@ export function OddsConfigDocScreen({
|
||||
const d = await getAllConfigVersions(getOddsVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
useAsyncEffect(() => {
|
||||
if (workspace) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void refreshTypes();
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshTypes, refreshList, workspace]);
|
||||
void Promise.all([refreshTypes(), refreshList()]);
|
||||
}, [workspace]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
@@ -186,13 +189,15 @@ export function OddsConfigDocScreen({
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace) {
|
||||
@@ -638,9 +643,11 @@ export function OddsConfigDocScreen({
|
||||
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
|
||||
|
||||
{resolvedLoadingDetail || resolvedLoadingTypes ? (
|
||||
<p className={cn("text-center text-sm text-muted-foreground", mergedLayout ? "py-6" : "py-8")}>
|
||||
{t("odds.loadingDetails", { ns: "config" })}
|
||||
</p>
|
||||
<AdminLoadingState
|
||||
className={cn(mergedLayout ? "py-6" : "py-8")}
|
||||
minHeight="6rem"
|
||||
label={t("odds.loadingDetails", { ns: "config" })}
|
||||
/>
|
||||
) : resolvedPlayCode ? (
|
||||
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
||||
|
||||
@@ -41,11 +41,14 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_PLAY_SWITCH_MANAGE } from "@/lib/admin-prd";
|
||||
@@ -138,6 +141,7 @@ function buildPlayConfigSavePayload(
|
||||
|
||||
export function PlayConfigDocScreen() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
||||
@@ -165,19 +169,18 @@ export function PlayConfigDocScreen() {
|
||||
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshList]);
|
||||
useAsyncEffect(() => {
|
||||
void refreshList();
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
const requestSeq = detailRequestSeq.current + 1;
|
||||
@@ -196,7 +199,9 @@ export function PlayConfigDocScreen() {
|
||||
if (detailRequestSeq.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
@@ -204,7 +209,7 @@ export function PlayConfigDocScreen() {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0) {
|
||||
@@ -538,7 +543,7 @@ export function PlayConfigDocScreen() {
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -19,7 +19,14 @@ import {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
import { getAdminSettings, updateAdminSetting } from "@/api/admin-settings";
|
||||
import { updateAdminSetting } from "@/api/admin-settings";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import {
|
||||
loadApplyRebateToPayoutSetting,
|
||||
setCachedApplyRebateToPayoutSetting,
|
||||
} from "@/lib/admin-settlement-settings-cache";
|
||||
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -33,6 +40,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
@@ -58,7 +66,6 @@ import {
|
||||
} from "@/modules/config/doc/odds-rebate-rates";
|
||||
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
||||
|
||||
const SETTLEMENT_GROUP = "settlement";
|
||||
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
|
||||
|
||||
function dimensionDistinctPrimaryScopePercents(
|
||||
@@ -98,6 +105,7 @@ export function RebateConfigDocScreen({
|
||||
onVersionIdChange,
|
||||
}: RebateConfigDocScreenProps) {
|
||||
const { t } = useTranslation(["config", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
|
||||
@@ -137,54 +145,52 @@ export function RebateConfigDocScreen({
|
||||
|
||||
const refreshTypes = useCallback(async () => {
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setTypes([]);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
try {
|
||||
const d = await getAllConfigVersions(getOddsVersions);
|
||||
setListRows(d.items);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setListRows([]);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const loadWinEnjoySetting = useCallback(async () => {
|
||||
setWinEnjoyLoading(true);
|
||||
try {
|
||||
const res = await getAdminSettings(SETTLEMENT_GROUP);
|
||||
const hit = res.items.find((item) => item.key === APPLY_REBATE_TO_PAYOUT_KEY);
|
||||
setApplyRebateToPayout(Boolean(hit?.value));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setWinEnjoyLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
useAsyncEffect(() => {
|
||||
if (workspace) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(async () => {
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
await refreshTypes();
|
||||
await refreshList();
|
||||
await Promise.all([refreshTypes(), refreshList()]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [refreshTypes, refreshList, workspace]);
|
||||
})();
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadWinEnjoySetting();
|
||||
});
|
||||
}, [loadWinEnjoySetting]);
|
||||
useAsyncEffect(() => {
|
||||
void (async () => {
|
||||
setWinEnjoyLoading(true);
|
||||
try {
|
||||
setApplyRebateToPayout(await loadApplyRebateToPayoutSetting());
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
} finally {
|
||||
setWinEnjoyLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) {
|
||||
@@ -202,6 +208,7 @@ export function RebateConfigDocScreen({
|
||||
setWinEnjoySaving(true);
|
||||
try {
|
||||
await updateAdminSetting(APPLY_REBATE_TO_PAYOUT_KEY, checked);
|
||||
setCachedApplyRebateToPayoutSetting(checked);
|
||||
setApplyRebateToPayout(checked);
|
||||
toast.success(t("rebate.winEnjoy.saveSuccess", { ns: "config" }));
|
||||
} catch (e) {
|
||||
@@ -214,8 +221,7 @@ export function RebateConfigDocScreen({
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const pt = await getAdminPlayTypes();
|
||||
const typeList = pt.items;
|
||||
const typeList = await ensureAdminPlayTypesLoaded(getAdminPlayTypes);
|
||||
setTypes(typeList);
|
||||
const d = await getOddsVersion(id);
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
@@ -225,13 +231,15 @@ export function RebateConfigDocScreen({
|
||||
setP3(inferRebatePercentFromDimension(3, rows, typeList));
|
||||
setP4(inferRebatePercentFromDimension(4, rows, typeList));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace) {
|
||||
@@ -614,7 +622,7 @@ export function RebateConfigDocScreen({
|
||||
) : null}
|
||||
|
||||
{resolvedLoading || resolvedLoadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
||||
import {
|
||||
@@ -45,7 +46,9 @@ import {
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
|
||||
@@ -86,6 +89,7 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
|
||||
|
||||
export function RiskCapDocScreen() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_RISK_CAP_MANAGE]);
|
||||
@@ -113,19 +117,18 @@ export function RiskCapDocScreen() {
|
||||
const d = await getAllConfigVersions(getRiskCapVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshList]);
|
||||
useAsyncEffect(() => {
|
||||
void refreshList();
|
||||
}, []);
|
||||
|
||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
||||
const defaultRow = rows.find(isDefaultRiskRow);
|
||||
@@ -151,14 +154,16 @@ export function RiskCapDocScreen() {
|
||||
setDraftRows(mapped);
|
||||
syncDefaultCapFromRows(mapped);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
syncDefaultCapFromRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0) {
|
||||
@@ -498,7 +503,7 @@ export function RiskCapDocScreen() {
|
||||
}
|
||||
>
|
||||
{loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.loadingDetails", { ns: "config" })}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
|
||||
) : specialRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||
) : (
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSetting,
|
||||
} from "@/api/admin-settings";
|
||||
import { getAdminSettings, updateAdminSettingsBatch } from "@/api/admin-settings";
|
||||
import { useOptionalAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
|
||||
import { WALLET_GROUP, WALLET_KEYS } from "@/modules/settings/settings-keys";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||
@@ -15,15 +14,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
const WALLET_GROUP = "wallet";
|
||||
|
||||
const KEYS = {
|
||||
IN_MIN: "wallet.transfer_in_min_minor",
|
||||
IN_MAX: "wallet.transfer_in_max_minor",
|
||||
OUT_MIN: "wallet.transfer_out_min_minor",
|
||||
OUT_MAX: "wallet.transfer_out_max_minor",
|
||||
} as const;
|
||||
|
||||
function minorUnitsToDisplay(n: unknown, decimals = 2): string {
|
||||
const num = Number(n);
|
||||
if (!Number.isFinite(num)) return "";
|
||||
@@ -43,12 +33,24 @@ interface Draft {
|
||||
outMax: string;
|
||||
}
|
||||
|
||||
function draftFromKv(kv: Record<string, unknown>): Draft {
|
||||
return {
|
||||
inMin: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MIN] ?? 100),
|
||||
inMax: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MAX] ?? 0),
|
||||
outMin: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MIN] ?? 100),
|
||||
outMax: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MAX] ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
type WalletConfigDocScreenProps = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const shared = useOptionalAdminSettingsData();
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const [draft, setDraft] = useState<Draft>({
|
||||
inMin: "",
|
||||
@@ -57,55 +59,81 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
|
||||
outMax: "",
|
||||
});
|
||||
const [saved, setSaved] = useState<Draft>({ inMin: "", inMax: "", outMin: "", outMax: "" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [standaloneLoading, setStandaloneLoading] = useState(!embedded);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const dirty =
|
||||
draft.inMin !== saved.inMin ||
|
||||
draft.inMax !== saved.inMax ||
|
||||
draft.outMin !== saved.outMin ||
|
||||
draft.outMax !== saved.outMax;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const loading = embedded ? (shared?.loading ?? true) : standaloneLoading;
|
||||
|
||||
const loadStandalone = useCallback(async () => {
|
||||
setStandaloneLoading(true);
|
||||
try {
|
||||
const res = await getAdminSettings(WALLET_GROUP);
|
||||
const kv: Record<string, unknown> = {};
|
||||
for (const item of res.items) {
|
||||
kv[item.key] = item.value;
|
||||
}
|
||||
const d: Draft = {
|
||||
inMin: minorUnitsToDisplay(kv[KEYS.IN_MIN] ?? 100),
|
||||
inMax: minorUnitsToDisplay(kv[KEYS.IN_MAX] ?? 0),
|
||||
outMin: minorUnitsToDisplay(kv[KEYS.OUT_MIN] ?? 100),
|
||||
outMax: minorUnitsToDisplay(kv[KEYS.OUT_MAX] ?? 0),
|
||||
};
|
||||
const d = draftFromKv(kv);
|
||||
setDraft(d);
|
||||
setSaved(d);
|
||||
setDirty(false);
|
||||
} catch {
|
||||
toast.error(t("wallet.loadFailed", { ns: "config" }));
|
||||
toast.error(tRef.current("wallet.loadFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStandaloneLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
if (!embedded) {
|
||||
void loadStandalone();
|
||||
}
|
||||
}, [embedded, loadStandalone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embedded || shared?.kv === null || shared?.kv === undefined) {
|
||||
return;
|
||||
}
|
||||
const d = draftFromKv(shared.kv);
|
||||
setDraft(d);
|
||||
setSaved(d);
|
||||
}, [embedded, shared?.kv]);
|
||||
|
||||
const handleChange = (field: keyof Draft, value: string) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const items = [];
|
||||
if (draft.inMin !== saved.inMin) {
|
||||
items.push({ key: WALLET_KEYS.IN_MIN, value: displayToMinorUnits(draft.inMin) });
|
||||
}
|
||||
if (draft.inMax !== saved.inMax) {
|
||||
items.push({ key: WALLET_KEYS.IN_MAX, value: displayToMinorUnits(draft.inMax) });
|
||||
}
|
||||
if (draft.outMin !== saved.outMin) {
|
||||
items.push({ key: WALLET_KEYS.OUT_MIN, value: displayToMinorUnits(draft.outMin) });
|
||||
}
|
||||
if (draft.outMax !== saved.outMax) {
|
||||
items.push({ key: WALLET_KEYS.OUT_MAX, value: displayToMinorUnits(draft.outMax) });
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAdminSetting(KEYS.IN_MIN, displayToMinorUnits(draft.inMin));
|
||||
await updateAdminSetting(KEYS.IN_MAX, displayToMinorUnits(draft.inMax));
|
||||
await updateAdminSetting(KEYS.OUT_MIN, displayToMinorUnits(draft.outMin));
|
||||
await updateAdminSetting(KEYS.OUT_MAX, displayToMinorUnits(draft.outMax));
|
||||
await updateAdminSettingsBatch(items);
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const item of items) {
|
||||
updates[item.key] = item.value;
|
||||
}
|
||||
shared?.patchKv(updates);
|
||||
toast.success(t("wallet.saveSuccess", { ns: "config" }));
|
||||
setSaved(draft);
|
||||
setDirty(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("wallet.saveFailed", { ns: "config" }),
|
||||
@@ -186,13 +214,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
|
||||
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDraft(saved);
|
||||
setDirty(false);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setDraft(saved)} disabled={saving}>
|
||||
{t("wallet.discard", { ns: "config" })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -37,6 +40,7 @@ type PoolFilter = "all" | "sold_out" | "high_risk";
|
||||
|
||||
export function RiskCapRuntimePanel() {
|
||||
const { t } = useTranslation(["config", "risk", "draws", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const [draws, setDraws] = useState<AdminDrawListItem[]>([]);
|
||||
const [drawsLoading, setDrawsLoading] = useState(true);
|
||||
const [drawId, setDrawId] = useState<string>("");
|
||||
@@ -64,12 +68,14 @@ export function RiskCapRuntimePanel() {
|
||||
setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDraws([]);
|
||||
} finally {
|
||||
setDrawsLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const loadPools = useCallback(async () => {
|
||||
if (!drawId) {
|
||||
@@ -94,24 +100,22 @@ export function RiskCapRuntimePanel() {
|
||||
setPools(data.items);
|
||||
setCurrencyCode(data.currency_code);
|
||||
} catch (e) {
|
||||
setPoolsError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setPoolsError(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setPools([]);
|
||||
} finally {
|
||||
setPoolsLoading(false);
|
||||
}
|
||||
}, [appliedNumber, drawId, poolFilter, t]);
|
||||
}, [appliedNumber, drawId, poolFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadDraws();
|
||||
});
|
||||
}, [loadDraws]);
|
||||
useAsyncEffect(() => {
|
||||
void loadDraws();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPools();
|
||||
});
|
||||
}, [loadPools]);
|
||||
useAsyncEffect(() => {
|
||||
void loadPools();
|
||||
}, [appliedNumber, drawId, poolFilter]);
|
||||
|
||||
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;
|
||||
|
||||
@@ -226,11 +230,7 @@ export function RiskCapRuntimePanel() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{poolsLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableLoadingRow colSpan={5} />
|
||||
) : pools.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -10,6 +9,8 @@ import {
|
||||
getOddsVersion,
|
||||
getOddsVersions,
|
||||
} from "@/api/admin-config";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
|
||||
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
@@ -42,7 +43,7 @@ export function useOddsConfigWorkspace(
|
||||
selectedId: string,
|
||||
onSelectedIdChange: (id: string) => void,
|
||||
): OddsConfigWorkspace {
|
||||
const { t } = useTranslation("common");
|
||||
const tRef = useTranslationRef("common");
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||
@@ -52,6 +53,7 @@ export function useOddsConfigWorkspace(
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const detailRequestSeq = useRef(0);
|
||||
const bootstrappedRef = useRef(false);
|
||||
|
||||
const applyDetail = useCallback((next: OddsVersionDetail) => {
|
||||
setDetail(next);
|
||||
@@ -61,15 +63,14 @@ export function useOddsConfigWorkspace(
|
||||
const refreshTypes = useCallback(async () => {
|
||||
setLoadingTypes(true);
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed"));
|
||||
setTypes([]);
|
||||
} finally {
|
||||
setLoadingTypes(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
@@ -78,13 +79,13 @@ export function useOddsConfigWorkspace(
|
||||
const d = await getAllConfigVersions(getOddsVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed");
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(
|
||||
async (id: number) => {
|
||||
@@ -100,7 +101,7 @@ export function useOddsConfigWorkspace(
|
||||
if (seq !== detailRequestSeq.current) {
|
||||
return;
|
||||
}
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed"));
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
@@ -109,7 +110,7 @@ export function useOddsConfigWorkspace(
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyDetail, t],
|
||||
[applyDetail],
|
||||
);
|
||||
|
||||
const reloadDetail = useCallback(async () => {
|
||||
@@ -124,8 +125,11 @@ export function useOddsConfigWorkspace(
|
||||
}, [loadDetail, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTypes();
|
||||
void refreshList();
|
||||
if (bootstrappedRef.current) {
|
||||
return;
|
||||
}
|
||||
bootstrappedRef.current = true;
|
||||
void Promise.all([refreshTypes(), refreshList()]);
|
||||
}, [refreshTypes, refreshList]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||
import {
|
||||
DailyTrendChart,
|
||||
PlayBreakdownChart,
|
||||
@@ -356,6 +357,112 @@ export function DashboardPlayRankingCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardAgentRankingCard({
|
||||
analytics,
|
||||
}: {
|
||||
analytics: DashboardAnalyticsState;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const {
|
||||
enabled,
|
||||
rankingMetric,
|
||||
loading,
|
||||
topAgentRows,
|
||||
currency,
|
||||
formatMoney,
|
||||
formatSignedMoney,
|
||||
} = analytics;
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metricValue = (row: (typeof topAgentRows)[number]): number => {
|
||||
if (rankingMetric === "payout") {
|
||||
return row.total_payout_minor;
|
||||
}
|
||||
if (rankingMetric === "profit") {
|
||||
return row.approx_house_gross_minor;
|
||||
}
|
||||
return row.total_bet_minor;
|
||||
};
|
||||
|
||||
const maxAbs = Math.max(1, ...topAgentRows.map((r) => Math.abs(metricValue(r))));
|
||||
|
||||
const formatRowValue = (row: (typeof topAgentRows)[number]): string => {
|
||||
const v = metricValue(row);
|
||||
if (rankingMetric === "profit") {
|
||||
return formatSignedMoney(v, currency);
|
||||
}
|
||||
return formatMoney(v, currency);
|
||||
};
|
||||
|
||||
const barColor = (row: (typeof topAgentRows)[number]): string => {
|
||||
if (rankingMetric === "bet") {
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
if (rankingMetric === "payout") {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
}
|
||||
return row.approx_house_gross_minor >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
|
||||
<CardHeader className="space-y-2 border-b border-border/60 px-4 py-3">
|
||||
<CardTitle className="text-sm font-semibold">{t("analytics.agentRanking")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`analytics.rankingMetrics.${rankingMetric}`)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
|
||||
{loading ? (
|
||||
<Skeleton className="h-[210px] w-full" />
|
||||
) : topAgentRows.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{topAgentRows.map((row, idx) => {
|
||||
const v = metricValue(row);
|
||||
const pct = (Math.abs(v) / maxAbs) * 100;
|
||||
const color = barColor(row);
|
||||
return (
|
||||
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="mt-0.5 w-5 shrink-0 text-center text-[11px] font-semibold text-muted-foreground">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
|
||||
{formatRowValue(row)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted/30">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.max(2, pct)}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noAgentData")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单列堆叠布局(兼容旧用法) */
|
||||
export function DashboardAnalyticsPanel({
|
||||
enabled,
|
||||
|
||||
@@ -20,14 +20,12 @@ import {
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { getAdminPlayTypes } from "@/api/admin-config";
|
||||
import {
|
||||
getAdminPlayTypesLoadPromise,
|
||||
getCachedAdminPlayTypes,
|
||||
resolveAdminPlayTypeDisplayName,
|
||||
} from "@/lib/admin-play-types";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import {
|
||||
DashboardAnalyticsMain,
|
||||
DashboardAgentRankingCard,
|
||||
DashboardPlayRankingCard,
|
||||
} from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
@@ -50,7 +48,11 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorUnits,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
@@ -66,9 +68,10 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
type HotPlayTab = "4D" | "3D" | "2D" | "special";
|
||||
|
||||
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
const major = minor / 10 ** decimals;
|
||||
const major = safeMinor / 10 ** decimals;
|
||||
try {
|
||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||
style: "currency",
|
||||
@@ -77,7 +80,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(major);
|
||||
} catch {
|
||||
return formatAdminMinorUnits(minor, code, decimals);
|
||||
return formatAdminMinorUnits(safeMinor, code, decimals);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,28 +165,8 @@ export function DashboardConsole(): ReactElement {
|
||||
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
||||
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
||||
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
||||
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
const loadPlayOptions = useCallback(async () => {
|
||||
try {
|
||||
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
|
||||
setPlayOptions(
|
||||
getCachedAdminPlayTypes().map((item) => ({
|
||||
code: item.play_code,
|
||||
label:
|
||||
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setPlayOptions([]);
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPlayOptions();
|
||||
});
|
||||
}, [loadPlayOptions]);
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
@@ -230,27 +213,30 @@ export function DashboardConsole(): ReactElement {
|
||||
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const currency =
|
||||
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
|
||||
const canFinance = capabilities?.draw_finance_risk ?? false;
|
||||
const platformLocked = platformRisk?.locked_amount ?? 0;
|
||||
const platformCap = platformRisk?.cap_amount ?? 0;
|
||||
const platformUsagePct = platformRisk?.usage_percent ?? 0;
|
||||
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
|
||||
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
|
||||
const rawPlatformUsagePct = platformRisk?.usage_percent;
|
||||
const platformUsagePct =
|
||||
typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct)
|
||||
? Math.min(100, Math.max(0, rawPlatformUsagePct))
|
||||
: platformCap > 0
|
||||
? (platformLocked / platformCap) * 100
|
||||
: 0;
|
||||
|
||||
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||
|
||||
@@ -359,10 +345,16 @@ export function DashboardConsole(): ReactElement {
|
||||
href="/admin/risk"
|
||||
title={t("riskCapUsage")}
|
||||
value={`${platformUsagePct.toFixed(1)}%`}
|
||||
subtitle={t("platformLockedAndCap", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
cap: formatMoneyMinor(platformCap, currency),
|
||||
})}
|
||||
subtitle={
|
||||
platformCap > 0
|
||||
? t("platformLockedAndCap", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
cap: formatMoneyMinor(platformCap, currency),
|
||||
})
|
||||
: t("platformCapNotConfigured", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
})
|
||||
}
|
||||
actionLabel={t("occupancyDetails")}
|
||||
icon={<Shield className="size-5" aria-hidden />}
|
||||
accent={
|
||||
@@ -542,6 +534,7 @@ export function DashboardConsole(): ReactElement {
|
||||
{showAnalytics ? (
|
||||
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
||||
<DashboardPlayRankingCard analytics={analytics} />
|
||||
<DashboardAgentRankingCard analytics={analytics} />
|
||||
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorDecimal,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildBatchProgressConfig,
|
||||
@@ -53,6 +58,74 @@ export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
type DashboardFinanceMetricCell = {
|
||||
key: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
emphasize: boolean;
|
||||
};
|
||||
|
||||
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */
|
||||
function formatDashboardMetricAmount(
|
||||
minor: number,
|
||||
currencyCode: string | null,
|
||||
formatMoney: MoneyFormatter,
|
||||
): { display: string; title: string } {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
return {
|
||||
display: formatAdminMinorDecimal(safeMinor, code, decimals),
|
||||
title: formatMoney(safeMinor, currencyCode),
|
||||
};
|
||||
}
|
||||
|
||||
function DashboardFinanceMetricCells({
|
||||
cells,
|
||||
currency,
|
||||
formatMoney,
|
||||
}: {
|
||||
cells: readonly DashboardFinanceMetricCell[];
|
||||
currency: string | null;
|
||||
formatMoney: MoneyFormatter;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{cells.map((cell) => {
|
||||
const { display, title } = formatDashboardMetricAmount(
|
||||
cell.amount,
|
||||
currency,
|
||||
formatMoney,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg px-1 py-2 ring-1",
|
||||
cell.emphasize
|
||||
? "bg-primary/6 ring-primary/15"
|
||||
: "bg-muted/30 ring-border/50",
|
||||
)}
|
||||
>
|
||||
<p className="line-clamp-2 text-center text-[10px] leading-tight text-muted-foreground">
|
||||
{cell.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 truncate text-center text-[10px] font-bold tabular-nums leading-tight",
|
||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{display}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function usageBarFill(pct: number): string {
|
||||
if (pct >= 95) {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
@@ -485,10 +558,11 @@ export function PayoutPanelSnapshot({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_payout_minor;
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const hasPayout = win + jackpot > 0;
|
||||
const bet = coerceAdminMinor(finance.total_bet_minor);
|
||||
const win = coerceAdminMinor(finance.total_win_payout_minor);
|
||||
const jackpot = coerceAdminMinor(finance.total_jackpot_win_minor);
|
||||
const payout = coerceAdminMinor(finance.total_payout_minor);
|
||||
const hasPayout = payout > 0 || win + jackpot > 0;
|
||||
|
||||
if (bet <= 0 && !hasPayout) {
|
||||
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
|
||||
@@ -502,29 +576,7 @@ export function PayoutPanelSnapshot({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={cn(
|
||||
"rounded-lg px-1.5 py-2 ring-1",
|
||||
cell.emphasize
|
||||
? "bg-primary/6 ring-primary/15"
|
||||
: "bg-muted/30 ring-border/50",
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
|
||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatMoney(cell.amount, currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
|
||||
{hasPayout ? (
|
||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
|
||||
) : (
|
||||
@@ -983,7 +1035,10 @@ export function ResultBatchQueueSummary({
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { pending_review_total, pending_draw_count, published_total, batch_total } = queue;
|
||||
const pendingReviewTotal = coerceAdminMinor(queue.pending_review_total);
|
||||
const pendingDrawCount = coerceAdminMinor(queue.pending_draw_count);
|
||||
const publishedTotal = coerceAdminMinor(queue.published_total);
|
||||
const batchTotal = coerceAdminMinor(queue.batch_total);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
@@ -994,7 +1049,7 @@ export function ResultBatchQueueSummary({
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
)}
|
||||
>
|
||||
{pending_review_total}
|
||||
{pendingReviewTotal}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
|
||||
</div>
|
||||
@@ -1005,18 +1060,16 @@ export function ResultBatchQueueSummary({
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
)}
|
||||
>
|
||||
{published_total}
|
||||
{publishedTotal}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
|
||||
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
|
||||
{batch_total}
|
||||
{pendingDrawCount > 0 ? pendingDrawCount : batchTotal}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{pending_draw_count > 0
|
||||
? t("batchPendingDrawsCount", { count: pending_draw_count })
|
||||
: t("batchTotal")}
|
||||
{pendingDrawCount > 0 ? t("batchPendingDraws") : t("batchTotal")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1032,10 +1085,14 @@ export function PlatformLifetimePayoutSnapshot({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_minor;
|
||||
const jackpot = finance.total_jackpot_minor;
|
||||
const hasPayout = win + jackpot > 0;
|
||||
const bet = coerceAdminMinor(finance.total_bet_minor);
|
||||
const payout = coerceAdminMinor(finance.total_payout_minor);
|
||||
let win = coerceAdminMinor(finance.total_win_minor);
|
||||
let jackpot = coerceAdminMinor(finance.total_jackpot_minor);
|
||||
if (payout > 0 && win + jackpot === 0) {
|
||||
win = payout;
|
||||
}
|
||||
const hasPayout = payout > 0 || win + jackpot > 0;
|
||||
|
||||
if (bet <= 0 && !hasPayout) {
|
||||
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
|
||||
@@ -1049,29 +1106,7 @@ export function PlatformLifetimePayoutSnapshot({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={cn(
|
||||
"rounded-lg px-1.5 py-2 ring-1",
|
||||
cell.emphasize
|
||||
? "bg-primary/6 ring-primary/15"
|
||||
: "bg-muted/30 ring-border/50",
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
|
||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatMoney(cell.amount, currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
|
||||
{!hasPayout ? (
|
||||
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
|
||||
{t("platformNoPayoutYet")}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { format, subDays } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorUnits,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminDashboardAnalyticsData,
|
||||
AdminDashboardAnalyticsAgentRow,
|
||||
DashboardAnalyticsMetric,
|
||||
DashboardAnalyticsPeriod,
|
||||
} from "@/types/api/admin-dashboard-analytics";
|
||||
@@ -27,9 +34,10 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
|
||||
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
|
||||
|
||||
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
const major = minor / 10 ** decimals;
|
||||
const major = safeMinor / 10 ** decimals;
|
||||
try {
|
||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||
style: "currency",
|
||||
@@ -38,7 +46,7 @@ export function formatDashboardMoneyMinor(minor: number, currencyCode: string |
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(major);
|
||||
} catch {
|
||||
return formatAdminMinorUnits(minor, code, decimals);
|
||||
return formatAdminMinorUnits(safeMinor, code, decimals);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +66,7 @@ export function useDashboardAnalytics({
|
||||
playOptions: { code: string; label: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
const playLabel = useAdminPlayCodeLabel();
|
||||
|
||||
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
||||
@@ -94,19 +103,18 @@ export function useDashboardAnalytics({
|
||||
const needsAuthSync =
|
||||
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
||||
setError(
|
||||
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
|
||||
needsAuthSync
|
||||
? tRef.current("warnings.apiResourceMissing")
|
||||
: raw || tRef.current("warnings.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled, period, playCode, customFrom, customTo, t]);
|
||||
}, [enabled, period, playCode, customFrom, customTo]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [enabled, period, playCode, customFrom, customTo]);
|
||||
|
||||
const currency = data?.currency_code ?? null;
|
||||
const summary = data?.summary;
|
||||
@@ -152,6 +160,28 @@ export function useDashboardAnalytics({
|
||||
return rows.slice(0, 5);
|
||||
}, [data, rankingMetric]);
|
||||
|
||||
const metricAgentValue = useCallback(
|
||||
(row: AdminDashboardAnalyticsAgentRow): number => {
|
||||
if (rankingMetric === "payout") {
|
||||
return row.total_payout_minor;
|
||||
}
|
||||
if (rankingMetric === "profit") {
|
||||
return row.approx_house_gross_minor;
|
||||
}
|
||||
return row.total_bet_minor;
|
||||
},
|
||||
[rankingMetric],
|
||||
);
|
||||
|
||||
const topAgentRows = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const rows = [...data.agent_breakdown];
|
||||
rows.sort((a, b) => metricAgentValue(b) - metricAgentValue(a));
|
||||
return rows.slice(0, 5);
|
||||
}, [data, metricAgentValue]);
|
||||
|
||||
const sparklines = useMemo(() => {
|
||||
const series = data?.daily_series ?? [];
|
||||
return {
|
||||
@@ -183,6 +213,7 @@ export function useDashboardAnalytics({
|
||||
playOptions,
|
||||
resolvePlayLabel,
|
||||
topPlayRows,
|
||||
topAgentRows,
|
||||
sparklines,
|
||||
formatMoney: formatDashboardMoneyMinor,
|
||||
formatSignedMoney: formatDashboardSignedMoneyMinor,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -17,6 +19,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -50,6 +53,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
|
||||
export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const idNum = Number(drawId);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||
@@ -67,7 +71,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(t("invalidDrawId"));
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -77,11 +81,11 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
setData(await getAdminDraw(idNum));
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
||||
if (!Number.isFinite(idNum)) return;
|
||||
@@ -97,15 +101,12 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
@@ -11,6 +13,7 @@ import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -37,6 +40,7 @@ import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
||||
|
||||
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "settlement", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "settlement", "common"]);
|
||||
useAdminCurrencyCatalog();
|
||||
const idNum = Number(drawId);
|
||||
const profile = useAdminProfile();
|
||||
@@ -54,7 +58,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum) || idNum < 1) {
|
||||
setErr(t("invalidDrawId"));
|
||||
setErr(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -63,12 +67,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
try {
|
||||
setData(await getAdminDrawFinanceSummary(idNum));
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
async function runSettlement(): Promise<void> {
|
||||
if (!Number.isFinite(idNum) || idNum < 1) return;
|
||||
@@ -84,14 +88,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (err || !data) {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -34,6 +37,7 @@ import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
|
||||
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const router = useRouter();
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
@@ -50,7 +54,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(t("invalidDrawId"));
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -60,18 +64,15 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
setData(await getAdminDrawResultBatches(idNum));
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
|
||||
if (!Number.isFinite(batchNum)) return undefined;
|
||||
@@ -115,7 +116,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDrawResultBatches } from "@/api/admin-draws";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,6 +31,7 @@ import { DrawStatusBadge } from "./draw-status-badge";
|
||||
|
||||
export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
@@ -39,7 +43,7 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(t("invalidDrawId"));
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -49,21 +53,18 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
setData(await getAdminDrawResultBatches(idNum));
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Dices, Rocket, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -56,6 +59,7 @@ function randomDrawNumber4d(): string {
|
||||
|
||||
export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
@@ -73,7 +77,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(t("invalidDrawId"));
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -83,18 +87,15 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
setData(await getAdminDrawResultBatches(idNum));
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
|
||||
data,
|
||||
@@ -148,7 +149,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Ban, Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -14,10 +16,12 @@ import {
|
||||
} from "@/api/admin-draws";
|
||||
import { formatAdminInstant } from "@/lib/admin-datetime";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -86,6 +90,7 @@ function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): s
|
||||
|
||||
export function DrawsIndexConsole() {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const exportLabels = useExportLabels("drawsList");
|
||||
useAdminCurrencyCatalog();
|
||||
const defaultCurrency = "NPR";
|
||||
@@ -106,6 +111,8 @@ export function DrawsIndexConsole() {
|
||||
const [draftStatus, setDraftStatus] = useState("");
|
||||
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
||||
const [appliedStatus, setAppliedStatus] = useState("");
|
||||
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState<number>(10);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
@@ -137,17 +144,18 @@ export function DrawsIndexConsole() {
|
||||
appliedStatus.trim() === "" || appliedStatus === DRAW_FILTER_ALL
|
||||
? undefined
|
||||
: appliedStatus.trim(),
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
|
||||
async function generatePlan(): Promise<void> {
|
||||
setGenerating(true);
|
||||
@@ -168,12 +176,9 @@ export function DrawsIndexConsole() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
|
||||
const handleSelectAll = useCallback((checked: boolean) => {
|
||||
if (checked && data) {
|
||||
@@ -293,6 +298,12 @@ export function DrawsIndexConsole() {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<AdminAgentFilter
|
||||
id="draws-agent-filter"
|
||||
className="admin-list-field sm:w-[14rem]"
|
||||
value={agentNodeId}
|
||||
onChange={setAgentNodeId}
|
||||
/>
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="draw-filter-no" className="sm:w-10 sm:shrink-0">
|
||||
{t("drawNo")}
|
||||
@@ -347,6 +358,7 @@ export function DrawsIndexConsole() {
|
||||
onClick={() => {
|
||||
setAppliedDrawNo(draftDrawNo);
|
||||
setAppliedStatus(draftStatus);
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -358,8 +370,10 @@ export function DrawsIndexConsole() {
|
||||
onClick={() => {
|
||||
setDraftDrawNo("");
|
||||
setDraftStatus("");
|
||||
setAgentNodeId(undefined);
|
||||
setAppliedDrawNo("");
|
||||
setAppliedStatus("");
|
||||
setAppliedAgentNodeId(undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -410,11 +424,7 @@ export function DrawsIndexConsole() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableLoadingRow colSpan={10} />
|
||||
) : data === null || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { getAdminPageBundle } from "@/lib/admin-permission-bundles";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
@@ -138,6 +141,7 @@ function formToPayload(
|
||||
|
||||
export function IntegrationSitesConsole() {
|
||||
const { t } = useTranslation("config");
|
||||
const tRef = useTranslationRef("config");
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(
|
||||
profile?.permissions,
|
||||
@@ -174,18 +178,16 @@ export function IntegrationSitesConsole() {
|
||||
setItems(data.items);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
|
||||
error instanceof LotteryApiBizError ? error.message : tRef.current("integrationSites.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openCreate(): void {
|
||||
setMode("create");
|
||||
@@ -352,7 +354,7 @@ export function IntegrationSitesConsole() {
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.loading")}</p>
|
||||
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import {
|
||||
getAdminJackpotPoolAdjustments,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
|
||||
type Draft = {
|
||||
contribution_rate: string;
|
||||
@@ -78,6 +81,7 @@ type JackpotPoolsConsoleProps = {
|
||||
|
||||
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["jackpot", "common"]);
|
||||
const tRef = useTranslationRef(["jackpot", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
|
||||
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);
|
||||
@@ -114,17 +118,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
setAdjustmentDrafts(adjDrafts);
|
||||
setAdjustmentRows(adjRows);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const updateDraft = (id: number, patch: Partial<Draft>) => {
|
||||
setDrafts((prev) => ({
|
||||
@@ -229,7 +231,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
|
||||
const poolList = (
|
||||
<div className={embedded ? "space-y-4" : "space-y-8"}>
|
||||
{loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null}
|
||||
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -13,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -74,7 +77,7 @@ function JackpotRecordTableSection({
|
||||
/>
|
||||
</div>
|
||||
{loading && !hasData ? (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground">{t("states.loading")}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="px-4 py-6" />
|
||||
) : (
|
||||
<div className={TABLE_IN_SHELL_CLASS}>{children}</div>
|
||||
)}
|
||||
@@ -83,8 +86,12 @@ function JackpotRecordTableSection({
|
||||
);
|
||||
}
|
||||
|
||||
type RecordTab = "payout" | "contribution";
|
||||
|
||||
export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) {
|
||||
const { t } = useTranslation(["jackpot", "common"]);
|
||||
const tRef = useTranslationRef(["jackpot"]);
|
||||
const [recordTab, setRecordTab] = useState<RecordTab>("payout");
|
||||
const payoutExport = useExportLabels("jackpotPayouts");
|
||||
const contributionExport = useExportLabels("jackpotContributions");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
@@ -100,7 +107,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
const [cPer, setCPer] = useState(10);
|
||||
|
||||
const [loadingP, setLoadingP] = useState(true);
|
||||
const [loadingC, setLoadingC] = useState(true);
|
||||
const [loadingC, setLoadingC] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const loadPayouts = useCallback(async () => {
|
||||
@@ -113,11 +120,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
});
|
||||
setPayouts(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("payoutLoadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("payoutLoadFailed"));
|
||||
} finally {
|
||||
setLoadingP(false);
|
||||
}
|
||||
}, [pPage, pPer, appliedDrawNo, t]);
|
||||
}, [pPage, pPer, appliedDrawNo]);
|
||||
|
||||
const loadContribs = useCallback(async () => {
|
||||
setLoadingC(true);
|
||||
@@ -129,23 +136,22 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
});
|
||||
setContribs(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("contributionLoadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("contributionLoadFailed"));
|
||||
} finally {
|
||||
setLoadingC(false);
|
||||
}
|
||||
}, [cPage, cPer, appliedDrawNo, t]);
|
||||
}, [cPage, cPer, appliedDrawNo]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPayouts();
|
||||
});
|
||||
}, [loadPayouts]);
|
||||
useAsyncEffect(() => {
|
||||
void loadPayouts();
|
||||
}, [pPage, pPer, appliedDrawNo]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadContribs();
|
||||
});
|
||||
}, [loadContribs]);
|
||||
useAsyncEffect(() => {
|
||||
if (recordTab !== "contribution") {
|
||||
return;
|
||||
}
|
||||
void loadContribs();
|
||||
}, [recordTab, cPage, cPer, appliedDrawNo]);
|
||||
|
||||
const applyDraw = () => {
|
||||
setAppliedDrawNo(drawNo);
|
||||
@@ -328,9 +334,27 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
{filterBlock}
|
||||
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b border-border/70 pb-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={recordTab === "payout" ? "default" : "outline"}
|
||||
onClick={() => setRecordTab("payout")}
|
||||
>
|
||||
{t("payoutRecords")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={recordTab === "contribution" ? "default" : "outline"}
|
||||
onClick={() => setRecordTab("contribution")}
|
||||
>
|
||||
{t("contributionRecords")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{payoutTable}
|
||||
{contributionTable}
|
||||
{recordTab === "payout" ? payoutTable : contributionTable}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -16,6 +18,8 @@ import {
|
||||
postAdminPlayerUnfreeze,
|
||||
putAdminPlayer,
|
||||
} from "@/api/admin-player";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
@@ -81,6 +86,7 @@ const PLAYER_STATUS_OPTIONS = [
|
||||
|
||||
export function PlayersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["players", "common"]);
|
||||
const tRef = useTranslationRef(["players", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const exportLabels = useExportLabels("players");
|
||||
@@ -95,6 +101,8 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [query, setQuery] = useState("");
|
||||
const [siteCode, setSiteCode] = useState("");
|
||||
const [appliedSiteCode, setAppliedSiteCode] = useState("");
|
||||
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
|
||||
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -131,12 +139,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
per_page: perPage,
|
||||
keyword: query.trim() || undefined,
|
||||
site_code: appliedSiteCode.trim() || undefined,
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
setLastPage(Math.max(1, data.meta.last_page));
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||
setErr(msg);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
@@ -144,13 +153,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, appliedSiteCode, t]);
|
||||
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
|
||||
|
||||
function openCreateAccount(): void {
|
||||
setAccountMode("create");
|
||||
@@ -334,6 +341,12 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminAgentFilter
|
||||
id="players-agent-filter"
|
||||
className="admin-list-field sm:w-[14rem]"
|
||||
value={agentNodeId}
|
||||
onChange={setAgentNodeId}
|
||||
/>
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("search")}
|
||||
@@ -349,6 +362,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -365,6 +379,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
@@ -377,15 +392,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table id="players-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("site")}</TableHead>
|
||||
<AdminAgentHead />
|
||||
<TableHead>{t("sitePlayerId")}</TableHead>
|
||||
<TableHead>{t("username")}</TableHead>
|
||||
<TableHead>{t("nickname")}</TableHead>
|
||||
@@ -398,9 +411,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 && !loading ? (
|
||||
{loading && items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={12} />
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground">
|
||||
<TableCell colSpan={12} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -413,6 +428,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{row.site_code}</span>
|
||||
</TableCell>
|
||||
<AdminAgentCell row={row} />
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{row.site_player_id}</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Eye } from "lucide-react";
|
||||
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -20,6 +22,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -36,6 +39,7 @@ import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||
import type {
|
||||
AdminReconcileJobRow,
|
||||
AdminReconcileItemsData,
|
||||
AdminReconcileJobListData,
|
||||
} from "@/types/api/admin-reconcile";
|
||||
@@ -80,8 +84,23 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
||||
const raw = summary?.[key];
|
||||
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
|
||||
function renderPeriodRange(
|
||||
row: Pick<AdminReconcileJobRow, "period_start" | "period_end">,
|
||||
formatTs: (value: string | null | undefined) => string,
|
||||
): string {
|
||||
const from = row.period_start ? formatTs(row.period_start) : "—";
|
||||
const to = row.period_end ? formatTs(row.period_end) : "—";
|
||||
return `${from} ~ ${to}`;
|
||||
}
|
||||
|
||||
export function ReconcileConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["reconcile", "common"]);
|
||||
const tRef = useTranslationRef(["reconcile", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
||||
@@ -115,18 +134,16 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const d = await getAdminReconcileJobs({ page, per_page: perPage });
|
||||
setJobs(d);
|
||||
} catch (e) {
|
||||
setJobsErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setJobsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setJobs(null);
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
}
|
||||
}, [page, perPage, t]);
|
||||
}, [page, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadJobs();
|
||||
});
|
||||
}, [loadJobs]);
|
||||
useAsyncEffect(() => {
|
||||
void loadJobs();
|
||||
}, [page, perPage]);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
if (selectedId == null) {
|
||||
@@ -141,18 +158,16 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
});
|
||||
setItems(d);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadItemsFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadItemsFailed"));
|
||||
setItems(null);
|
||||
} finally {
|
||||
setItemsLoading(false);
|
||||
}
|
||||
}, [selectedId, itemsPage, itemsPerPage, t]);
|
||||
}, [selectedId, itemsPage, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadItems();
|
||||
});
|
||||
}, [loadItems]);
|
||||
useAsyncEffect(() => {
|
||||
void loadItems();
|
||||
}, [selectedId, itemsPage, itemsPerPage]);
|
||||
|
||||
const loadPlayers = useCallback(async (keyword: string) => {
|
||||
const q = keyword.trim();
|
||||
@@ -218,6 +233,9 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const jm = jobs?.meta;
|
||||
const im = items?.meta;
|
||||
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
|
||||
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
|
||||
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
|
||||
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
@@ -225,28 +243,157 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
||||
<CardDescription>{t("createDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,0.9fr)_minmax(220px,0.95fr)_auto] lg:items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border bg-muted/15 p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="rounded-lg bg-background p-2 text-muted-foreground">
|
||||
<CalendarRange className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("scopeTitle")}</div>
|
||||
<p className="text-sm text-muted-foreground">{t("scopeDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
||||
<p className="text-xs text-muted-foreground">{t("reconcileTypeHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<AdminDateRangeField
|
||||
id="rc-date-range"
|
||||
label={t("dateRange")}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("dateRangeHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<AdminDateRangeField
|
||||
id="rc-date-range"
|
||||
label={t("dateRange")}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border bg-background p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="rounded-lg bg-muted/20 p-2 text-muted-foreground">
|
||||
<UserRound className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("playerScopeTitle")}</div>
|
||||
<p className="text-sm text-muted-foreground">{t("playerSearchHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
||||
<Input
|
||||
id="rc-player-search"
|
||||
value={playerSearch}
|
||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||
placeholder={t("playerSearchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPlayer ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{selectedPlayer.site_player_id}
|
||||
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
||||
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{t("playerSelected")} · {selectedPlayer.site_code}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(null);
|
||||
setPlayerSearch("");
|
||||
setPlayerResults([]);
|
||||
}}
|
||||
>
|
||||
{t("playerClear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
||||
<div className="mt-4 rounded-lg border bg-background">
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{playerLoading ? (
|
||||
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
|
||||
) : playerResults.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{playerResults.map((player) => {
|
||||
const active = selectedPlayer?.id === player.id;
|
||||
return (
|
||||
<button
|
||||
key={player.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
||||
active && "bg-muted/30",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedPlayer(player);
|
||||
setPlayerSearch(player.site_player_id);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{player.site_player_id}
|
||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{player.username ?? "—"} · {player.site_code}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{active ? t("playerSelectedShort") : t("playerChoose")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-lg border border-dashed bg-muted/10 px-3 py-3 text-sm text-muted-foreground">
|
||||
{t("playerAllPlayersHint")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3">
|
||||
<div className="min-w-0 text-sm text-muted-foreground">
|
||||
{selectedPlayer
|
||||
? t("createSummaryPlayer", {
|
||||
player: selectedPlayer.site_player_id,
|
||||
from: dateFrom || "—",
|
||||
to: dateTo || "—",
|
||||
})
|
||||
: t("createSummaryAll", {
|
||||
from: dateFrom || "—",
|
||||
to: dateTo || "—",
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full lg:w-auto"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
@@ -263,82 +410,6 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{submitting ? t("submitting") : t("createTask")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-1.5 pt-4">
|
||||
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
||||
<Input
|
||||
id="rc-player-search"
|
||||
value={playerSearch}
|
||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||
placeholder={t("playerSearchPlaceholder")}
|
||||
/>
|
||||
{selectedPlayer ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{selectedPlayer.site_player_id}
|
||||
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
||||
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(null);
|
||||
setPlayerSearch("");
|
||||
setPlayerResults([]);
|
||||
}}
|
||||
>
|
||||
{t("playerClear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
||||
<div className="rounded-lg border bg-background">
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{playerLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("loadingPlayers")}</div>
|
||||
) : playerResults.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{playerResults.map((player) => {
|
||||
const active = selectedPlayer?.id === player.id;
|
||||
return (
|
||||
<button
|
||||
key={player.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
||||
active && "bg-muted/30",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedPlayer(player);
|
||||
setPlayerSearch(player.site_player_id);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{player.site_player_id}
|
||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{player.username ?? "—"} · {player.site_code}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{active ? t("playerSelectedShort") : t("playerChoose")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -349,6 +420,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
||||
<CardDescription>{t("jobsDesc")}</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
||||
{t("refresh")}
|
||||
@@ -356,9 +428,6 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
||||
{jobsLoading && !jobs ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
{jobs ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
@@ -373,7 +442,10 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-center">{t("itemCount")}</TableHead>
|
||||
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
|
||||
<TableHead>{t("period")}</TableHead>
|
||||
<TableHead>{t("finishedAt")}</TableHead>
|
||||
<TableHead>{t("createdAt")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted/20 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("operate")}
|
||||
@@ -381,9 +453,11 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.items.length === 0 ? (
|
||||
{jobsLoading && !jobs ? (
|
||||
<AdminTableLoadingRow colSpan={10} />
|
||||
) : jobs.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -402,12 +476,28 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{jobStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{getJobSummaryValue(row.summary_json, "item_count")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
getJobSummaryValue(row.summary_json, "mismatch_count") > 0
|
||||
? "font-medium text-amber-700"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{getJobSummaryValue(row.summary_json, "mismatch_count")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
|
||||
<span className="line-clamp-2">
|
||||
{row.period_start ? formatTs(row.period_start) : "—"} ~{" "}
|
||||
{row.period_end ? formatTs(row.period_end) : "—"}
|
||||
{renderPeriodRange(row, formatTs)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(row.finished_at)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
@@ -475,10 +565,27 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
||||
{itemsLoading && !items ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||
) : null}
|
||||
{items ? (
|
||||
<>
|
||||
<div className="mb-4 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("itemCount")}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobItemCount}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("mismatchCount")}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xl font-semibold tabular-nums text-amber-700">
|
||||
<ShieldAlert className="size-4" />
|
||||
{selectedJobMismatchCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("matchedCount")}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobMatchedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("jobNo")} {items.job_no}</span>
|
||||
<span>·</span>
|
||||
@@ -493,7 +600,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{t("period")} {selectedJob ? `${selectedJob.period_start ? formatTs(selectedJob.period_start) : "—"} ~ ${selectedJob.period_end ? formatTs(selectedJob.period_end) : "—"}` : "—"}</span>
|
||||
<span>{t("period")} {selectedJob ? renderPeriodRange(selectedJob, formatTs) : "—"}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
||||
@@ -502,29 +609,47 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("sideARef")}</TableHead>
|
||||
<TableHead>{t("sideBRef")}</TableHead>
|
||||
<TableHead>{t("differenceAmount")}</TableHead>
|
||||
<TableHead className="text-right">{t("differenceAmount")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("detectedAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("noDetails")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.items.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableRow
|
||||
key={r.id}
|
||||
className={cn(
|
||||
r.status === "mismatch" && "bg-amber-500/5",
|
||||
r.status === "matched" && "bg-emerald-500/5",
|
||||
)}
|
||||
>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
|
||||
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.difference_amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={r.status}>
|
||||
{itemStatusLabel(r.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(r.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
import { Download, RefreshCw } from "lucide-react";
|
||||
|
||||
@@ -10,6 +12,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -40,6 +43,7 @@ type ReportJobsPanelProps = {
|
||||
|
||||
export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) {
|
||||
const { t } = useTranslation(["reports", "common"]);
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [jobs, setJobs] = useState<AdminReportJobRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -51,18 +55,16 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
const data = await getAdminReportJobs({ page: 1, per_page: 10 });
|
||||
setJobs(data.items);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("tasks.loadFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed"));
|
||||
setJobs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadJobs();
|
||||
});
|
||||
}, [loadJobs, refreshToken]);
|
||||
useAsyncEffect(() => {
|
||||
void loadJobs();
|
||||
}, [refreshToken]);
|
||||
|
||||
async function handleDownload(job: AdminReportJobRow): Promise<void> {
|
||||
if (!canExport || job.status !== "completed") {
|
||||
@@ -111,11 +113,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableLoadingRow colSpan={6} />
|
||||
) : jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
|
||||
@@ -20,13 +20,10 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { getAdminPlayTypes } from "@/api/admin-config";
|
||||
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import {
|
||||
getAdminPlayTypesLoadPromise,
|
||||
getCachedAdminPlayTypes,
|
||||
resolveAdminPlayTypeDisplayName,
|
||||
} from "@/lib/admin-play-types";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs";
|
||||
@@ -49,6 +46,8 @@ import { getAdminTransferOrders } from "@/api/admin-wallet";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -56,6 +55,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -121,12 +121,24 @@ type ReportDefinition = {
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
type PreviewColumns = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
metricA: string;
|
||||
metricB: string;
|
||||
metricC: string;
|
||||
status: string;
|
||||
extra: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type ReportFilters = {
|
||||
drawNo: string;
|
||||
drawId: number | null;
|
||||
number: string;
|
||||
player: string;
|
||||
playerId: number | null;
|
||||
agentNodeId: number | undefined;
|
||||
play: string;
|
||||
operator: string;
|
||||
operatorId: number | null;
|
||||
@@ -190,6 +202,7 @@ const emptyFilters: ReportFilters = {
|
||||
number: "",
|
||||
player: "",
|
||||
playerId: null,
|
||||
agentNodeId: undefined,
|
||||
play: "",
|
||||
operator: "",
|
||||
operatorId: null,
|
||||
@@ -302,6 +315,10 @@ function formatPlainMoney(value: number, currencyCode: string | null | undefined
|
||||
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
||||
}
|
||||
|
||||
function formatUsagePercent(ratio: number | null | undefined): string {
|
||||
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
|
||||
}
|
||||
|
||||
function optionText(...parts: Array<string | number | null | undefined>): string {
|
||||
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
||||
}
|
||||
@@ -314,6 +331,7 @@ function reportListParams(filters: ReportFilters, page: number, perPage: number)
|
||||
date_to: filters.dateTo || undefined,
|
||||
player_id: filters.playerId ?? undefined,
|
||||
play_code: filters.play.trim() || undefined,
|
||||
agent_node_id: filters.agentNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -328,23 +346,22 @@ function parsePositiveInteger(value: string): number | null {
|
||||
|
||||
async function resolveDraw(
|
||||
filters: ReportFilters,
|
||||
t: (key: string, options?: { ns?: string; drawNo?: string }) => string,
|
||||
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
|
||||
): Promise<{ id: number; draw_no: string }> {
|
||||
if (filters.drawId && filters.drawNo.trim()) {
|
||||
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
|
||||
if (filters.drawId != null && filters.drawId > 0) {
|
||||
const drawNo = filters.drawNo.trim();
|
||||
return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) };
|
||||
}
|
||||
|
||||
const drawNo = filters.drawNo.trim();
|
||||
if (!drawNo) {
|
||||
throw new LotteryApiBizError(t("validation.drawNoRequired", { ns: "reports" }), -1, null);
|
||||
throw new LotteryApiBizError(messages.drawNoRequired, -1, null);
|
||||
}
|
||||
|
||||
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
||||
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
||||
if (!matched) {
|
||||
throw new LotteryApiBizError(t("validation.drawNoNotFound", { ns: "reports", drawNo }), -1, {
|
||||
drawNo,
|
||||
});
|
||||
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
|
||||
}
|
||||
return { id: matched.id, draw_no: matched.draw_no };
|
||||
}
|
||||
@@ -403,10 +420,131 @@ export function ReportsConsole() {
|
||||
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
||||
const [jobRefreshToken, setJobRefreshToken] = useState(0);
|
||||
const [search, setSearch] = useState<SearchState>(emptySearch);
|
||||
const [playOptions, setPlayOptions] = useState<PlayOption[]>([]);
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
|
||||
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
|
||||
|
||||
const pageScopedLabel = useCallback(
|
||||
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
|
||||
[t],
|
||||
);
|
||||
|
||||
const previewColumns = useMemo<PreviewColumns>(() => {
|
||||
switch (selectedReport.key) {
|
||||
case "draw_profit":
|
||||
return {
|
||||
primary: t("preview.columns.drawProfit.primary"),
|
||||
secondary: t("preview.columns.drawProfit.secondary"),
|
||||
metricA: t("preview.columns.drawProfit.metricA"),
|
||||
metricB: t("preview.columns.drawProfit.metricB"),
|
||||
metricC: t("preview.columns.drawProfit.metricC"),
|
||||
status: t("preview.columns.drawProfit.status"),
|
||||
extra: t("preview.columns.drawProfit.extra"),
|
||||
time: t("preview.columns.drawProfit.time"),
|
||||
};
|
||||
case "daily_profit":
|
||||
return {
|
||||
primary: t("preview.columns.dailyProfit.primary"),
|
||||
secondary: t("preview.columns.dailyProfit.secondary"),
|
||||
metricA: t("preview.columns.dailyProfit.metricA"),
|
||||
metricB: t("preview.columns.dailyProfit.metricB"),
|
||||
metricC: t("preview.columns.dailyProfit.metricC"),
|
||||
status: t("preview.columns.dailyProfit.status"),
|
||||
extra: t("preview.columns.dailyProfit.extra"),
|
||||
time: t("preview.columns.dailyProfit.time"),
|
||||
};
|
||||
case "player_win_loss":
|
||||
return {
|
||||
primary: t("preview.columns.playerWinLoss.primary"),
|
||||
secondary: t("agentColumns.agent", { ns: "common" }),
|
||||
metricA: t("preview.columns.playerWinLoss.metricA"),
|
||||
metricB: t("preview.columns.playerWinLoss.metricB"),
|
||||
metricC: t("preview.columns.playerWinLoss.metricC"),
|
||||
status: t("preview.columns.playerWinLoss.status"),
|
||||
extra: t("preview.columns.playerWinLoss.extra"),
|
||||
time: t("preview.columns.playerWinLoss.time"),
|
||||
};
|
||||
case "player_transfer":
|
||||
return {
|
||||
primary: t("preview.columns.playerTransfer.primary"),
|
||||
secondary: t("preview.columns.playerTransfer.secondary"),
|
||||
metricA: t("preview.columns.playerTransfer.metricA"),
|
||||
metricB: t("preview.columns.playerTransfer.metricB"),
|
||||
metricC: t("preview.columns.playerTransfer.metricC"),
|
||||
status: t("preview.columns.playerTransfer.status"),
|
||||
extra: t("preview.columns.playerTransfer.extra"),
|
||||
time: t("preview.columns.playerTransfer.time"),
|
||||
};
|
||||
case "hot_number_risk":
|
||||
return {
|
||||
primary: t("preview.columns.hotNumberRisk.primary"),
|
||||
secondary: t("preview.columns.hotNumberRisk.secondary"),
|
||||
metricA: t("preview.columns.hotNumberRisk.metricA"),
|
||||
metricB: t("preview.columns.hotNumberRisk.metricB"),
|
||||
metricC: t("preview.columns.hotNumberRisk.metricC"),
|
||||
status: t("preview.columns.hotNumberRisk.status"),
|
||||
extra: t("preview.columns.hotNumberRisk.extra"),
|
||||
time: t("preview.columns.hotNumberRisk.time"),
|
||||
};
|
||||
case "play_dimension":
|
||||
return {
|
||||
primary: t("preview.columns.playDimension.primary"),
|
||||
secondary: t("preview.columns.playDimension.secondary"),
|
||||
metricA: t("preview.columns.playDimension.metricA"),
|
||||
metricB: t("preview.columns.playDimension.metricB"),
|
||||
metricC: t("preview.columns.playDimension.metricC"),
|
||||
status: t("preview.columns.playDimension.status"),
|
||||
extra: t("preview.columns.playDimension.extra"),
|
||||
time: t("preview.columns.playDimension.time"),
|
||||
};
|
||||
case "sold_out_number":
|
||||
return {
|
||||
primary: t("preview.columns.soldOut.primary"),
|
||||
secondary: t("preview.columns.soldOut.secondary"),
|
||||
metricA: t("preview.columns.soldOut.metricA"),
|
||||
metricB: t("preview.columns.soldOut.metricB"),
|
||||
metricC: t("preview.columns.soldOut.metricC"),
|
||||
status: t("preview.columns.soldOut.status"),
|
||||
extra: t("preview.columns.soldOut.extra"),
|
||||
time: t("preview.columns.soldOut.time"),
|
||||
};
|
||||
case "rebate_commission":
|
||||
return {
|
||||
primary: t("preview.columns.rebateCommission.primary"),
|
||||
secondary: t("preview.columns.rebateCommission.secondary"),
|
||||
metricA: t("preview.columns.rebateCommission.metricA"),
|
||||
metricB: t("preview.columns.rebateCommission.metricB"),
|
||||
metricC: t("preview.columns.rebateCommission.metricC"),
|
||||
status: t("preview.columns.rebateCommission.status"),
|
||||
extra: t("preview.columns.rebateCommission.extra"),
|
||||
time: t("preview.columns.rebateCommission.time"),
|
||||
};
|
||||
case "admin_audit":
|
||||
return {
|
||||
primary: t("preview.columns.adminAudit.primary"),
|
||||
secondary: t("preview.columns.adminAudit.secondary"),
|
||||
metricA: t("preview.columns.adminAudit.metricA"),
|
||||
metricB: t("preview.columns.adminAudit.metricB"),
|
||||
metricC: t("preview.columns.adminAudit.metricC"),
|
||||
status: t("preview.columns.adminAudit.status"),
|
||||
extra: t("preview.columns.adminAudit.extra"),
|
||||
time: t("preview.columns.adminAudit.time"),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
primary: t("preview.columns.primary"),
|
||||
secondary: t("preview.columns.secondary"),
|
||||
metricA: t("preview.columns.metricA"),
|
||||
metricB: t("preview.columns.metricB"),
|
||||
metricC: t("preview.columns.metricC"),
|
||||
status: t("preview.columns.status"),
|
||||
extra: t("preview.columns.extra"),
|
||||
time: t("preview.columns.time"),
|
||||
};
|
||||
}
|
||||
}, [selectedReport.key, t]);
|
||||
|
||||
const exportFileBase = useMemo(() => {
|
||||
const segments: string[] = [selectedReport.key];
|
||||
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
||||
@@ -419,29 +557,6 @@ export function ReportsConsole() {
|
||||
return normalizeFilenamePart(segments.join("-")) || selectedReport.key;
|
||||
}, [selectedReport.key, filters]);
|
||||
|
||||
const loadPlayOptions = useCallback(async () => {
|
||||
try {
|
||||
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
|
||||
setPlayOptions(
|
||||
getCachedAdminPlayTypes().map((item) => ({
|
||||
code: item.play_code,
|
||||
label: optionText(
|
||||
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item),
|
||||
item.play_code,
|
||||
),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setPlayOptions([]);
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPlayOptions();
|
||||
});
|
||||
}, [loadPlayOptions]);
|
||||
|
||||
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
|
||||
setSearch((prev) => ({ ...prev, loading: true }));
|
||||
try {
|
||||
@@ -481,7 +596,11 @@ export function ReportsConsole() {
|
||||
try {
|
||||
switch (selectedReport.key) {
|
||||
case "draw_profit": {
|
||||
const draw = await resolveDraw(filters, t);
|
||||
const draw = await resolveDraw(filters, {
|
||||
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||
drawNoNotFound: (drawNo) =>
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const summary = await getAdminDrawFinanceSummary(draw.id);
|
||||
setResult({
|
||||
key: "draw_profit",
|
||||
@@ -519,10 +638,10 @@ export function ReportsConsole() {
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||
{
|
||||
label: t("preview.stats.houseGross"),
|
||||
label: pageScopedLabel("houseGross"),
|
||||
value: formatPlainMoney(totalGross, "NPR"),
|
||||
tone: totalGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
@@ -548,7 +667,7 @@ export function ReportsConsole() {
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{
|
||||
label: t("preview.stats.houseGross"),
|
||||
label: pageScopedLabel("houseGross"),
|
||||
value: formatPlainMoney(
|
||||
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
||||
"NPR",
|
||||
@@ -592,17 +711,21 @@ export function ReportsConsole() {
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
|
||||
{ label: t("preview.stats.transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
|
||||
{ label: pageScopedLabel("transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
|
||||
{ label: pageScopedLabel("transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "hot_number_risk": {
|
||||
if (!filters.number.trim()) {
|
||||
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
|
||||
throw new LotteryApiBizError(tRef.current("validation.drawNoNumberRequired"), -1, null);
|
||||
}
|
||||
const draw = await resolveDraw(filters, t);
|
||||
const draw = await resolveDraw(filters, {
|
||||
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||
drawNoNotFound: (drawNo) =>
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
||||
const rows: ExportRow[] = [
|
||||
{
|
||||
@@ -642,14 +765,18 @@ export function ReportsConsole() {
|
||||
summary: [
|
||||
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
|
||||
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
|
||||
{ label: t("preview.stats.usage"), value: detail.pool.usage_ratio == null ? "-" : `${detail.pool.usage_ratio}%`, tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
||||
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
||||
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "sold_out_number": {
|
||||
const draw = await resolveDraw(filters, t);
|
||||
const draw = await resolveDraw(filters, {
|
||||
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||
drawNoNotFound: (drawNo) =>
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
||||
const rows = payload.items.map((item) => ({
|
||||
draw_id: payload.draw_id,
|
||||
@@ -695,8 +822,8 @@ export function ReportsConsole() {
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
|
||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
|
||||
],
|
||||
});
|
||||
break;
|
||||
@@ -717,8 +844,8 @@ export function ReportsConsole() {
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
|
||||
{ label: t("preview.stats.orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
|
||||
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
|
||||
{ label: pageScopedLabel("orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
@@ -761,15 +888,15 @@ export function ReportsConsole() {
|
||||
}
|
||||
default:
|
||||
setResult(null);
|
||||
setError(t("loadFailed"));
|
||||
setError(tRef.current("loadFailed"));
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(null);
|
||||
setError(err instanceof LotteryApiBizError ? err.message : t("loadFailed"));
|
||||
setError(err instanceof LotteryApiBizError ? err.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewReports, filters, page, perPage, selectedReport, t]);
|
||||
}, [canViewReports, filters, page, perPage, selectedReport]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -928,7 +1055,7 @@ export function ReportsConsole() {
|
||||
/>
|
||||
<div className="mt-2 max-h-64 overflow-auto">
|
||||
{search.loading ? (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
<AdminLoadingInline className="py-2" />
|
||||
) : null}
|
||||
{!search.loading && kind === "draw" ? (
|
||||
search.draws.map((item) => (
|
||||
@@ -1067,11 +1194,7 @@ export function ReportsConsole() {
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
@@ -1148,7 +1271,7 @@ export function ReportsConsole() {
|
||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
|
||||
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
|
||||
<TableCell>v{result.raw.pool.version}</TableCell>
|
||||
</TableRow>
|
||||
{result.raw.logs.items.map((item) => (
|
||||
@@ -1176,7 +1299,7 @@ export function ReportsConsole() {
|
||||
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
|
||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
|
||||
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
|
||||
<TableCell>v{item.version}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
@@ -1201,7 +1324,10 @@ export function ReportsConsole() {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.player_id}>
|
||||
<TableCell className="font-medium">{item.username}</TableCell>
|
||||
<TableCell>ID {item.player_id}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{adminAgentDisplayLabel(item)}
|
||||
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
|
||||
@@ -1305,6 +1431,13 @@ export function ReportsConsole() {
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
{selectedReport.category === "profit" || selectedReport.category === "wallet" ? (
|
||||
<AdminAgentFilter
|
||||
id="report-agent-filter"
|
||||
value={filters.agentNodeId}
|
||||
onChange={(id) => setFilters((prev) => ({ ...prev, agentNodeId: id }))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div className="flex shrink-0 gap-2">
|
||||
@@ -1395,17 +1528,20 @@ export function ReportsConsole() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950">
|
||||
{t("preview.summaryScopeHint")}
|
||||
</div>
|
||||
<Table id="reports-preview-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.secondary")}</TableHead>
|
||||
<TableHead className="text-center">{t("preview.columns.metricA")}</TableHead>
|
||||
<TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
|
||||
<TableHead className="text-center">{t("preview.columns.metricC")}</TableHead>
|
||||
<TableHead>{t("preview.columns.status")}</TableHead>
|
||||
<TableHead>{t("preview.columns.extra")}</TableHead>
|
||||
<TableHead>{t("preview.columns.time")}</TableHead>
|
||||
<TableHead>{previewColumns.primary}</TableHead>
|
||||
<TableHead>{previewColumns.secondary}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
||||
<TableHead>{previewColumns.status}</TableHead>
|
||||
<TableHead>{previewColumns.extra}</TableHead>
|
||||
<TableHead>{previewColumns.time}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderTable()}</TableBody>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDraw } from "@/api/admin-draws";
|
||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display";
|
||||
@@ -11,6 +14,7 @@ import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||
|
||||
export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "draws"]);
|
||||
const tRef = useTranslationRef(["risk", "draws"]);
|
||||
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -21,24 +25,22 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
||||
setDraw(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("drawInfoLoadFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("drawInfoLoadFailed");
|
||||
setError(msg);
|
||||
setDraw(null);
|
||||
}
|
||||
}, [drawId, t]);
|
||||
}, [drawId]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId]);
|
||||
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
|
||||
if (!draw) {
|
||||
return <p className="text-sm text-muted-foreground">{t("loadingDraw")}</p>;
|
||||
return <AdminLoadingInline className="py-4" label={t("loadingDraw")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Shield } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -13,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -48,6 +51,7 @@ const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
|
||||
export function RiskIndexConsole() {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskIndex");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||
@@ -81,19 +85,17 @@ export function RiskIndexConsole() {
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadDrawListFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDrawListFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, drawNoQuery, statusFilter, t]);
|
||||
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||
|
||||
function applySearch(): void {
|
||||
setDrawNoQuery(drawNoInput.trim());
|
||||
@@ -174,10 +176,7 @@ export function RiskIndexConsole() {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-shell">
|
||||
<Table id="risk-index-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -188,7 +187,9 @@ export function RiskIndexConsole() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={4} />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
@@ -222,7 +223,6 @@ export function RiskIndexConsole() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<AdminListPaginationFooter
|
||||
selectId="risk-index-draws-per-page"
|
||||
total={total}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -11,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -48,6 +51,7 @@ function riskActionFilterLabel(
|
||||
|
||||
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskLockLogs");
|
||||
useAdminCurrencyCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
@@ -79,19 +83,17 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadLogsFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadLogsFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber, t]);
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber]);
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card">
|
||||
@@ -157,10 +159,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-lock-logs-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
@@ -175,6 +174,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminRiskLockLogRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
@@ -214,7 +214,6 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminRiskPoolDetail } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -35,6 +38,7 @@ export function RiskPoolDetailConsole({
|
||||
number4d: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskPoolDetail", { number: number4d });
|
||||
useAdminCurrencyCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
@@ -53,19 +57,17 @@ export function RiskPoolDetailConsole({
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadDetailFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDetailFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawId, number4d, page, perPage, t]);
|
||||
}, [drawId, number4d, page, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId, number4d, page, perPage]);
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
@@ -87,7 +89,7 @@ export function RiskPoolDetailConsole({
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Lock, Unlock } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -17,6 +19,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -81,6 +84,7 @@ export function RiskPoolsConsole({
|
||||
allowSortChange = false,
|
||||
}: RiskPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [
|
||||
@@ -115,19 +119,17 @@ export function RiskPoolsConsole({
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadPoolsFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadPoolsFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawId, filter, number, page, perPage, sort, t]);
|
||||
}, [drawId, filter, number, page, perPage, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId, filter, number, page, perPage, sort]);
|
||||
|
||||
const handleManualStatus = useCallback(
|
||||
async (row: AdminRiskPoolRow) => {
|
||||
@@ -240,10 +242,7 @@ export function RiskPoolsConsole({
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-pools-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
@@ -258,6 +257,7 @@ export function RiskPoolsConsole({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
|
||||
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
|
||||
const acting = actingNumber === row.normalized_number;
|
||||
@@ -359,7 +359,6 @@ export function RiskPoolsConsole({
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ConfirmDialog />
|
||||
|
||||
92
src/modules/settings/admin-settings-data-context.tsx
Normal file
92
src/modules/settings/admin-settings-data-context.tsx
Normal 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);
|
||||
}
|
||||
36
src/modules/settings/components/settings-section-actions.tsx
Normal file
36
src/modules/settings/components/settings-section-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -68,6 +70,7 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
|
||||
|
||||
export function CurrencySettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers"]);
|
||||
const tRef = useTranslationRef(["config", "adminUsers"]);
|
||||
const exportLabels = useExportLabels("currencies");
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
|
||||
@@ -96,18 +99,16 @@ export function CurrencySettingsPanel() {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError
|
||||
? error.message
|
||||
: t("currencies.loadFailed", { ns: "config" }),
|
||||
: tRef.current("currencies.loadFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canManage, t]);
|
||||
}, [canManage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [canManage]);
|
||||
|
||||
function openCreate(): void {
|
||||
setMode("create");
|
||||
|
||||
99
src/modules/settings/hooks/use-settings-section.ts
Normal file
99
src/modules/settings/hooks/use-settings-section.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
148
src/modules/settings/panels/currency-format-settings-panel.tsx
Normal file
148
src/modules/settings/panels/currency-format-settings-panel.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
234
src/modules/settings/panels/draw-settings-panel.tsx
Normal file
234
src/modules/settings/panels/draw-settings-panel.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
src/modules/settings/panels/frontend-settings-panel.tsx
Normal file
138
src/modules/settings/panels/frontend-settings-panel.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
149
src/modules/settings/panels/settlement-settings-panel.tsx
Normal file
149
src/modules/settings/panels/settlement-settings-panel.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/modules/settings/settings-keys.ts
Normal file
38
src/modules/settings/settings-keys.ts
Normal 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;
|
||||
@@ -1,568 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSetting,
|
||||
} from "@/api/admin-settings";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
|
||||
import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel";
|
||||
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
|
||||
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
|
||||
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const DRAW_GROUP = "draw";
|
||||
const SETTLEMENT_GROUP = "settlement";
|
||||
|
||||
const DRAW_KEYS = {
|
||||
DEFAULT_CURRENCY: "currency.default_code",
|
||||
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
||||
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
||||
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
||||
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
||||
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
||||
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
||||
AUTO_APPROVE: "settlement.auto_approve_on_tick",
|
||||
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
|
||||
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
|
||||
} as const;
|
||||
|
||||
const FRONTEND_GROUP = "frontend";
|
||||
const FRONTEND_KEYS = {
|
||||
PLAY_RULES_HTML: "frontend.play_rules_html",
|
||||
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
|
||||
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
|
||||
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
|
||||
} as const;
|
||||
|
||||
interface RuntimeDraft {
|
||||
defaultCurrency: string;
|
||||
drawIntervalMinutes: string;
|
||||
drawBettingWindowSeconds: string;
|
||||
drawCloseBeforeDrawSeconds: string;
|
||||
drawBufferDrawsAhead: string;
|
||||
requireManualReview: boolean;
|
||||
cooldownMinutes: string;
|
||||
currencyDisplayDecimals: string;
|
||||
currencyDecimalSeparator: string;
|
||||
currencyThousandsSeparator: string;
|
||||
autoSettlement: boolean;
|
||||
autoApprove: boolean;
|
||||
autoPayout: boolean;
|
||||
applyRebateToPayout: boolean;
|
||||
playRulesHtmlZh: string;
|
||||
playRulesHtmlEn: string;
|
||||
playRulesHtmlNe: string;
|
||||
}
|
||||
|
||||
const RUNTIME_DRAFT_KEYS = [
|
||||
"defaultCurrency",
|
||||
"drawIntervalMinutes",
|
||||
"drawBettingWindowSeconds",
|
||||
"drawCloseBeforeDrawSeconds",
|
||||
"drawBufferDrawsAhead",
|
||||
"requireManualReview",
|
||||
"cooldownMinutes",
|
||||
"currencyDisplayDecimals",
|
||||
"currencyDecimalSeparator",
|
||||
"currencyThousandsSeparator",
|
||||
"autoSettlement",
|
||||
"autoApprove",
|
||||
"autoPayout",
|
||||
"applyRebateToPayout",
|
||||
] as const satisfies readonly (keyof RuntimeDraft)[];
|
||||
|
||||
const FRONTEND_DRAFT_KEYS = [
|
||||
"playRulesHtmlZh",
|
||||
"playRulesHtmlEn",
|
||||
"playRulesHtmlNe",
|
||||
] as const satisfies readonly (keyof RuntimeDraft)[];
|
||||
|
||||
function isSectionDirty<const K extends keyof RuntimeDraft>(
|
||||
draft: RuntimeDraft,
|
||||
saved: RuntimeDraft,
|
||||
keys: readonly K[],
|
||||
): boolean {
|
||||
return keys.some((key) => draft[key] !== saved[key]);
|
||||
}
|
||||
|
||||
function applyDraftFields<const K extends keyof RuntimeDraft>(
|
||||
base: RuntimeDraft,
|
||||
source: RuntimeDraft,
|
||||
keys: readonly K[],
|
||||
): RuntimeDraft {
|
||||
const next = { ...base };
|
||||
for (const key of keys) {
|
||||
next[key] = source[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function SaveActions({
|
||||
dirty,
|
||||
loading,
|
||||
saving,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveLabel,
|
||||
savingLabel,
|
||||
discardLabel,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
saveLabel: string;
|
||||
savingLabel: string;
|
||||
discardLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
|
||||
{saving ? savingLabel : saveLabel}
|
||||
</Button>
|
||||
{dirty ? (
|
||||
<Button type="button" variant="outline" onClick={onDiscard}>
|
||||
{discardLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemSettingsScreen() {
|
||||
const { t } = useTranslation(["common", "config", "adminUsers"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const [draft, setDraft] = useState<RuntimeDraft>({
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
autoSettlement: true,
|
||||
autoApprove: true,
|
||||
autoPayout: true,
|
||||
applyRebateToPayout: false,
|
||||
playRulesHtmlZh: "",
|
||||
playRulesHtmlEn: "",
|
||||
playRulesHtmlNe: "",
|
||||
});
|
||||
const [saved, setSaved] = useState<RuntimeDraft>({
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
autoSettlement: true,
|
||||
autoApprove: true,
|
||||
autoPayout: true,
|
||||
applyRebateToPayout: false,
|
||||
playRulesHtmlZh: "",
|
||||
playRulesHtmlEn: "",
|
||||
playRulesHtmlNe: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingRuntime, setSavingRuntime] = useState(false);
|
||||
const [savingFrontend, setSavingFrontend] = useState(false);
|
||||
|
||||
const runtimeDirty = useMemo(
|
||||
() => isSectionDirty(draft, saved, RUNTIME_DRAFT_KEYS),
|
||||
[draft, saved],
|
||||
);
|
||||
const frontendDirty = useMemo(
|
||||
() => isSectionDirty(draft, saved, FRONTEND_DRAFT_KEYS),
|
||||
[draft, saved],
|
||||
);
|
||||
const anyDirty = runtimeDirty || frontendDirty;
|
||||
const saving = savingRuntime || savingFrontend;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [drawRes, settlementRes, frontendRes] = await Promise.all([
|
||||
getAdminSettings(DRAW_GROUP),
|
||||
getAdminSettings(SETTLEMENT_GROUP),
|
||||
getAdminSettings(FRONTEND_GROUP),
|
||||
]);
|
||||
|
||||
const kv: Record<string, unknown> = {};
|
||||
for (const item of [...drawRes.items, ...settlementRes.items, ...frontendRes.items]) {
|
||||
kv[item.key] = item.value;
|
||||
}
|
||||
|
||||
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
|
||||
const nextDraft: RuntimeDraft = {
|
||||
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
||||
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
||||
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
||||
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
||||
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
||||
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
||||
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
||||
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
|
||||
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
|
||||
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
|
||||
applyRebateToPayout: Boolean(kv[DRAW_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
|
||||
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
||||
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
||||
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSaved(nextDraft);
|
||||
} catch {
|
||||
toast.error(t("system.loadFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const discardSection = <const K extends keyof RuntimeDraft>(keys: readonly K[]) => {
|
||||
setDraft((prev) => applyDraftFields(prev, saved, keys));
|
||||
};
|
||||
|
||||
const handleSaveRuntime = async () => {
|
||||
setSavingRuntime(true);
|
||||
try {
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DEFAULT_CURRENCY,
|
||||
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
||||
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
||||
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
||||
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
||||
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
||||
);
|
||||
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
||||
Math.max(0, Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2)),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
||||
(draft.currencyDecimalSeparator || ".").slice(0, 1),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
||||
(draft.currencyThousandsSeparator || ",").slice(0, 1),
|
||||
);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
|
||||
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
|
||||
toast.success(t("system.saveRuntimeSuccess", { ns: "config" }));
|
||||
setSaved((prev) => applyDraftFields(prev, draft, RUNTIME_DRAFT_KEYS));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setSavingRuntime(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFrontend = async () => {
|
||||
setSavingFrontend(true);
|
||||
try {
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
|
||||
toast.success(t("system.saveFrontendSuccess", { ns: "config" }));
|
||||
setSaved((prev) => applyDraftFields(prev, draft, FRONTEND_DRAFT_KEYS));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setSavingFrontend(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveLabel = t("actions.save", { ns: "adminUsers" });
|
||||
const savingLabel = t("saving", { ns: "adminUsers" });
|
||||
const discardLabel = t("system.discard", { ns: "config" });
|
||||
function SystemSettingsContent() {
|
||||
const { t } = useTranslation(["config"]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
{anyDirty ? (
|
||||
<div className="sticky top-0 z-20 -mx-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 shadow-sm backdrop-blur-sm">
|
||||
<p className="text-sm font-medium text-amber-950 dark:text-amber-100">
|
||||
{t("system.unsavedChanges", { ns: "config" })}
|
||||
{runtimeDirty && frontendDirty
|
||||
? ` · ${t("system.title", { ns: "config" })} / ${t("system.frontendConfig", { ns: "config" })}`
|
||||
: runtimeDirty
|
||||
? ` · ${t("system.title", { ns: "config" })}`
|
||||
: ` · ${t("system.frontendConfig", { ns: "config" })}`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AdminPageCard
|
||||
title={t("system.title", { ns: "config" })}
|
||||
description={t("system.description", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.requireManualReview}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("requireManualReview", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="default-currency" className="text-sm font-medium">
|
||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="default-currency"
|
||||
value={draft.defaultCurrency}
|
||||
onChange={(e) => updateDraft("defaultCurrency", e.target.value.toUpperCase())}
|
||||
disabled={loading || saving}
|
||||
maxLength={16}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-interval-minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
step="1"
|
||||
value={draft.drawIntervalMinutes}
|
||||
onChange={(e) => updateDraft("drawIntervalMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-betting-window-seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
step="1"
|
||||
value={draft.drawBettingWindowSeconds}
|
||||
onChange={(e) => updateDraft("drawBettingWindowSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-close-before-seconds"
|
||||
type="number"
|
||||
min="5"
|
||||
step="1"
|
||||
value={draft.drawCloseBeforeDrawSeconds}
|
||||
onChange={(e) => updateDraft("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
|
||||
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-buffer-ahead"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={draft.drawBufferDrawsAhead}
|
||||
onChange={(e) => updateDraft("drawBufferDrawsAhead", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-display-decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={draft.currencyDisplayDecimals}
|
||||
onChange={(e) => updateDraft("currencyDisplayDecimals", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-decimal-separator"
|
||||
value={draft.currencyDecimalSeparator}
|
||||
onChange={(e) => updateDraft("currencyDecimalSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-thousands-separator"
|
||||
value={draft.currencyThousandsSeparator}
|
||||
onChange={(e) => updateDraft("currencyThousandsSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoSettlement}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoSettlement", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoApprove}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoApprove", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1 pr-4">
|
||||
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.applyRebateToPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("applyRebateToPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="cooldown-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SaveActions
|
||||
dirty={runtimeDirty}
|
||||
loading={loading}
|
||||
saving={savingRuntime}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveRuntimeTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveRuntimeDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => handleSaveRuntime(),
|
||||
})
|
||||
}
|
||||
onDiscard={() => discardSection(RUNTIME_DRAFT_KEYS)}
|
||||
saveLabel={saveLabel}
|
||||
savingLabel={savingLabel}
|
||||
discardLabel={discardLabel}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<DrawSettingsPanel />
|
||||
<CurrencyFormatSettingsPanel />
|
||||
<SettlementSettingsPanel />
|
||||
|
||||
<AdminPageCard
|
||||
title={t("wallet.title", { ns: "config" })}
|
||||
@@ -571,73 +25,15 @@ export function SystemSettingsScreen() {
|
||||
<WalletConfigDocScreen embedded />
|
||||
</AdminPageCard>
|
||||
|
||||
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("system.fields.playRulesHtml", { ns: "config" })}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
||||
</p>
|
||||
<Tabs defaultValue="zh" className="w-full">
|
||||
<TabsList className="w-full max-w-md">
|
||||
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="zh" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-zh"
|
||||
value={draft.playRulesHtmlZh}
|
||||
onChange={(e) => updateDraft("playRulesHtmlZh", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="en" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-en"
|
||||
value={draft.playRulesHtmlEn}
|
||||
onChange={(e) => updateDraft("playRulesHtmlEn", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="ne" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-ne"
|
||||
value={draft.playRulesHtmlNe}
|
||||
onChange={(e) => updateDraft("playRulesHtmlNe", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SaveActions
|
||||
dirty={frontendDirty}
|
||||
loading={loading}
|
||||
saving={savingFrontend}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => handleSaveFrontend(),
|
||||
})
|
||||
}
|
||||
onDiscard={() => discardSection(FRONTEND_DRAFT_KEYS)}
|
||||
saveLabel={saveLabel}
|
||||
savingLabel={savingLabel}
|
||||
discardLabel={discardLabel}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
|
||||
<ConfirmDialog />
|
||||
<FrontendSettingsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemSettingsScreen() {
|
||||
return (
|
||||
<AdminSettingsDataProvider>
|
||||
<SystemSettingsContent />
|
||||
</AdminSettingsDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -14,6 +16,8 @@ import {
|
||||
postAdminRejectSettlementBatch,
|
||||
} from "@/api/admin-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
@@ -37,6 +41,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
@@ -72,6 +77,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
|
||||
|
||||
export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const tRef = useTranslationRef(["settlement", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
useAdminCurrencyCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
@@ -84,6 +90,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
@@ -95,18 +103,22 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
try {
|
||||
const [s, d] = await Promise.all([
|
||||
getAdminSettlementBatch(batchId),
|
||||
getAdminSettlementBatchDetails(batchId, { page, per_page: perPage }),
|
||||
getAdminSettlementBatchDetails(batchId, {
|
||||
page,
|
||||
per_page: perPage,
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
}),
|
||||
]);
|
||||
setSummary(s);
|
||||
setDetails(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setSummary(null);
|
||||
setDetails(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [batchId, page, perPage, t]);
|
||||
}, [batchId, page, perPage, appliedAgentNodeId]);
|
||||
|
||||
async function runAction(label: string, action: () => Promise<unknown>): Promise<void> {
|
||||
setActing(label);
|
||||
@@ -173,10 +185,9 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => void load(), 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [batchId, page, perPage, appliedAgentNodeId]);
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
@@ -322,7 +333,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : loading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("loadingSummary")}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-4" label={t("loadingSummary")} />
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
@@ -332,11 +343,30 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<CardContent>
|
||||
{details ? (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<AdminAgentFilter
|
||||
id="settlement-details-agent-filter"
|
||||
className="w-[14rem]"
|
||||
value={agentNodeId}
|
||||
onChange={setAgentNodeId}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("search", { ns: "common", defaultValue: "Search" })}
|
||||
</Button>
|
||||
</div>
|
||||
<Table id={`settlement-details-table-${batchId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead>{t("matchedTier")}</TableHead>
|
||||
<TableHead className="text-center">{t("regularPayout")}</TableHead>
|
||||
@@ -348,6 +378,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(r.play_code)}</TableCell>
|
||||
<AdminAgentIdentityCells row={r} />
|
||||
<AdminPlayerIdentityCells row={r} />
|
||||
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||
@@ -379,7 +410,11 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{loading ? t("loadingDetails") : t("states.noData", { ns: "common" })}
|
||||
{loading ? (
|
||||
<AdminLoadingInline label={t("loadingDetails")} />
|
||||
) : (
|
||||
t("states.noData", { ns: "common" })
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Eye, HandCoins, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
postAdminPayoutSettlementBatch,
|
||||
postAdminRejectSettlementBatch,
|
||||
} from "@/api/admin-settlement";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
@@ -45,6 +48,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
@@ -87,6 +91,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
|
||||
|
||||
export function SettlementBatchesConsole() {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const tRef = useTranslationRef(["settlement", "common"]);
|
||||
const exportLabels = useExportLabels("settlementBatches");
|
||||
const profile = useAdminProfile();
|
||||
useAdminCurrencyCatalog();
|
||||
@@ -99,6 +104,8 @@ export function SettlementBatchesConsole() {
|
||||
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
||||
const [draftStatus, setDraftStatus] = useState(STATUS_ALL);
|
||||
const [appliedStatus, setAppliedStatus] = useState(STATUS_ALL);
|
||||
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [actingId, setActingId] = useState<number | null>(null);
|
||||
@@ -117,24 +124,25 @@ export function SettlementBatchesConsole() {
|
||||
appliedStatus === STATUS_ALL || appliedStatus.trim() === ""
|
||||
? undefined
|
||||
: appliedStatus.trim(),
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => void load(), 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
|
||||
const applyFilters = () => {
|
||||
setAppliedDrawNo(draftDrawNo);
|
||||
setAppliedStatus(draftStatus);
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@@ -193,6 +201,12 @@ export function SettlementBatchesConsole() {
|
||||
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
<AdminAgentFilter
|
||||
id="settlement-batches-agent-filter"
|
||||
className="admin-list-field sm:w-[14rem]"
|
||||
value={agentNodeId}
|
||||
onChange={setAgentNodeId}
|
||||
/>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
|
||||
{t("drawNo")}
|
||||
@@ -234,10 +248,7 @@ export function SettlementBatchesConsole() {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-0">
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-shell">
|
||||
<Table id="settlement-batches-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -253,6 +264,7 @@ export function SettlementBatchesConsole() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={9} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||
@@ -333,7 +345,6 @@ export function SettlementBatchesConsole() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="settlement-batches-per-page"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -58,6 +63,7 @@ const TICKET_STATUS_OPTIONS = [
|
||||
|
||||
type TicketFilters = {
|
||||
siteCode: string;
|
||||
agentNodeId: number | undefined;
|
||||
playerQuery: string;
|
||||
drawNo: string;
|
||||
numberKeyword: string;
|
||||
@@ -68,6 +74,7 @@ type TicketFilters = {
|
||||
|
||||
const emptyTicketFilters: TicketFilters = {
|
||||
siteCode: "",
|
||||
agentNodeId: undefined,
|
||||
playerQuery: "",
|
||||
drawNo: "",
|
||||
numberKeyword: "",
|
||||
@@ -101,6 +108,7 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const tRef = useTranslationRef(["tickets", "common"]);
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const exportLabels = useExportLabels("tickets");
|
||||
@@ -131,6 +139,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
per_page: perPage,
|
||||
...query,
|
||||
site_code: applied.siteCode.trim() || undefined,
|
||||
agent_node_id: applied.agentNodeId,
|
||||
draw_no: applied.drawNo.trim() || undefined,
|
||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||
number: applied.numberKeyword.trim() || undefined,
|
||||
@@ -139,24 +148,23 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applied, page, perPage, t]);
|
||||
}, [applied, page, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [applied, page, perPage]);
|
||||
|
||||
const runSearch = () => {
|
||||
setErr(null);
|
||||
setApplied({
|
||||
...draft,
|
||||
siteCode: draft.siteCode.trim(),
|
||||
agentNodeId: draft.agentNodeId,
|
||||
playerQuery: draft.playerQuery.trim(),
|
||||
drawNo: draft.drawNo.trim(),
|
||||
numberKeyword: draft.numberKeyword.trim(),
|
||||
@@ -222,6 +230,12 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminAgentFilter
|
||||
id="tickets-agent-filter"
|
||||
className="admin-list-field sm:w-[14rem]"
|
||||
value={draft.agentNodeId}
|
||||
onChange={(id) => setDraft((current) => ({ ...current, agentNodeId: id }))}
|
||||
/>
|
||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||
<Label htmlFor="pt-player" className="sm:shrink-0">
|
||||
{t("playerId")}
|
||||
@@ -344,17 +358,14 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
) : null}
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
{loading || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="tickets-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead>{t("orderNo")}</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
@@ -370,9 +381,11 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={16} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={15} className="text-muted-foreground">
|
||||
<TableCell colSpan={16} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -384,6 +397,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
return (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
@@ -413,19 +427,21 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-tickets-per-page"
|
||||
total={data.total}
|
||||
page={data.page}
|
||||
lastPage={Math.max(1, data.last_page)}
|
||||
perPage={data.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-tickets-per-page"
|
||||
total={data.total}
|
||||
page={data.page}
|
||||
lastPage={Math.max(1, data.last_page)}
|
||||
perPage={data.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Copy, RotateCcw, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
} from "@/api/admin-wallet";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -24,6 +28,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -125,6 +130,7 @@ function statusLabelT(status: string, t: (key: string) => string): string {
|
||||
}
|
||||
|
||||
type TransferFilters = {
|
||||
agentNodeId: number | undefined;
|
||||
playerId: string;
|
||||
playerAccount: string;
|
||||
transferNo: string;
|
||||
@@ -136,6 +142,7 @@ type TransferFilters = {
|
||||
};
|
||||
|
||||
const emptyTransferFilters: TransferFilters = {
|
||||
agentNodeId: undefined,
|
||||
playerId: "",
|
||||
playerAccount: "",
|
||||
transferNo: "",
|
||||
@@ -147,6 +154,7 @@ const emptyTransferFilters: TransferFilters = {
|
||||
};
|
||||
|
||||
type TxnFilters = {
|
||||
agentNodeId: number | undefined;
|
||||
playerId: string;
|
||||
playerAccount: string;
|
||||
txnNo: string;
|
||||
@@ -159,6 +167,7 @@ type TxnFilters = {
|
||||
};
|
||||
|
||||
const emptyTxnFilters: TxnFilters = {
|
||||
agentNodeId: undefined,
|
||||
playerId: "",
|
||||
playerAccount: "",
|
||||
txnNo: "",
|
||||
@@ -306,6 +315,7 @@ function TransferOrderRowActions({
|
||||
|
||||
export function TransferOrdersPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
|
||||
@@ -386,21 +396,20 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
created_from: applied.createdFrom.trim() || undefined,
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
status: applied.statusCsv.trim() || undefined,
|
||||
agent_node_id: applied.agentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, applied, t]);
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
@@ -421,6 +430,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<AdminAgentFilter
|
||||
id="transfer-agent-filter"
|
||||
value={draft.agentNodeId}
|
||||
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
|
||||
/>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
|
||||
<Input
|
||||
@@ -531,11 +545,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table id="wallet-transfer-orders-table" className="table-fixed">
|
||||
@@ -543,6 +553,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<TableRow>
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
|
||||
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead className="w-14">{t("direction")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
||||
@@ -554,9 +565,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={13} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="text-muted-foreground">
|
||||
<TableCell colSpan={13} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -569,6 +582,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell>{row.direction}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
@@ -605,19 +619,21 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transfer-orders-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transfer-orders-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -629,6 +645,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
|
||||
export function WalletTxnsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("walletTransactions");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
||||
@@ -660,21 +677,20 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
biz_type: applied.bizType.trim() || undefined,
|
||||
status: applied.statusCsv.trim() || undefined,
|
||||
agent_node_id: applied.agentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, applied, t]);
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
@@ -694,6 +710,11 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<AdminAgentFilter
|
||||
id="wallet-txn-agent-filter"
|
||||
value={draft.agentNodeId}
|
||||
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
|
||||
/>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-no">{t("txnNo")}</Label>
|
||||
<Input
|
||||
@@ -835,11 +856,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table id="wallet-transactions-table" className="table-fixed">
|
||||
@@ -847,6 +864,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
<TableRow>
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
|
||||
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
||||
@@ -856,9 +874,11 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={11} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
<TableCell colSpan={11} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -871,6 +891,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
|
||||
<TableCell className="tabular-nums text-xs">
|
||||
@@ -891,19 +912,21 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transactions-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transactions-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -913,6 +936,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
|
||||
export function PlayerWalletPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("playerWallets");
|
||||
useAdminCurrencyCatalog();
|
||||
const [playerId, setPlayerId] = useState("");
|
||||
@@ -923,7 +947,7 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
const query = useCallback(async () => {
|
||||
const id = Number(playerId.trim());
|
||||
if (Number.isNaN(id) || id < 1) {
|
||||
setErr(t("invalidPlayerId"));
|
||||
setErr(tRef.current("invalidPlayerId"));
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
@@ -933,12 +957,12 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
const d = await getAdminPlayerWallets(id);
|
||||
setResult(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("queryFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("queryFailed"));
|
||||
setResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [playerId, t]);
|
||||
}, [playerId]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
90
src/types/api/admin-agent.ts
Normal file
90
src/types/api/admin-agent.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
@@ -14,6 +14,8 @@ export type AdminAuthLoginRequest = {
|
||||
captcha_code: string;
|
||||
};
|
||||
|
||||
import type { AdminAgentContext } from "@/types/api/admin-agent";
|
||||
|
||||
/** 登录成功后缓存于会话(localStorage)的管理员摘要 */
|
||||
export type AdminProfile = {
|
||||
id: number;
|
||||
@@ -24,6 +26,11 @@ export type AdminProfile = {
|
||||
permissions?: string[];
|
||||
/** 当前管理员可见的后台菜单,由 Laravel 注册表统一下发。 */
|
||||
navigation?: AdminNavItem[];
|
||||
/** 代理账号绑定节点;超管为 null */
|
||||
agent?: AdminAgentContext | null;
|
||||
is_super_admin?: boolean;
|
||||
/** 当前代理可下放给下级的 prd.* 上限(未配置 grants 时与操作权限一致) */
|
||||
delegation_ceiling?: string[];
|
||||
};
|
||||
|
||||
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
||||
|
||||
@@ -26,6 +26,15 @@ export type AdminDashboardAnalyticsPlayRow = {
|
||||
approx_house_gross_minor: number;
|
||||
};
|
||||
|
||||
export type AdminDashboardAnalyticsAgentRow = {
|
||||
agent_node_id: number;
|
||||
agent_code: string;
|
||||
agent_name: string;
|
||||
total_bet_minor: number;
|
||||
total_payout_minor: number;
|
||||
approx_house_gross_minor: number;
|
||||
};
|
||||
|
||||
export type AdminDashboardAnalyticsChartMeta = {
|
||||
chart_date_from: string;
|
||||
chart_date_to: string;
|
||||
@@ -45,6 +54,7 @@ export type AdminDashboardAnalyticsData = {
|
||||
daily_series: AdminReportDailyProfitRow[];
|
||||
chart_meta: AdminDashboardAnalyticsChartMeta;
|
||||
play_breakdown: AdminDashboardAnalyticsPlayRow[];
|
||||
agent_breakdown: AdminDashboardAnalyticsAgentRow[];
|
||||
};
|
||||
|
||||
export type AdminDashboardAnalyticsQuery = {
|
||||
|
||||
@@ -9,6 +9,9 @@ export type AdminPlayerWalletRow = {
|
||||
|
||||
export type AdminPlayerRow = {
|
||||
id: number;
|
||||
agent_node_id?: number | null;
|
||||
agent_code?: string | null;
|
||||
agent_name?: string | null;
|
||||
site_code: string;
|
||||
site_player_id: string;
|
||||
username: string | null;
|
||||
|
||||
@@ -7,6 +7,9 @@ export type AdminReportDailyProfitRow = {
|
||||
|
||||
export type AdminReportPlayerWinLossRow = {
|
||||
player_id: number;
|
||||
agent_node_id?: number | null;
|
||||
agent_code?: string | null;
|
||||
agent_name?: string | null;
|
||||
username: string;
|
||||
total_bet_minor: number;
|
||||
total_payout_minor: number;
|
||||
@@ -45,4 +48,5 @@ export type AdminReportQueryParams = {
|
||||
date_to?: string;
|
||||
player_id?: number;
|
||||
play_code?: string;
|
||||
agent_node_id?: number;
|
||||
};
|
||||
|
||||
@@ -62,6 +62,9 @@ export type AdminSettlementBatchShowData = {
|
||||
export type AdminSettlementDetailRow = {
|
||||
id: number;
|
||||
ticket_item_id: number;
|
||||
agent_node_id?: number | null;
|
||||
agent_code?: string | null;
|
||||
agent_name?: string | null;
|
||||
ticket_no: string | null;
|
||||
play_code: string | null;
|
||||
currency_code: string | null;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export type AdminTicketItemRow = {
|
||||
id: number;
|
||||
ticket_no: string;
|
||||
agent_node_id?: number | null;
|
||||
agent_code?: string | null;
|
||||
agent_name?: string | null;
|
||||
player_id: number;
|
||||
site_code: string | null;
|
||||
site_player_id: string | null;
|
||||
|
||||
@@ -41,6 +41,10 @@ export type AdminRoleRow = {
|
||||
status: number;
|
||||
is_system: boolean;
|
||||
sort_order: number;
|
||||
scope_type?: string;
|
||||
owner_agent_id?: number | null;
|
||||
delegated_from_role_id?: number | null;
|
||||
is_read_only_template?: boolean;
|
||||
permission_slugs: string[];
|
||||
user_count: number;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
export type AdminTransferOrderItem = {
|
||||
id: number;
|
||||
transfer_no: string;
|
||||
agent_node_id?: number | null;
|
||||
agent_code?: string | null;
|
||||
agent_name?: string | null;
|
||||
player_id: number;
|
||||
site_code: string | null;
|
||||
site_player_id: string | null;
|
||||
@@ -34,6 +37,9 @@ export type AdminTransferOrderListData = {
|
||||
export type AdminWalletTxnItem = {
|
||||
id: number;
|
||||
txn_no: string;
|
||||
agent_node_id?: number | null;
|
||||
agent_code?: string | null;
|
||||
agent_name?: string | null;
|
||||
player_id: number;
|
||||
site_code: string | null;
|
||||
site_player_id: string | null;
|
||||
|
||||
Reference in New Issue
Block a user