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;
|
per_page?: number;
|
||||||
draw_no?: string;
|
draw_no?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
agent_node_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getAdminDraws(q: AdminDrawListQuery = {}): Promise<AdminDrawListData> {
|
export async function getAdminDraws(q: AdminDrawListQuery = {}): Promise<AdminDrawListData> {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export async function getAdminPlayers(params?: {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
site_code?: string;
|
site_code?: string;
|
||||||
|
agent_node_id?: number;
|
||||||
}): Promise<AdminPlayerListData> {
|
}): Promise<AdminPlayerListData> {
|
||||||
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
|
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,3 +27,14 @@ export async function updateAdminSetting(
|
|||||||
): Promise<AdminSettingItem> {
|
): Promise<AdminSettingItem> {
|
||||||
return adminRequest.put<AdminSettingItem>(`${A}/settings/${key}`, { value });
|
return adminRequest.put<AdminSettingItem>(`${A}/settings/${key}`, { value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminSettingBatchItem = {
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateAdminSettingsBatch(
|
||||||
|
items: AdminSettingBatchItem[],
|
||||||
|
): Promise<AdminSettingListResponse> {
|
||||||
|
return adminRequest.put<AdminSettingListResponse>(`${A}/settings/batch`, { items });
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type AdminSettlementBatchListQuery = {
|
|||||||
per_page?: number;
|
per_page?: number;
|
||||||
draw_no?: string;
|
draw_no?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
agent_node_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getAdminSettlementBatches(
|
export async function getAdminSettlementBatches(
|
||||||
@@ -33,6 +34,7 @@ export async function getAdminSettlementBatch(batchId: number): Promise<AdminSet
|
|||||||
export type AdminSettlementBatchDetailsQuery = {
|
export type AdminSettlementBatchDetailsQuery = {
|
||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
|
agent_node_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getAdminSettlementBatchDetails(
|
export async function getAdminSettlementBatchDetails(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type TicketItemsListQuery = {
|
|||||||
player_id?: number;
|
player_id?: number;
|
||||||
player_account?: string;
|
player_account?: string;
|
||||||
site_code?: string;
|
site_code?: string;
|
||||||
|
agent_node_id?: number;
|
||||||
draw_no?: string;
|
draw_no?: string;
|
||||||
status?: string[];
|
status?: string[];
|
||||||
number?: string;
|
number?: string;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type TransferOrderListQuery = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
/** 仅异常:processing / failed / pending_reconcile */
|
/** 仅异常:processing / failed / pending_reconcile */
|
||||||
abnormal?: boolean;
|
abnormal?: boolean;
|
||||||
|
site_code?: string;
|
||||||
|
agent_node_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getAdminTransferOrders(
|
export async function getAdminTransferOrders(
|
||||||
@@ -45,6 +47,8 @@ export type WalletTransactionListQuery = {
|
|||||||
biz_type?: string;
|
biz_type?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
abnormal?: boolean;
|
abnormal?: boolean;
|
||||||
|
site_code?: string;
|
||||||
|
agent_node_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getAdminWalletTransactions(
|
export async function getAdminWalletTransactions(
|
||||||
|
|||||||
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-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
--animate-loading-dot-bounce: loading-dot-bounce 0.9s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -208,3 +209,18 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes loading-dot-bounce {
|
||||||
|
0%,
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
35% {
|
||||||
|
transform: translateY(-48%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
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",
|
currencies: "currencies.title",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TOP_ROUTE_LABELS: Record<string, string> = {
|
||||||
|
agents: "agents.title",
|
||||||
|
};
|
||||||
|
|
||||||
const CONFIG_ROUTE_LABELS: Record<string, string> = {
|
const CONFIG_ROUTE_LABELS: Record<string, string> = {
|
||||||
"integration-sites": "integrationSites.title",
|
"integration-sites": "integrationSites.title",
|
||||||
plays: "nav.items.plays",
|
plays: "nav.items.plays",
|
||||||
@@ -59,7 +63,7 @@ type BreadcrumbCrumb = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AdminBreadcrumb() {
|
export function AdminBreadcrumb() {
|
||||||
const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports"]);
|
const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports", "agents"]);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const navItems = profile?.navigation ?? [];
|
const navItems = profile?.navigation ?? [];
|
||||||
@@ -98,11 +102,14 @@ export function AdminBreadcrumb() {
|
|||||||
isCurrent: pathname === navItem.href || segments.length === 2,
|
isCurrent: pathname === navItem.href || segments.length === 2,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const topKey = TOP_ROUTE_LABELS[businessSegment];
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: t(`nav.${businessSegment}`, {
|
label: topKey
|
||||||
ns: "common",
|
? t(topKey, { ns: "agents", defaultValue: titleCase(businessSegment) })
|
||||||
defaultValue: titleCase(businessSegment),
|
: t(`nav.${businessSegment}`, {
|
||||||
}),
|
ns: "common",
|
||||||
|
defaultValue: titleCase(businessSegment),
|
||||||
|
}),
|
||||||
href: `${ADMIN_BASE}/${businessSegment}`,
|
href: `${ADMIN_BASE}/${businessSegment}`,
|
||||||
isCurrent: segments.length === 2,
|
isCurrent: segments.length === 2,
|
||||||
});
|
});
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useMemo, type ReactElement } from "react";
|
import { useMemo, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdminSidebarNav,
|
||||||
|
AdminSidebarNavSkeleton,
|
||||||
|
} from "@/components/admin/admin-sidebar-nav";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
@@ -18,63 +18,28 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { adminNavLabel } from "@/lib/admin-nav-label";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
|
|
||||||
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||||
import { useAdminProfile, useAdminSessionStore } from "@/stores/admin-session";
|
import { useAdminProfile, useAdminSessionStore } from "@/stores/admin-session";
|
||||||
|
|
||||||
/** 与常见导航项文字宽度接近,避免整齐灰条 */
|
|
||||||
const SIDEBAR_NAV_SKELETON_WIDTHS = ["68%", "82%", "58%", "74%", "64%", "78%", "55%", "70%", "62%"] as const;
|
|
||||||
|
|
||||||
function SidebarNavSkeletonRow({
|
|
||||||
labelWidth,
|
|
||||||
delayMs,
|
|
||||||
}: {
|
|
||||||
labelWidth: string;
|
|
||||||
delayMs: number;
|
|
||||||
}): ReactElement {
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="flex h-8 w-full items-center gap-2 rounded-md px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1.5"
|
|
||||||
style={{ animationDelay: `${delayMs}ms` }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="size-4 shrink-0 rounded-[4px] bg-white/12 motion-safe:animate-pulse"
|
|
||||||
style={{ animationDelay: `${delayMs}ms` }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="h-2.5 shrink-0 rounded-full bg-white/12 motion-safe:animate-pulse group-data-[collapsible=icon]:hidden"
|
|
||||||
style={{ width: labelWidth, animationDelay: `${delayMs + 40}ms` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminSidebarSkeleton(): ReactElement {
|
function AdminSidebarSkeleton(): ReactElement {
|
||||||
const { t } = useTranslation("common");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" className="overflow-hidden">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader className="flex h-14 shrink-0 items-center gap-0 border-b border-sidebar-border p-0 px-2">
|
<SidebarHeader className="flex shrink-0 flex-col gap-0 border-b border-sidebar-border px-2 py-2">
|
||||||
<SidebarMenu className="h-full w-full">
|
<SidebarMenu className="h-full w-full">
|
||||||
<SidebarMenuItem className="h-full">
|
<SidebarMenuItem className="h-full">
|
||||||
<div className="flex h-12 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
|
<div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="N lotto"
|
alt="N lotto"
|
||||||
className="h-auto max-h-11 w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
className="h-auto max-h-10 w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="relative overflow-hidden">
|
<SidebarContent className="relative min-h-0 overflow-hidden p-0">
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -82,81 +47,64 @@ function AdminSidebarSkeleton(): ReactElement {
|
|||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover object-bottom"
|
className="h-full w-full object-cover object-bottom"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-sidebar to-transparent" />
|
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||||
<div className="absolute inset-0 bg-sidebar/20" />
|
<div className="absolute inset-0 bg-sidebar/20" />
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroup className="relative z-10">
|
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
|
||||||
<SidebarGroupLabel className="text-sidebar-foreground/55">
|
<AdminSidebarNavSkeleton />
|
||||||
{t("sidebar.workspace", { defaultValue: "Workspace" })}
|
</div>
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu className={cn("gap-0.5", "motion-safe:opacity-90")}>
|
|
||||||
{SIDEBAR_NAV_SKELETON_WIDTHS.map((width, i) => (
|
|
||||||
<SidebarNavSkeletonRow key={i} labelWidth={width} delayMs={i * 55} />
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
<span className="sr-only" role="status" aria-live="polite">
|
|
||||||
{t("auth.checking")}
|
|
||||||
</span>
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive(pathname: string, item: { href: string; activeMatchPrefix?: string; segment?: string }): boolean {
|
|
||||||
const { href, activeMatchPrefix, segment } = item;
|
|
||||||
const prefix = activeMatchPrefix ?? href;
|
|
||||||
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
|
|
||||||
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
|
|
||||||
}
|
|
||||||
// Keep "settings" independent from its child routes like /admin/settings/currencies.
|
|
||||||
if (segment === "settings") {
|
|
||||||
return pathname === href;
|
|
||||||
}
|
|
||||||
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminAppSidebar() {
|
export function AdminAppSidebar() {
|
||||||
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "audit", "reports"]);
|
|
||||||
const pathname = usePathname();
|
|
||||||
const shellAuthPending = useAdminSessionStore((s) => s.shellAuthPending);
|
const shellAuthPending = useAdminSessionStore((s) => s.shellAuthPending);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
|
|
||||||
if (shellAuthPending) {
|
|
||||||
return <AdminSidebarSkeleton />;
|
|
||||||
}
|
|
||||||
const visibleNav = useMemo(
|
const visibleNav = useMemo(
|
||||||
() => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"),
|
() => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"),
|
||||||
[profile?.navigation],
|
[profile?.navigation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (shellAuthPending) {
|
||||||
|
return <AdminSidebarSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" className="overflow-hidden">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader className="flex h-14 shrink-0 items-center gap-0 border-b border-sidebar-border p-0 px-2">
|
<SidebarHeader className="flex shrink-0 flex-col gap-0 border-b border-sidebar-border px-2 py-2">
|
||||||
<SidebarMenu className="h-full w-full">
|
<SidebarMenu className="h-full w-full">
|
||||||
<SidebarMenuItem className="h-full">
|
<SidebarMenuItem className="h-full">
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
render={<Link href={ADMIN_BASE} />}
|
render={<Link href={ADMIN_BASE} />}
|
||||||
className="h-full min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
|
className="h-10 min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
|
||||||
>
|
>
|
||||||
<div className="flex h-12 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
|
<div className="flex h-10 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="N lotto"
|
alt="N lotto"
|
||||||
className="h-auto max-h-11 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
className="h-auto max-h-10 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
{profile?.agent ? (
|
||||||
|
<p
|
||||||
|
className="truncate px-1 pb-1 text-[11px] leading-tight font-medium text-sidebar-foreground/60 group-data-[collapsible=icon]:hidden"
|
||||||
|
title={profile.agent.name}
|
||||||
|
>
|
||||||
|
{profile.agent.name}
|
||||||
|
<span className="text-sidebar-foreground/40"> · {profile.agent.code}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="relative overflow-hidden">
|
<SidebarContent className="relative min-h-0 overflow-hidden p-0">
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -164,32 +112,12 @@ export function AdminAppSidebar() {
|
|||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover object-bottom"
|
className="h-full w-full object-cover object-bottom"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-sidebar to-transparent" />
|
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||||
<div className="absolute inset-0 bg-sidebar/20" />
|
<div className="absolute inset-0 bg-sidebar/20" />
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroup>
|
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
|
||||||
<SidebarGroupLabel>{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })}</SidebarGroupLabel>
|
<AdminSidebarNav items={visibleNav} />
|
||||||
<SidebarGroupContent>
|
</div>
|
||||||
<SidebarMenu>
|
|
||||||
{visibleNav.map((item) => {
|
|
||||||
const Icon = resolveAdminNavIcon(item.segment);
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={item.segment}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
tooltip={adminNavLabel(item.segment, t, item.label)}
|
|
||||||
isActive={isActive(pathname, item)}
|
|
||||||
render={<Link href={item.href} />}
|
|
||||||
className="font-medium text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-active:bg-red-600 data-active:text-white data-active:shadow-sm"
|
|
||||||
>
|
|
||||||
<Icon data-icon="inline-start" aria-hidden />
|
|
||||||
<span>{adminNavLabel(item.segment, t, item.label)}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Image from "next/image";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
|||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { t } = useTranslation(["auth", "common"]);
|
const { t } = useTranslation(["auth", "common"]);
|
||||||
|
const tRef = useTranslationRef(["auth", "common"]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||||||
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
||||||
@@ -42,7 +44,7 @@ export function LoginForm() {
|
|||||||
try {
|
try {
|
||||||
const data = await getAdminCaptcha();
|
const data = await getAdminCaptcha();
|
||||||
if (!data) {
|
if (!data) {
|
||||||
toast.error(t("captchaLoadFailed"));
|
toast.error(tRef.current("captchaLoadFailed"));
|
||||||
setCaptchaKey(null);
|
setCaptchaKey(null);
|
||||||
setCaptchaSrc(null);
|
setCaptchaSrc(null);
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ export function LoginForm() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingCaptcha(false);
|
setLoadingCaptcha(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
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 { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
|
||||||
export type AdminSiteCodeOption = {
|
export type AdminSiteCodeOption = {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cachedSites: AdminSiteCodeOption[] | null = null;
|
||||||
|
let inflightSites: Promise<AdminSiteCodeOption[]> | null = null;
|
||||||
|
|
||||||
|
export function clearCachedAdminSiteCodeOptions(): void {
|
||||||
|
cachedSites = null;
|
||||||
|
inflightSites = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSiteCodeOptions(): Promise<AdminSiteCodeOption[]> {
|
||||||
|
if (cachedSites !== null) {
|
||||||
|
return cachedSites;
|
||||||
|
}
|
||||||
|
if (inflightSites !== null) {
|
||||||
|
return inflightSites;
|
||||||
|
}
|
||||||
|
|
||||||
|
inflightSites = getAdminIntegrationSites()
|
||||||
|
.then((data) => {
|
||||||
|
cachedSites = data.items.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
}));
|
||||||
|
return cachedSites;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
cachedSites = [];
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightSites = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return inflightSites;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接入站点下拉(已按当前管理员站点权限过滤)。
|
* 接入站点下拉(已按当前管理员站点权限过滤;模块级缓存避免多页重复 GET)。
|
||||||
*/
|
*/
|
||||||
export function useAdminSiteCodeOptions(): {
|
export function useAdminSiteCodeOptions(): {
|
||||||
sites: AdminSiteCodeOption[];
|
sites: AdminSiteCodeOption[];
|
||||||
@@ -24,24 +60,21 @@ export function useAdminSiteCodeOptions(): {
|
|||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canLoad = adminHasAnyPermission(profile?.permissions, PRD_INTEGRATION_ACCESS_ANY);
|
const canLoad = adminHasAnyPermission(profile?.permissions, PRD_INTEGRATION_ACCESS_ANY);
|
||||||
|
|
||||||
const [sites, setSites] = useState<AdminSiteCodeOption[]>([]);
|
const [sites, setSites] = useState<AdminSiteCodeOption[]>(cachedSites ?? []);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(canLoad && cachedSites === null);
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
if (!canLoad) {
|
if (!canLoad) {
|
||||||
setSites([]);
|
setSites([]);
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getAdminIntegrationSites();
|
clearCachedAdminSiteCodeOptions();
|
||||||
setSites(
|
const next = await fetchSiteCodeOptions();
|
||||||
data.items.map((row) => ({
|
setSites(next);
|
||||||
code: row.code,
|
|
||||||
name: row.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
setSites([]);
|
setSites([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,11 +82,24 @@ export function useAdminSiteCodeOptions(): {
|
|||||||
}
|
}
|
||||||
}, [canLoad]);
|
}, [canLoad]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
if (!canLoad) {
|
||||||
void reload();
|
setSites([]);
|
||||||
});
|
setLoading(false);
|
||||||
}, [reload]);
|
return;
|
||||||
|
}
|
||||||
|
if (cachedSites !== null) {
|
||||||
|
setSites(cachedSites);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const next = await fetchSiteCodeOptions();
|
||||||
|
setSites(next);
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, [canLoad]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sites,
|
sites,
|
||||||
|
|||||||
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 enReconcile from "@/i18n/locales/en/reconcile.json";
|
||||||
import enReports from "@/i18n/locales/en/reports.json";
|
import enReports from "@/i18n/locales/en/reports.json";
|
||||||
import enWallet from "@/i18n/locales/en/wallet.json";
|
import enWallet from "@/i18n/locales/en/wallet.json";
|
||||||
|
import enAgents from "@/i18n/locales/en/agents.json";
|
||||||
import neAudit from "@/i18n/locales/ne/audit.json";
|
import neAudit from "@/i18n/locales/ne/audit.json";
|
||||||
import neAdminUsers from "@/i18n/locales/ne/adminUsers.json";
|
import neAdminUsers from "@/i18n/locales/ne/adminUsers.json";
|
||||||
import neAuth from "@/i18n/locales/ne/auth.json";
|
import neAuth from "@/i18n/locales/ne/auth.json";
|
||||||
@@ -39,6 +40,7 @@ import neTickets from "@/i18n/locales/ne/tickets.json";
|
|||||||
import neReconcile from "@/i18n/locales/ne/reconcile.json";
|
import neReconcile from "@/i18n/locales/ne/reconcile.json";
|
||||||
import neReports from "@/i18n/locales/ne/reports.json";
|
import neReports from "@/i18n/locales/ne/reports.json";
|
||||||
import neWallet from "@/i18n/locales/ne/wallet.json";
|
import neWallet from "@/i18n/locales/ne/wallet.json";
|
||||||
|
import neAgents from "@/i18n/locales/ne/agents.json";
|
||||||
import zhAudit from "@/i18n/locales/zh/audit.json";
|
import zhAudit from "@/i18n/locales/zh/audit.json";
|
||||||
import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json";
|
import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json";
|
||||||
import zhAuth from "@/i18n/locales/zh/auth.json";
|
import zhAuth from "@/i18n/locales/zh/auth.json";
|
||||||
@@ -54,12 +56,13 @@ import zhTickets from "@/i18n/locales/zh/tickets.json";
|
|||||||
import zhReconcile from "@/i18n/locales/zh/reconcile.json";
|
import zhReconcile from "@/i18n/locales/zh/reconcile.json";
|
||||||
import zhReports from "@/i18n/locales/zh/reports.json";
|
import zhReports from "@/i18n/locales/zh/reports.json";
|
||||||
import zhWallet from "@/i18n/locales/zh/wallet.json";
|
import zhWallet from "@/i18n/locales/zh/wallet.json";
|
||||||
|
import zhAgents from "@/i18n/locales/zh/agents.json";
|
||||||
|
|
||||||
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
|
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
|
||||||
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
|
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
|
||||||
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh";
|
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh";
|
||||||
|
|
||||||
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "config"] as const;
|
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "agents", "config"] as const;
|
||||||
|
|
||||||
const resources = {
|
const resources = {
|
||||||
en: {
|
en: {
|
||||||
@@ -78,6 +81,7 @@ const resources = {
|
|||||||
audit: enAudit,
|
audit: enAudit,
|
||||||
settlement: enSettlement,
|
settlement: enSettlement,
|
||||||
wallet: enWallet,
|
wallet: enWallet,
|
||||||
|
agents: enAgents,
|
||||||
},
|
},
|
||||||
ne: {
|
ne: {
|
||||||
common: neCommon,
|
common: neCommon,
|
||||||
@@ -95,6 +99,7 @@ const resources = {
|
|||||||
audit: neAudit,
|
audit: neAudit,
|
||||||
settlement: neSettlement,
|
settlement: neSettlement,
|
||||||
wallet: neWallet,
|
wallet: neWallet,
|
||||||
|
agents: neAgents,
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
common: zhCommon,
|
common: zhCommon,
|
||||||
@@ -112,6 +117,7 @@ const resources = {
|
|||||||
audit: zhAudit,
|
audit: zhAudit,
|
||||||
settlement: zhSettlement,
|
settlement: zhSettlement,
|
||||||
wallet: zhWallet,
|
wallet: zhWallet,
|
||||||
|
agents: zhAgents,
|
||||||
},
|
},
|
||||||
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;
|
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;
|
||||||
|
|
||||||
|
|||||||
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",
|
"display": "Player",
|
||||||
"sitePlayerId": "Player ID"
|
"sitePlayerId": "Player ID"
|
||||||
},
|
},
|
||||||
|
"agentColumns": {
|
||||||
|
"agent": "Agent",
|
||||||
|
"filter": "Agent",
|
||||||
|
"filterAll": "All agents"
|
||||||
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"defaultAdmin": "Administrator",
|
"defaultAdmin": "Administrator",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
@@ -155,10 +160,19 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"account": "Account settings",
|
"account": "Account settings",
|
||||||
"integration": "Integration sites",
|
"integration": "Integration sites",
|
||||||
|
"agents": "Agents",
|
||||||
"config": "Operations config"
|
"config": "Operations config"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace",
|
||||||
|
"group": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"agent": "Agent organization",
|
||||||
|
"operations": "Operations",
|
||||||
|
"finance": "Finance & reports",
|
||||||
|
"rules": "Rules & parameters",
|
||||||
|
"platform": "Platform"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "Checking sign-in status…",
|
"checking": "Checking sign-in status…",
|
||||||
|
|||||||
@@ -178,7 +178,18 @@
|
|||||||
"loadFailed": "Failed to load system settings",
|
"loadFailed": "Failed to load system settings",
|
||||||
"saveSuccess": "System settings saved",
|
"saveSuccess": "System settings saved",
|
||||||
"saveRuntimeSuccess": "Draw and settlement parameters saved",
|
"saveRuntimeSuccess": "Draw and settlement parameters saved",
|
||||||
|
"saveDrawSuccess": "Draw parameters saved",
|
||||||
|
"saveCurrencyFormatSuccess": "Currency display format saved",
|
||||||
|
"saveSettlementSuccess": "Settlement automation saved",
|
||||||
"saveFrontendSuccess": "Front-end display settings saved",
|
"saveFrontendSuccess": "Front-end display settings saved",
|
||||||
|
"sections": {
|
||||||
|
"draw": "Draw schedule and review",
|
||||||
|
"drawDescription": "Controls draw timing, close window, manual review, and cooldown. Only changed fields in this block are submitted.",
|
||||||
|
"currencyFormat": "Currency display format",
|
||||||
|
"currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).",
|
||||||
|
"settlement": "Settlement automation",
|
||||||
|
"settlementDescription": "Controls whether tick auto-runs settlement, approval, and payout. Only changed fields in this block are submitted."
|
||||||
|
},
|
||||||
"saveFailed": "Failed to save system settings",
|
"saveFailed": "Failed to save system settings",
|
||||||
"unsavedChanges": "Unsaved changes",
|
"unsavedChanges": "Unsaved changes",
|
||||||
"frontendConfig": "Front-end configuration",
|
"frontendConfig": "Front-end configuration",
|
||||||
@@ -217,6 +228,12 @@
|
|||||||
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
|
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
|
||||||
"confirmSaveRuntimeTitle": "Save draw and settlement parameters?",
|
"confirmSaveRuntimeTitle": "Save draw and settlement parameters?",
|
||||||
"confirmSaveRuntimeDescription": "This updates draw review, schedule timing, cooldown, and auto settlement/approval/payout. Play-rules HTML is not changed.",
|
"confirmSaveRuntimeDescription": "This updates draw review, schedule timing, cooldown, and auto settlement/approval/payout. Play-rules HTML is not changed.",
|
||||||
|
"confirmSaveDrawTitle": "Save draw parameters?",
|
||||||
|
"confirmSaveDrawDescription": "This updates draw review, schedule timing, and cooldown in this block only.",
|
||||||
|
"confirmSaveCurrencyFormatTitle": "Save currency display format?",
|
||||||
|
"confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.",
|
||||||
|
"confirmSaveSettlementTitle": "Save settlement automation?",
|
||||||
|
"confirmSaveSettlementDescription": "This updates auto settlement, approval, and payout switches.",
|
||||||
"confirmSaveFrontendTitle": "Save front-end display settings?",
|
"confirmSaveFrontendTitle": "Save front-end display settings?",
|
||||||
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed."
|
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"granularityDay": "By day",
|
"granularityDay": "By day",
|
||||||
"playBreakdown": "Play breakdown",
|
"playBreakdown": "Play breakdown",
|
||||||
"playRanking": "Top 5 plays",
|
"playRanking": "Top 5 plays",
|
||||||
|
"agentRanking": "Top 5 agents",
|
||||||
"rankingMetricLabel": "Ranking metric",
|
"rankingMetricLabel": "Ranking metric",
|
||||||
"rankingMetrics": {
|
"rankingMetrics": {
|
||||||
"bet": "By bet amount",
|
"bet": "By bet amount",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
},
|
},
|
||||||
"periodDistribution": "Period structure",
|
"periodDistribution": "Period structure",
|
||||||
"noPlayData": "No play data in this period",
|
"noPlayData": "No play data in this period",
|
||||||
|
"noAgentData": "No agent data in this period",
|
||||||
"periods": {
|
"periods": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
"last_7_days": "Last 7 days",
|
"last_7_days": "Last 7 days",
|
||||||
@@ -90,6 +92,7 @@
|
|||||||
"batchPendingDraws": "Draws involved",
|
"batchPendingDraws": "Draws involved",
|
||||||
"batchPendingDrawsCount": "{{count}} draws pending",
|
"batchPendingDrawsCount": "{{count}} draws pending",
|
||||||
"platformLockedAndCap": "Site locked {{locked}} / cap {{cap}}",
|
"platformLockedAndCap": "Site locked {{locked}} / cap {{cap}}",
|
||||||
|
"platformCapNotConfigured": "Site locked {{locked}} · cap not configured",
|
||||||
"platformOrderAndTicket": "Site-wide {{orders}} orders · {{tickets}} lines",
|
"platformOrderAndTicket": "Site-wide {{orders}} orders · {{tickets}} lines",
|
||||||
"platformBetTotal": "Lifetime bet",
|
"platformBetTotal": "Lifetime bet",
|
||||||
"platformNoFinanceActivity": "No bets site-wide yet",
|
"platformNoFinanceActivity": "No bets site-wide yet",
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
"title": "Reconcile",
|
"title": "Reconcile",
|
||||||
"createTitle": "Create reconcile job",
|
"createTitle": "Create reconcile job",
|
||||||
"createDesc": "Manually check abnormal transfers by date range and optional player. Scheduled reconciliation still runs automatically.",
|
"createDesc": "Manually check abnormal transfers by date range and optional player. Scheduled reconciliation still runs automatically.",
|
||||||
|
"scopeTitle": "Define the reconcile scope",
|
||||||
|
"scopeDescription": "Choose the business type and date range first, then decide whether to narrow it to one player.",
|
||||||
"reconcileType": "Reconcile type",
|
"reconcileType": "Reconcile type",
|
||||||
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
|
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
|
||||||
"reconcileTypeHint": "Only wallet transfer is currently supported.",
|
"reconcileTypeHint": "Only wallet transfer is currently supported.",
|
||||||
"dateRange": "Reconcile date range",
|
"dateRange": "Reconcile date range",
|
||||||
|
"dateRangeHint": "Start with a shorter period to spot concentrated issues before widening the search.",
|
||||||
"createTask": "Create reconcile job",
|
"createTask": "Create reconcile job",
|
||||||
"submitting": "Submitting…",
|
"submitting": "Submitting…",
|
||||||
"loadFailed": "Failed to load",
|
"loadFailed": "Failed to load",
|
||||||
@@ -20,13 +23,21 @@
|
|||||||
"createSuccess": "Reconcile job created",
|
"createSuccess": "Reconcile job created",
|
||||||
"createFailed": "Failed to create job",
|
"createFailed": "Failed to create job",
|
||||||
"noCreatePermission": "Current account cannot create reconcile jobs.",
|
"noCreatePermission": "Current account cannot create reconcile jobs.",
|
||||||
|
"playerScopeTitle": "Optionally narrow to one player",
|
||||||
|
"playerAllPlayersHint": "If no player is selected, the reconcile job will cover all players in the chosen date range.",
|
||||||
|
"createSummaryAll": "A manual reconcile will run for all players from {{from}} to {{to}}.",
|
||||||
|
"createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.",
|
||||||
"jobsTitle": "Reconcile jobs",
|
"jobsTitle": "Reconcile jobs",
|
||||||
"jobsDesc": "Use the action on the right to open paginated item details.",
|
"jobsDesc": "Use the action on the right to open paginated item details.",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"jobNo": "Job no.",
|
"jobNo": "Job no.",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
"itemCount": "Items",
|
||||||
|
"mismatchCount": "Mismatches",
|
||||||
|
"matchedCount": "Matched",
|
||||||
"period": "Period",
|
"period": "Period",
|
||||||
|
"finishedAt": "Finished at",
|
||||||
"createdAt": "Created at",
|
"createdAt": "Created at",
|
||||||
"operate": "Action",
|
"operate": "Action",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
@@ -34,6 +45,7 @@
|
|||||||
"sideARef": "Lottery ref",
|
"sideARef": "Lottery ref",
|
||||||
"sideBRef": "Main site ref",
|
"sideBRef": "Main site ref",
|
||||||
"differenceAmount": "Difference (cent)",
|
"differenceAmount": "Difference (cent)",
|
||||||
|
"detectedAt": "Detected at",
|
||||||
"noDetails": "No details",
|
"noDetails": "No details",
|
||||||
"playerSearch": "Player (optional)",
|
"playerSearch": "Player (optional)",
|
||||||
"playerSearchPlaceholder": "Search by player ID / username / nickname",
|
"playerSearchPlaceholder": "Search by player ID / username / nickname",
|
||||||
|
|||||||
@@ -84,15 +84,109 @@
|
|||||||
"subtitle": "Results appear below. Export as CSV or Excel.",
|
"subtitle": "Results appear below. Export as CSV or Excel.",
|
||||||
"empty": "No data. Adjust filters and try again.",
|
"empty": "No data. Adjust filters and try again.",
|
||||||
"exportableRows": "rows exportable",
|
"exportableRows": "rows exportable",
|
||||||
|
"summaryScopeHint": "Except for the total record count, the stat cards above summarize the current preview page. Use full CSV/Excel export for full-range numbers.",
|
||||||
|
"scope": {
|
||||||
|
"currentPage": "Current page"
|
||||||
|
},
|
||||||
"columns": {
|
"columns": {
|
||||||
"primary": "",
|
"primary": "Primary",
|
||||||
"secondary": "",
|
"secondary": "Secondary",
|
||||||
"metricA": "",
|
"metricA": "Metric A",
|
||||||
"metricB": "",
|
"metricB": "Metric B",
|
||||||
"metricC": "",
|
"metricC": "Metric C",
|
||||||
"status": "",
|
"status": "Status",
|
||||||
"extra": "",
|
"extra": "Extra",
|
||||||
"time": ""
|
"time": "Time",
|
||||||
|
"drawProfit": {
|
||||||
|
"primary": "Draw / Batch",
|
||||||
|
"secondary": "Draw / Settlement status",
|
||||||
|
"metricA": "Orders / Tickets",
|
||||||
|
"metricB": "Tickets / Winners",
|
||||||
|
"metricC": "Bet / House P&L",
|
||||||
|
"status": "Payout / Jackpot",
|
||||||
|
"extra": "Batch count",
|
||||||
|
"time": "Finished"
|
||||||
|
},
|
||||||
|
"dailyProfit": {
|
||||||
|
"primary": "Business date",
|
||||||
|
"secondary": "Note",
|
||||||
|
"metricA": "Bet",
|
||||||
|
"metricB": "Payout",
|
||||||
|
"metricC": "House P&L",
|
||||||
|
"status": "Refund",
|
||||||
|
"extra": "Net",
|
||||||
|
"time": "Updated"
|
||||||
|
},
|
||||||
|
"playerWinLoss": {
|
||||||
|
"primary": "Player",
|
||||||
|
"secondary": "Player ID",
|
||||||
|
"metricA": "Bet",
|
||||||
|
"metricB": "Payout",
|
||||||
|
"metricC": "Net win/loss",
|
||||||
|
"status": "Tier",
|
||||||
|
"extra": "Note",
|
||||||
|
"time": "Time"
|
||||||
|
},
|
||||||
|
"playerTransfer": {
|
||||||
|
"primary": "Transfer no.",
|
||||||
|
"secondary": "Player",
|
||||||
|
"metricA": "Direction",
|
||||||
|
"metricB": "Status",
|
||||||
|
"metricC": "Amount",
|
||||||
|
"status": "External ref",
|
||||||
|
"extra": "Failure reason",
|
||||||
|
"time": "Created"
|
||||||
|
},
|
||||||
|
"hotNumberRisk": {
|
||||||
|
"primary": "Number / Log",
|
||||||
|
"secondary": "Draw / Action",
|
||||||
|
"metricA": "Cap / Amount",
|
||||||
|
"metricB": "Locked / Play",
|
||||||
|
"metricC": "Remaining / Ticket",
|
||||||
|
"status": "Sold out / Player",
|
||||||
|
"extra": "Usage / Reason",
|
||||||
|
"time": "Version / Time"
|
||||||
|
},
|
||||||
|
"playDimension": {
|
||||||
|
"primary": "Play",
|
||||||
|
"secondary": "Dimension",
|
||||||
|
"metricA": "Bet",
|
||||||
|
"metricB": "Payout",
|
||||||
|
"metricC": "House P&L",
|
||||||
|
"status": "Share",
|
||||||
|
"extra": "Note",
|
||||||
|
"time": "Time"
|
||||||
|
},
|
||||||
|
"soldOut": {
|
||||||
|
"primary": "Number",
|
||||||
|
"secondary": "Draw",
|
||||||
|
"metricA": "Cap",
|
||||||
|
"metricB": "Locked",
|
||||||
|
"metricC": "Remaining",
|
||||||
|
"status": "Sold out",
|
||||||
|
"extra": "Usage",
|
||||||
|
"time": "Version"
|
||||||
|
},
|
||||||
|
"rebateCommission": {
|
||||||
|
"primary": "Play",
|
||||||
|
"secondary": "Orders",
|
||||||
|
"metricA": "Rebate",
|
||||||
|
"metricB": "Ticket items",
|
||||||
|
"metricC": "Commission",
|
||||||
|
"status": "Rule hit",
|
||||||
|
"extra": "Note",
|
||||||
|
"time": "Time"
|
||||||
|
},
|
||||||
|
"adminAudit": {
|
||||||
|
"primary": "Log ID",
|
||||||
|
"secondary": "Operator type",
|
||||||
|
"metricA": "Operator ID",
|
||||||
|
"metricB": "Module",
|
||||||
|
"metricC": "Action",
|
||||||
|
"status": "Target type",
|
||||||
|
"extra": "IP",
|
||||||
|
"time": "Time"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"records": "Records",
|
"records": "Records",
|
||||||
@@ -179,7 +273,7 @@
|
|||||||
},
|
},
|
||||||
"daily_profit": {
|
"daily_profit": {
|
||||||
"title": "Daily P&L summary",
|
"title": "Daily P&L summary",
|
||||||
"summary": "Summarize bets, payouts, refunds, P&L, and net amount by date."
|
"summary": "Summarize bet amount, payout, and house P&L by business date. Refund and standalone net amount are not included yet."
|
||||||
},
|
},
|
||||||
"player_win_loss": {
|
"player_win_loss": {
|
||||||
"title": "Player win/loss report",
|
"title": "Player win/loss report",
|
||||||
|
|||||||
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": "खेलाडी",
|
"display": "खेलाडी",
|
||||||
"sitePlayerId": "खेलाडी ID"
|
"sitePlayerId": "खेलाडी ID"
|
||||||
},
|
},
|
||||||
|
"agentColumns": {
|
||||||
|
"agent": "एजेन्ट",
|
||||||
|
"filter": "एजेन्ट",
|
||||||
|
"filterAll": "सबै एजेन्ट"
|
||||||
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"defaultAdmin": "प्रशासक",
|
"defaultAdmin": "प्रशासक",
|
||||||
"notifications": "सूचना",
|
"notifications": "सूचना",
|
||||||
@@ -155,10 +160,19 @@
|
|||||||
"settings": "सेटिङ",
|
"settings": "सेटिङ",
|
||||||
"account": "खाता सेटिङ",
|
"account": "खाता सेटिङ",
|
||||||
"integration": "मुख्य साइट एकीकरण",
|
"integration": "मुख्य साइट एकीकरण",
|
||||||
|
"agents": "एजेन्ट व्यवस्थापन",
|
||||||
"config": "सञ्चालन कन्फिगरेसन"
|
"config": "सञ्चालन कन्फिगरेसन"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "कार्यस्थान"
|
"workspace": "कार्यस्थान",
|
||||||
|
"group": {
|
||||||
|
"overview": "सारांश",
|
||||||
|
"agent": "एजेन्ट संगठन",
|
||||||
|
"operations": "दैनिक सञ्चालन",
|
||||||
|
"finance": "वित्त र रिपोर्ट",
|
||||||
|
"rules": "नियम र प्यारामिटर",
|
||||||
|
"platform": "प्लेटफर्म"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "लगइन स्थिति जाँच हुँदैछ…",
|
"checking": "लगइन स्थिति जाँच हुँदैछ…",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"granularityDay": "दैनिक",
|
"granularityDay": "दैनिक",
|
||||||
"playBreakdown": "प्ले विभाजन",
|
"playBreakdown": "प्ले विभाजन",
|
||||||
"playRanking": "शीर्ष ५ प्ले",
|
"playRanking": "शीर्ष ५ प्ले",
|
||||||
|
"agentRanking": "शीर्ष ५ एजेन्ट",
|
||||||
"rankingMetricLabel": "रैंकिङ मेट्रिक",
|
"rankingMetricLabel": "रैंकिङ मेट्रिक",
|
||||||
"rankingMetrics": {
|
"rankingMetrics": {
|
||||||
"bet": "बेट रकम",
|
"bet": "बेट रकम",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
},
|
},
|
||||||
"periodDistribution": "अवधि संरचना",
|
"periodDistribution": "अवधि संरचना",
|
||||||
"noPlayData": "यस अवधिमा प्ले डाटा छैन",
|
"noPlayData": "यस अवधिमा प्ले डाटा छैन",
|
||||||
|
"noAgentData": "यस अवधिमा एजेन्ट डाटा छैन",
|
||||||
"periods": {
|
"periods": {
|
||||||
"today": "आज",
|
"today": "आज",
|
||||||
"last_7_days": "पछिल्लो ७ दिन",
|
"last_7_days": "पछिल्लो ७ दिन",
|
||||||
@@ -90,6 +92,7 @@
|
|||||||
"batchPendingDraws": "सम्बन्धित ड्रअ",
|
"batchPendingDraws": "सम्बन्धित ड्रअ",
|
||||||
"batchPendingDrawsCount": "{{count}} ड्रअ पेन्डिङ",
|
"batchPendingDrawsCount": "{{count}} ड्रअ पेन्डिङ",
|
||||||
"platformLockedAndCap": "साइट लक {{locked}} / क्याप {{cap}}",
|
"platformLockedAndCap": "साइट लक {{locked}} / क्याप {{cap}}",
|
||||||
|
"platformCapNotConfigured": "साइट लक {{locked}} · क्याप कन्फिगर गरिएको छैन",
|
||||||
"platformOrderAndTicket": "साइटव्यापी {{orders}} अर्डर · {{tickets}} लाइन",
|
"platformOrderAndTicket": "साइटव्यापी {{orders}} अर्डर · {{tickets}} लाइन",
|
||||||
"platformBetTotal": "जम्मा बेट",
|
"platformBetTotal": "जम्मा बेट",
|
||||||
"platformNoFinanceActivity": "साइटव्यापी अहिले बेट छैन",
|
"platformNoFinanceActivity": "साइटव्यापी अहिले बेट छैन",
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
"title": "मिलान",
|
"title": "मिलान",
|
||||||
"createTitle": "म्यानुअल मिलान कार्य",
|
"createTitle": "म्यानुअल मिलान कार्य",
|
||||||
"createDesc": "मिति दायरा र वैकल्पिक खेलाडी चयनबाट असामान्य ट्रान्सफर म्यानुअल रूपमा जाँच गर्नुहोस्। scheduled reconciliation स्वतः चलिरहन्छ।",
|
"createDesc": "मिति दायरा र वैकल्पिक खेलाडी चयनबाट असामान्य ट्रान्सफर म्यानुअल रूपमा जाँच गर्नुहोस्। scheduled reconciliation स्वतः चलिरहन्छ।",
|
||||||
|
"scopeTitle": "पहिले मिलानको दायरा तय गर्नुहोस्",
|
||||||
|
"scopeDescription": "पहिले व्यवसाय प्रकार र मिति दायरा रोज्नुहोस्, त्यसपछि आवश्यक परे एक खेलाडीमा सीमित गर्नुहोस्।",
|
||||||
"reconcileType": "मिलान प्रकार",
|
"reconcileType": "मिलान प्रकार",
|
||||||
"reconcileTypeFixed": "वालेट ट्रान्सफर (मुख्य साइट ⇄ लटरी)",
|
"reconcileTypeFixed": "वालेट ट्रान्सफर (मुख्य साइट ⇄ लटरी)",
|
||||||
"reconcileTypeHint": "हाल वालेट ट्रान्सफर मात्र समर्थित छ।",
|
"reconcileTypeHint": "हाल वालेट ट्रान्सफर मात्र समर्थित छ।",
|
||||||
"dateRange": "मिलान मिति दायरा",
|
"dateRange": "मिलान मिति दायरा",
|
||||||
|
"dateRangeHint": "पहिले छोटो समयावधि रोजेर समस्या कहाँ केन्द्रित छ हेर्नुहोस्, त्यसपछि आवश्यक परे दायरा बढाउनुहोस्।",
|
||||||
"createTask": "मिलान कार्य सिर्जना",
|
"createTask": "मिलान कार्य सिर्जना",
|
||||||
"submitting": "पेश हुँदैछ…",
|
"submitting": "पेश हुँदैछ…",
|
||||||
"loadFailed": "लोड असफल भयो",
|
"loadFailed": "लोड असफल भयो",
|
||||||
@@ -16,13 +19,21 @@
|
|||||||
"createSuccess": "मिलान कार्य सिर्जना भयो",
|
"createSuccess": "मिलान कार्य सिर्जना भयो",
|
||||||
"createFailed": "कार्य सिर्जना असफल भयो",
|
"createFailed": "कार्य सिर्जना असफल भयो",
|
||||||
"noCreatePermission": "हालको खातासँग मिलान कार्य सिर्जना गर्ने अनुमति छैन।",
|
"noCreatePermission": "हालको खातासँग मिलान कार्य सिर्जना गर्ने अनुमति छैन।",
|
||||||
|
"playerScopeTitle": "आवश्यक परे एक खेलाडीमा सीमित गर्नुहोस्",
|
||||||
|
"playerAllPlayersHint": "खेलाडी नछानेमा, छनोट गरिएको मिति दायराभित्र सबै खेलाडीका लागि मिलान चलाइनेछ।",
|
||||||
|
"createSummaryAll": "{{from}} देखि {{to}} सम्म सबै खेलाडीका लागि म्यानुअल मिलान चलाइनेछ।",
|
||||||
|
"createSummaryPlayer": "खेलाडी {{player}} का लागि {{from}} देखि {{to}} सम्म म्यानुअल मिलान चलाइनेछ।",
|
||||||
"jobsTitle": "मिलान कार्यहरू",
|
"jobsTitle": "मिलान कार्यहरू",
|
||||||
"jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।",
|
"jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।",
|
||||||
"refresh": "रिफ्रेस",
|
"refresh": "रिफ्रेस",
|
||||||
"jobNo": "कार्य नं.",
|
"jobNo": "कार्य नं.",
|
||||||
"type": "प्रकार",
|
"type": "प्रकार",
|
||||||
"status": "स्थिति",
|
"status": "स्थिति",
|
||||||
|
"itemCount": "विवरण संख्या",
|
||||||
|
"mismatchCount": "असंगति",
|
||||||
|
"matchedCount": "मेल खाएका",
|
||||||
"period": "अवधि",
|
"period": "अवधि",
|
||||||
|
"finishedAt": "समाप्त समय",
|
||||||
"createdAt": "सिर्जना समय",
|
"createdAt": "सिर्जना समय",
|
||||||
"operate": "कार्य",
|
"operate": "कार्य",
|
||||||
"view": "हेर्नुहोस्",
|
"view": "हेर्नुहोस्",
|
||||||
@@ -30,6 +41,7 @@
|
|||||||
"sideARef": "लटरी साइड सन्दर्भ",
|
"sideARef": "लटरी साइड सन्दर्भ",
|
||||||
"sideBRef": "मुख्य साइट सन्दर्भ",
|
"sideBRef": "मुख्य साइट सन्दर्भ",
|
||||||
"differenceAmount": "अन्तर (cent)",
|
"differenceAmount": "अन्तर (cent)",
|
||||||
|
"detectedAt": "फेला परेको समय",
|
||||||
"noDetails": "विवरण छैन",
|
"noDetails": "विवरण छैन",
|
||||||
"playerSearch": "खेलाडी (वैकल्पिक)",
|
"playerSearch": "खेलाडी (वैकल्पिक)",
|
||||||
"playerSearchPlaceholder": "player ID / username / nickname बाट खोज्नुहोस्",
|
"playerSearchPlaceholder": "player ID / username / nickname बाट खोज्नुहोस्",
|
||||||
|
|||||||
@@ -84,15 +84,109 @@
|
|||||||
"subtitle": "तल तालिकामा नतिजा देखिन्छ।",
|
"subtitle": "तल तालिकामा नतिजा देखिन्छ।",
|
||||||
"empty": "डाटा छैन।",
|
"empty": "डाटा छैन।",
|
||||||
"exportableRows": "पङ्क्ति निर्यात योग्य",
|
"exportableRows": "पङ्क्ति निर्यात योग्य",
|
||||||
|
"summaryScopeHint": "कुल रेकर्ड बाहेक माथिका कार्डहरू हालको पूर्वावलोकन पृष्ठको योग हुन्। पूर्ण दायराको संख्या चाहिँ पूर्ण CSV/Excel निर्यात प्रयोग गर्नुहोस्।",
|
||||||
|
"scope": {
|
||||||
|
"currentPage": "हालको पृष्ठ"
|
||||||
|
},
|
||||||
"columns": {
|
"columns": {
|
||||||
"primary": "",
|
"primary": "मुख्य",
|
||||||
"secondary": "",
|
"secondary": "सहायक",
|
||||||
"metricA": "",
|
"metricA": "सूचक A",
|
||||||
"metricB": "",
|
"metricB": "सूचक B",
|
||||||
"metricC": "",
|
"metricC": "सूचक C",
|
||||||
"status": "",
|
"status": "स्थिति",
|
||||||
"extra": "",
|
"extra": "थप",
|
||||||
"time": ""
|
"time": "समय",
|
||||||
|
"drawProfit": {
|
||||||
|
"primary": "ड्र / ब्याच",
|
||||||
|
"secondary": "ड्र / सेटलमेन्ट स्थिति",
|
||||||
|
"metricA": "अर्डर / टिकट",
|
||||||
|
"metricB": "टिकट / विजेता",
|
||||||
|
"metricC": "बेट / हाउस P&L",
|
||||||
|
"status": "पेआउट / ज्याकपोट",
|
||||||
|
"extra": "ब्याच संख्या",
|
||||||
|
"time": "समाप्त"
|
||||||
|
},
|
||||||
|
"dailyProfit": {
|
||||||
|
"primary": "व्यावसायिक मिति",
|
||||||
|
"secondary": "टिप्पणी",
|
||||||
|
"metricA": "बेट",
|
||||||
|
"metricB": "पेआउट",
|
||||||
|
"metricC": "हाउस P&L",
|
||||||
|
"status": "रिफन्ड",
|
||||||
|
"extra": "नेट",
|
||||||
|
"time": "अपडेट"
|
||||||
|
},
|
||||||
|
"playerWinLoss": {
|
||||||
|
"primary": "खेलाडी",
|
||||||
|
"secondary": "खेलाडी ID",
|
||||||
|
"metricA": "बेट",
|
||||||
|
"metricB": "पेआउट",
|
||||||
|
"metricC": "नेट जित/हार",
|
||||||
|
"status": "स्तर",
|
||||||
|
"extra": "टिप्पणी",
|
||||||
|
"time": "समय"
|
||||||
|
},
|
||||||
|
"playerTransfer": {
|
||||||
|
"primary": "ट्रान्सफर नं.",
|
||||||
|
"secondary": "खेलाडी",
|
||||||
|
"metricA": "दिशा",
|
||||||
|
"metricB": "स्थिति",
|
||||||
|
"metricC": "रकम",
|
||||||
|
"status": "बाह्य सन्दर्भ",
|
||||||
|
"extra": "असफल कारण",
|
||||||
|
"time": "सिर्जना"
|
||||||
|
},
|
||||||
|
"hotNumberRisk": {
|
||||||
|
"primary": "नम्बर / लग",
|
||||||
|
"secondary": "ड्र / कार्य",
|
||||||
|
"metricA": "क्याप / रकम",
|
||||||
|
"metricB": "लक / खेल",
|
||||||
|
"metricC": "बाँकी / टिकट",
|
||||||
|
"status": "सोल्ड आउट / खेलाडी",
|
||||||
|
"extra": "प्रयोग / कारण",
|
||||||
|
"time": "संस्करण / समय"
|
||||||
|
},
|
||||||
|
"playDimension": {
|
||||||
|
"primary": "खेल",
|
||||||
|
"secondary": "आयाम",
|
||||||
|
"metricA": "बेट",
|
||||||
|
"metricB": "पेआउट",
|
||||||
|
"metricC": "हाउस P&L",
|
||||||
|
"status": "अनुपात",
|
||||||
|
"extra": "टिप्पणी",
|
||||||
|
"time": "समय"
|
||||||
|
},
|
||||||
|
"soldOut": {
|
||||||
|
"primary": "नम्बर",
|
||||||
|
"secondary": "ड्र",
|
||||||
|
"metricA": "क्याप",
|
||||||
|
"metricB": "लक",
|
||||||
|
"metricC": "बाँकी",
|
||||||
|
"status": "सोल्ड आउट",
|
||||||
|
"extra": "प्रयोग",
|
||||||
|
"time": "संस्करण"
|
||||||
|
},
|
||||||
|
"rebateCommission": {
|
||||||
|
"primary": "खेल",
|
||||||
|
"secondary": "अर्डर",
|
||||||
|
"metricA": "रिबेट",
|
||||||
|
"metricB": "टिकट आइटम",
|
||||||
|
"metricC": "कमिसन",
|
||||||
|
"status": "नियम मिलान",
|
||||||
|
"extra": "टिप्पणी",
|
||||||
|
"time": "समय"
|
||||||
|
},
|
||||||
|
"adminAudit": {
|
||||||
|
"primary": "लग ID",
|
||||||
|
"secondary": "अपरेटर प्रकार",
|
||||||
|
"metricA": "अपरेटर ID",
|
||||||
|
"metricB": "मोड्युल",
|
||||||
|
"metricC": "कार्य",
|
||||||
|
"status": "लक्ष्य प्रकार",
|
||||||
|
"extra": "IP",
|
||||||
|
"time": "समय"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"records": "रेकर्ड",
|
"records": "रेकर्ड",
|
||||||
@@ -179,7 +273,7 @@
|
|||||||
},
|
},
|
||||||
"daily_profit": {
|
"daily_profit": {
|
||||||
"title": "दैनिक P&L सारांश",
|
"title": "दैनिक P&L सारांश",
|
||||||
"summary": "मिति अनुसार बेट, पेआउट, रिफन्ड, P&L र नेट रकम सारांश गर्नुहोस्।"
|
"summary": "व्यावसायिक मितिअनुसार बेट रकम, पेआउट र हाउस P&L सारांश गर्नुहोस्। रिफन्ड र छुट्टै नेट रकम अहिले समावेश छैन।"
|
||||||
},
|
},
|
||||||
"player_win_loss": {
|
"player_win_loss": {
|
||||||
"title": "खेलाडी जित/हार रिपोर्ट",
|
"title": "खेलाडी जित/हार रिपोर्ट",
|
||||||
|
|||||||
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": "玩家",
|
"display": "玩家",
|
||||||
"sitePlayerId": "玩家 ID"
|
"sitePlayerId": "玩家 ID"
|
||||||
},
|
},
|
||||||
|
"agentColumns": {
|
||||||
|
"agent": "所属代理",
|
||||||
|
"filter": "代理",
|
||||||
|
"filterAll": "全部代理"
|
||||||
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"defaultAdmin": "管理员",
|
"defaultAdmin": "管理员",
|
||||||
"notifications": "通知",
|
"notifications": "通知",
|
||||||
@@ -155,10 +160,19 @@
|
|||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
"account": "账号设置",
|
"account": "账号设置",
|
||||||
"integration": "接入站点",
|
"integration": "接入站点",
|
||||||
|
"agents": "代理管理",
|
||||||
"config": "运营配置"
|
"config": "运营配置"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "工作台"
|
"workspace": "工作台",
|
||||||
|
"group": {
|
||||||
|
"overview": "总览",
|
||||||
|
"agent": "代理组织",
|
||||||
|
"operations": "日常运营",
|
||||||
|
"finance": "资金与报表",
|
||||||
|
"rules": "规则与参数",
|
||||||
|
"platform": "平台管理"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "正在校验登录状态…",
|
"checking": "正在校验登录状态…",
|
||||||
|
|||||||
@@ -178,7 +178,18 @@
|
|||||||
"loadFailed": "系统设置加载失败",
|
"loadFailed": "系统设置加载失败",
|
||||||
"saveSuccess": "系统设置已保存",
|
"saveSuccess": "系统设置已保存",
|
||||||
"saveRuntimeSuccess": "开奖与结算参数已保存",
|
"saveRuntimeSuccess": "开奖与结算参数已保存",
|
||||||
|
"saveDrawSuccess": "开奖参数已保存",
|
||||||
|
"saveCurrencyFormatSuccess": "金额显示格式已保存",
|
||||||
|
"saveSettlementSuccess": "结算自动化参数已保存",
|
||||||
"saveFrontendSuccess": "前端展示配置已保存",
|
"saveFrontendSuccess": "前端展示配置已保存",
|
||||||
|
"sections": {
|
||||||
|
"draw": "开奖节奏与审核",
|
||||||
|
"drawDescription": "控制期号节奏、封盘与开奖后人工审核、冷静期。仅保存本区块内修改过的项。",
|
||||||
|
"currencyFormat": "金额显示格式",
|
||||||
|
"currencyFormatDescription": "全站金额展示的小数位与分隔符,与币种主数据无关。",
|
||||||
|
"settlement": "结算自动化",
|
||||||
|
"settlementDescription": "控制 tick 是否自动结算、审核与派彩。修改后只提交本区块变更项。"
|
||||||
|
},
|
||||||
"saveFailed": "系统设置保存失败",
|
"saveFailed": "系统设置保存失败",
|
||||||
"unsavedChanges": "有未保存的更改",
|
"unsavedChanges": "有未保存的更改",
|
||||||
"frontendConfig": "前端配置",
|
"frontendConfig": "前端配置",
|
||||||
@@ -217,6 +228,12 @@
|
|||||||
"confirmSaveDescription": "将更新开奖审核、冷静期、自动结算/审核/派彩及玩法规则展示,可能影响全站运行。",
|
"confirmSaveDescription": "将更新开奖审核、冷静期、自动结算/审核/派彩及玩法规则展示,可能影响全站运行。",
|
||||||
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
|
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
|
||||||
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
|
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
|
||||||
|
"confirmSaveDrawTitle": "确认保存开奖参数?",
|
||||||
|
"confirmSaveDrawDescription": "将更新开奖审核、期号节奏与冷静期等本区块字段。",
|
||||||
|
"confirmSaveCurrencyFormatTitle": "确认保存金额显示格式?",
|
||||||
|
"confirmSaveCurrencyFormatDescription": "将更新小数位与千分位/小数分隔符。",
|
||||||
|
"confirmSaveSettlementTitle": "确认保存结算自动化?",
|
||||||
|
"confirmSaveSettlementDescription": "将更新自动结算、审核与派彩相关开关。",
|
||||||
"confirmSaveFrontendTitle": "确认保存前端展示配置?",
|
"confirmSaveFrontendTitle": "确认保存前端展示配置?",
|
||||||
"confirmSaveFrontendDescription": "将更新玩家端玩法规则页面 HTML,不影响开奖与结算逻辑。"
|
"confirmSaveFrontendDescription": "将更新玩家端玩法规则页面 HTML,不影响开奖与结算逻辑。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"granularityDay": "按天",
|
"granularityDay": "按天",
|
||||||
"playBreakdown": "玩法拆解 Top",
|
"playBreakdown": "玩法拆解 Top",
|
||||||
"playRanking": "玩法排行榜 Top 5",
|
"playRanking": "玩法排行榜 Top 5",
|
||||||
|
"agentRanking": "代理排行榜 Top 5",
|
||||||
"rankingMetricLabel": "排行维度",
|
"rankingMetricLabel": "排行维度",
|
||||||
"rankingMetrics": {
|
"rankingMetrics": {
|
||||||
"bet": "按投注金额",
|
"bet": "按投注金额",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
},
|
},
|
||||||
"periodDistribution": "区间结构对比",
|
"periodDistribution": "区间结构对比",
|
||||||
"noPlayData": "该区间暂无玩法数据",
|
"noPlayData": "该区间暂无玩法数据",
|
||||||
|
"noAgentData": "该区间暂无代理数据",
|
||||||
"periods": {
|
"periods": {
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
"last_7_days": "近 7 天",
|
"last_7_days": "近 7 天",
|
||||||
@@ -90,6 +92,7 @@
|
|||||||
"batchPendingDraws": "涉及期数",
|
"batchPendingDraws": "涉及期数",
|
||||||
"batchPendingDrawsCount": "{{count}} 期待审",
|
"batchPendingDrawsCount": "{{count}} 期待审",
|
||||||
"platformLockedAndCap": "全站已占用 {{locked}} / 封顶 {{cap}}",
|
"platformLockedAndCap": "全站已占用 {{locked}} / 封顶 {{cap}}",
|
||||||
|
"platformCapNotConfigured": "全站已占用 {{locked}} · 尚未配置封顶",
|
||||||
"platformOrderAndTicket": "全站 {{orders}} 单 · {{tickets}} 笔",
|
"platformOrderAndTicket": "全站 {{orders}} 单 · {{tickets}} 笔",
|
||||||
"platformBetTotal": "累计投注",
|
"platformBetTotal": "累计投注",
|
||||||
"platformNoFinanceActivity": "全站暂无投注",
|
"platformNoFinanceActivity": "全站暂无投注",
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
"title": "对账",
|
"title": "对账",
|
||||||
"createTitle": "人工发起对账",
|
"createTitle": "人工发起对账",
|
||||||
"createDesc": "用于按日期范围并可选指定玩家,人工核对异常转账。系统定时对账仍会自动执行。",
|
"createDesc": "用于按日期范围并可选指定玩家,人工核对异常转账。系统定时对账仍会自动执行。",
|
||||||
|
"scopeTitle": "先定义对账范围",
|
||||||
|
"scopeDescription": "先确定要核对的业务类型和日期区间,再决定是否缩小到单个玩家。",
|
||||||
"reconcileType": "对账类型",
|
"reconcileType": "对账类型",
|
||||||
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
|
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
|
||||||
"reconcileTypeHint": "当前仅支持钱包划转。",
|
"reconcileTypeHint": "当前仅支持钱包划转。",
|
||||||
"dateRange": "对账日期范围",
|
"dateRange": "对账日期范围",
|
||||||
|
"dateRangeHint": "建议优先选较短时间段,先看异常是否集中,再按需扩大范围。",
|
||||||
"createTask": "创建对账任务",
|
"createTask": "创建对账任务",
|
||||||
"submitting": "提交中…",
|
"submitting": "提交中…",
|
||||||
"loadFailed": "加载失败",
|
"loadFailed": "加载失败",
|
||||||
@@ -20,13 +23,21 @@
|
|||||||
"createSuccess": "已创建对账任务",
|
"createSuccess": "已创建对账任务",
|
||||||
"createFailed": "创建失败",
|
"createFailed": "创建失败",
|
||||||
"noCreatePermission": "当前账号无新建对账任务权限。",
|
"noCreatePermission": "当前账号无新建对账任务权限。",
|
||||||
|
"playerScopeTitle": "再决定是否指定玩家",
|
||||||
|
"playerAllPlayersHint": "不选择玩家时,会按日期范围对全量玩家做一次人工对账。",
|
||||||
|
"createSummaryAll": "将对 {{from}} 至 {{to}} 的全量玩家发起人工对账。",
|
||||||
|
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
|
||||||
"jobsTitle": "对账任务",
|
"jobsTitle": "对账任务",
|
||||||
"jobsDesc": "在右侧操作中查看差异明细与分页。",
|
"jobsDesc": "在右侧操作中查看差异明细与分页。",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"jobNo": "任务号",
|
"jobNo": "任务号",
|
||||||
"type": "类型",
|
"type": "类型",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
|
"itemCount": "明细数",
|
||||||
|
"mismatchCount": "异常数",
|
||||||
|
"matchedCount": "一致数",
|
||||||
"period": "对账周期",
|
"period": "对账周期",
|
||||||
|
"finishedAt": "完成时间",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"operate": "操作",
|
"operate": "操作",
|
||||||
"view": "查看",
|
"view": "查看",
|
||||||
@@ -34,6 +45,7 @@
|
|||||||
"sideARef": "彩票侧引用",
|
"sideARef": "彩票侧引用",
|
||||||
"sideBRef": "主站侧引用",
|
"sideBRef": "主站侧引用",
|
||||||
"differenceAmount": "差额(分)",
|
"differenceAmount": "差额(分)",
|
||||||
|
"detectedAt": "发现时间",
|
||||||
"noDetails": "无明细",
|
"noDetails": "无明细",
|
||||||
"playerSearch": "指定玩家(可选)",
|
"playerSearch": "指定玩家(可选)",
|
||||||
"playerSearchPlaceholder": "输入玩家 ID / 用户名 / 昵称搜索",
|
"playerSearchPlaceholder": "输入玩家 ID / 用户名 / 昵称搜索",
|
||||||
|
|||||||
@@ -84,15 +84,109 @@
|
|||||||
"subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。",
|
"subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。",
|
||||||
"empty": "暂无数据,请调整筛选条件后重试。",
|
"empty": "暂无数据,请调整筛选条件后重试。",
|
||||||
"exportableRows": "行可导出",
|
"exportableRows": "行可导出",
|
||||||
|
"summaryScopeHint": "上方统计卡除“记录数”外,默认按当前预览页汇总;需要全量口径请使用“导出 CSV/Excel(全量)”。",
|
||||||
|
"scope": {
|
||||||
|
"currentPage": "当前页"
|
||||||
|
},
|
||||||
"columns": {
|
"columns": {
|
||||||
"primary": "",
|
"primary": "主字段",
|
||||||
"secondary": "",
|
"secondary": "辅助字段",
|
||||||
"metricA": "",
|
"metricA": "指标 A",
|
||||||
"metricB": "",
|
"metricB": "指标 B",
|
||||||
"metricC": "",
|
"metricC": "指标 C",
|
||||||
"status": "",
|
"status": "状态",
|
||||||
"extra": "",
|
"extra": "补充信息",
|
||||||
"time": ""
|
"time": "时间",
|
||||||
|
"drawProfit": {
|
||||||
|
"primary": "期号 / 批次",
|
||||||
|
"secondary": "期状态 / 结算状态",
|
||||||
|
"metricA": "订单 / 票数",
|
||||||
|
"metricB": "票数 / 中奖数",
|
||||||
|
"metricC": "下注 / 平台盈亏",
|
||||||
|
"status": "派彩 / Jackpot",
|
||||||
|
"extra": "结算批次数",
|
||||||
|
"time": "完成时间"
|
||||||
|
},
|
||||||
|
"dailyProfit": {
|
||||||
|
"primary": "业务日",
|
||||||
|
"secondary": "说明",
|
||||||
|
"metricA": "下注",
|
||||||
|
"metricB": "派彩",
|
||||||
|
"metricC": "平台盈亏",
|
||||||
|
"status": "退款",
|
||||||
|
"extra": "净额",
|
||||||
|
"time": "更新时间"
|
||||||
|
},
|
||||||
|
"playerWinLoss": {
|
||||||
|
"primary": "玩家",
|
||||||
|
"secondary": "玩家 ID",
|
||||||
|
"metricA": "下注",
|
||||||
|
"metricB": "派彩",
|
||||||
|
"metricC": "净输赢",
|
||||||
|
"status": "层级",
|
||||||
|
"extra": "备注",
|
||||||
|
"time": "时间"
|
||||||
|
},
|
||||||
|
"playerTransfer": {
|
||||||
|
"primary": "转账单号",
|
||||||
|
"secondary": "玩家",
|
||||||
|
"metricA": "方向",
|
||||||
|
"metricB": "状态",
|
||||||
|
"metricC": "金额",
|
||||||
|
"status": "外部流水",
|
||||||
|
"extra": "失败原因",
|
||||||
|
"time": "创建时间"
|
||||||
|
},
|
||||||
|
"hotNumberRisk": {
|
||||||
|
"primary": "号码 / 日志",
|
||||||
|
"secondary": "期号 / 动作",
|
||||||
|
"metricA": "封顶 / 金额",
|
||||||
|
"metricB": "已占用 / 玩法",
|
||||||
|
"metricC": "剩余 / 注单",
|
||||||
|
"status": "售罄 / 玩家",
|
||||||
|
"extra": "使用率 / 原因",
|
||||||
|
"time": "版本 / 时间"
|
||||||
|
},
|
||||||
|
"playDimension": {
|
||||||
|
"primary": "玩法",
|
||||||
|
"secondary": "维度",
|
||||||
|
"metricA": "下注",
|
||||||
|
"metricB": "派彩",
|
||||||
|
"metricC": "平台盈亏",
|
||||||
|
"status": "占比",
|
||||||
|
"extra": "备注",
|
||||||
|
"time": "时间"
|
||||||
|
},
|
||||||
|
"soldOut": {
|
||||||
|
"primary": "号码",
|
||||||
|
"secondary": "期号",
|
||||||
|
"metricA": "封顶",
|
||||||
|
"metricB": "已占用",
|
||||||
|
"metricC": "剩余",
|
||||||
|
"status": "是否售罄",
|
||||||
|
"extra": "使用率",
|
||||||
|
"time": "版本"
|
||||||
|
},
|
||||||
|
"rebateCommission": {
|
||||||
|
"primary": "玩法",
|
||||||
|
"secondary": "订单数",
|
||||||
|
"metricA": "回水",
|
||||||
|
"metricB": "注单数",
|
||||||
|
"metricC": "佣金",
|
||||||
|
"status": "配置命中",
|
||||||
|
"extra": "备注",
|
||||||
|
"time": "时间"
|
||||||
|
},
|
||||||
|
"adminAudit": {
|
||||||
|
"primary": "日志 ID",
|
||||||
|
"secondary": "操作者类型",
|
||||||
|
"metricA": "操作者 ID",
|
||||||
|
"metricB": "模块",
|
||||||
|
"metricC": "动作",
|
||||||
|
"status": "目标类型",
|
||||||
|
"extra": "IP",
|
||||||
|
"time": "时间"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"records": "记录数",
|
"records": "记录数",
|
||||||
@@ -179,7 +273,7 @@
|
|||||||
},
|
},
|
||||||
"daily_profit": {
|
"daily_profit": {
|
||||||
"title": "每日盈亏汇总",
|
"title": "每日盈亏汇总",
|
||||||
"summary": "按自然日汇总投注、派奖、退款、盈亏和净额。"
|
"summary": "按业务日汇总投注、派彩与平台盈亏,当前不包含退款与单独净额字段。"
|
||||||
},
|
},
|
||||||
"player_win_loss": {
|
"player_win_loss": {
|
||||||
"title": "玩家输赢报表",
|
"title": "玩家输赢报表",
|
||||||
|
|||||||
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",
|
audit: "audit",
|
||||||
settings: "settings",
|
settings: "settings",
|
||||||
integration: "integration",
|
integration: "integration",
|
||||||
|
agents: "agents",
|
||||||
config: "config",
|
config: "config",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export function getAdminPlayTypesLoadPromise(
|
|||||||
return inflightLoad;
|
return inflightLoad;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确保玩法目录已加载并返回缓存列表(全局去重,配置页勿直接 getAdminPlayTypes) */
|
||||||
|
export async function ensureAdminPlayTypesLoaded(
|
||||||
|
loader: () => Promise<{ items: AdminPlayTypeRow[] }>,
|
||||||
|
): Promise<AdminPlayTypeRow[]> {
|
||||||
|
await getAdminPlayTypesLoadPromise(loader);
|
||||||
|
return getCachedAdminPlayTypes();
|
||||||
|
}
|
||||||
|
|
||||||
/** 解析玩法显示名;无配置时回退 play_code */
|
/** 解析玩法显示名;无配置时回退 play_code */
|
||||||
export function resolveAdminPlayTypeDisplayName(
|
export function resolveAdminPlayTypeDisplayName(
|
||||||
playCode: string | null | undefined,
|
playCode: string | null | undefined,
|
||||||
|
|||||||
@@ -131,3 +131,20 @@ export const PRD_PAYOUT_ACCESS_ANY = [
|
|||||||
|
|
||||||
/** 接入站点配置页 */
|
/** 接入站点配置页 */
|
||||||
export const PRD_INTEGRATION_ACCESS_ANY = [PRD_INTEGRATION_VIEW, PRD_INTEGRATION_MANAGE] as const;
|
export const PRD_INTEGRATION_ACCESS_ANY = [PRD_INTEGRATION_VIEW, PRD_INTEGRATION_MANAGE] as const;
|
||||||
|
|
||||||
|
/** 代理管理 */
|
||||||
|
export const PRD_AGENT_VIEW = "prd.agent.view" as const;
|
||||||
|
export const PRD_AGENT_MANAGE = "prd.agent.manage" as const;
|
||||||
|
export const PRD_AGENT_ROLE_VIEW = "prd.agent.role.view" as const;
|
||||||
|
export const PRD_AGENT_ROLE_MANAGE = "prd.agent.role.manage" as const;
|
||||||
|
export const PRD_AGENT_USER_VIEW = "prd.agent.user.view" as const;
|
||||||
|
export const PRD_AGENT_USER_MANAGE = "prd.agent.user.manage" as const;
|
||||||
|
|
||||||
|
export const PRD_AGENTS_ACCESS_ANY = [
|
||||||
|
PRD_AGENT_VIEW,
|
||||||
|
PRD_AGENT_MANAGE,
|
||||||
|
PRD_AGENT_ROLE_VIEW,
|
||||||
|
PRD_AGENT_ROLE_MANAGE,
|
||||||
|
PRD_AGENT_USER_VIEW,
|
||||||
|
PRD_AGENT_USER_MANAGE,
|
||||||
|
] as const;
|
||||||
|
|||||||
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;
|
const DEFAULT_DECIMAL_PLACES = 2;
|
||||||
|
|
||||||
|
/** 接口缺字段或非数字时按 0 处理,避免仪表盘出现 NPRNaN */
|
||||||
|
export function coerceAdminMinor(value: unknown): number {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAdminCurrencyDecimalPlaces(currencyCode: string | null | undefined): number {
|
export function getAdminCurrencyDecimalPlaces(currencyCode: string | null | undefined): number {
|
||||||
const code = currencyCode?.trim().toUpperCase();
|
const code = currencyCode?.trim().toUpperCase();
|
||||||
if (!code) {
|
if (!code) {
|
||||||
@@ -23,11 +32,12 @@ export function formatAdminMinorUnits(
|
|||||||
currencyCode = "NPR",
|
currencyCode = "NPR",
|
||||||
decimalPlaces?: number,
|
decimalPlaces?: number,
|
||||||
): string {
|
): string {
|
||||||
|
const safeMinor = coerceAdminMinor(minor);
|
||||||
const resolvedDecimalPlaces =
|
const resolvedDecimalPlaces =
|
||||||
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
|
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
|
||||||
? decimalPlaces
|
? decimalPlaces
|
||||||
: getAdminCurrencyDecimalPlaces(currencyCode);
|
: getAdminCurrencyDecimalPlaces(currencyCode);
|
||||||
const major = minor / 10 ** resolvedDecimalPlaces;
|
const major = safeMinor / 10 ** resolvedDecimalPlaces;
|
||||||
return `${currencyCode} ${major.toLocaleString(undefined, {
|
return `${currencyCode} ${major.toLocaleString(undefined, {
|
||||||
minimumFractionDigits: resolvedDecimalPlaces,
|
minimumFractionDigits: resolvedDecimalPlaces,
|
||||||
maximumFractionDigits: resolvedDecimalPlaces,
|
maximumFractionDigits: resolvedDecimalPlaces,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import enSettlement from "@/i18n/locales/en/settlement.json";
|
|||||||
import enCommon from "@/i18n/locales/en/common.json";
|
import enCommon from "@/i18n/locales/en/common.json";
|
||||||
import enTickets from "@/i18n/locales/en/tickets.json";
|
import enTickets from "@/i18n/locales/en/tickets.json";
|
||||||
import enWallet from "@/i18n/locales/en/wallet.json";
|
import enWallet from "@/i18n/locales/en/wallet.json";
|
||||||
|
import enAgents from "@/i18n/locales/en/agents.json";
|
||||||
|
|
||||||
const EN_FLAT: Record<string, Record<string, unknown>> = {
|
const EN_FLAT: Record<string, Record<string, unknown>> = {
|
||||||
dashboard: enDashboard,
|
dashboard: enDashboard,
|
||||||
@@ -33,6 +34,7 @@ const EN_FLAT: Record<string, Record<string, unknown>> = {
|
|||||||
config: enConfig,
|
config: enConfig,
|
||||||
common: enCommon,
|
common: enCommon,
|
||||||
auth: enAuth,
|
auth: enAuth,
|
||||||
|
agents: enAgents,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getByPath(obj: Record<string, unknown>, path: string): string | undefined {
|
function getByPath(obj: Record<string, unknown>, path: string): string | undefined {
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
Globe,
|
||||||
Landmark,
|
Landmark,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogIn,
|
LogIn,
|
||||||
|
Network,
|
||||||
Scale,
|
Scale,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -23,6 +25,7 @@ import type { AdminNavItem } from "@/modules/_config/admin-nav";
|
|||||||
export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon> =
|
export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon> =
|
||||||
{
|
{
|
||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
|
agents: Network,
|
||||||
players: Users,
|
players: Users,
|
||||||
draws: CalendarClock,
|
draws: CalendarClock,
|
||||||
rules_plays: SlidersHorizontal,
|
rules_plays: SlidersHorizontal,
|
||||||
@@ -39,6 +42,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
|||||||
admin_users: ShieldCheck,
|
admin_users: ShieldCheck,
|
||||||
admin_roles: ShieldCheck,
|
admin_roles: ShieldCheck,
|
||||||
currencies: CircleDollarSign,
|
currencies: CircleDollarSign,
|
||||||
|
integration: Globe,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
export const ADMIN_BASE = "/admin" as const;
|
export const ADMIN_BASE = "/admin" as const;
|
||||||
|
|
||||||
|
export type AdminNavGroup =
|
||||||
|
| "overview"
|
||||||
|
| "agent"
|
||||||
|
| "operations"
|
||||||
|
| "finance"
|
||||||
|
| "rules"
|
||||||
|
| "platform";
|
||||||
|
|
||||||
export type AdminNavSegment =
|
export type AdminNavSegment =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
|
| "agents"
|
||||||
| "players"
|
| "players"
|
||||||
| "draws"
|
| "draws"
|
||||||
| "rules_plays"
|
| "rules_plays"
|
||||||
@@ -18,12 +27,15 @@ export type AdminNavSegment =
|
|||||||
| "audit"
|
| "audit"
|
||||||
| "admin_users"
|
| "admin_users"
|
||||||
| "admin_roles"
|
| "admin_roles"
|
||||||
| "currencies";
|
| "currencies"
|
||||||
|
| "integration";
|
||||||
|
|
||||||
export type AdminNavItem = {
|
export type AdminNavItem = {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
segment: AdminNavSegment;
|
segment: AdminNavSegment;
|
||||||
|
nav_group?: AdminNavGroup;
|
||||||
|
platform_only?: boolean;
|
||||||
activeMatchPrefix?: string;
|
activeMatchPrefix?: string;
|
||||||
requiredAny?: readonly string[];
|
requiredAny?: readonly string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
|
import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -59,6 +62,7 @@ function permissionLabel(slug: string, fallback: string, t: (key: string) => str
|
|||||||
|
|
||||||
export function AdminRolesConsole(): React.ReactElement {
|
export function AdminRolesConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["adminUsers", "common"]);
|
const { t } = useTranslation(["adminUsers", "common"]);
|
||||||
|
const tRef = useTranslationRef(["adminUsers", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
|
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
|
||||||
@@ -118,19 +122,17 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
setCatalog(catalogData);
|
setCatalog(catalogData);
|
||||||
setRoles(roleData.items);
|
setRoles(roleData.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("roleLoadFailed");
|
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("roleLoadFailed");
|
||||||
setErr(msg);
|
setErr(msg);
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, []);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
function isDirectGroupOpen(key: string): boolean {
|
function isDirectGroupOpen(key: string): boolean {
|
||||||
return directMenuExpanded[key] === true;
|
return directMenuExpanded[key] === true;
|
||||||
@@ -329,9 +331,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
{loading && roles.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table id="admin-roles-table">
|
<Table id="admin-roles-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -347,7 +346,9 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{roles.length === 0 ? (
|
{loading && roles.length === 0 ? (
|
||||||
|
<AdminTableLoadingRow colSpan={8} />
|
||||||
|
) : roles.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-muted-foreground">
|
<TableCell colSpan={8} className="text-muted-foreground">
|
||||||
{t("states.noData", { ns: "common" })}
|
{t("states.noData", { ns: "common" })}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +36,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -51,6 +54,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
|||||||
|
|
||||||
export function AdminUsersConsole(): React.ReactElement {
|
export function AdminUsersConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["adminUsers", "common"]);
|
const { t } = useTranslation(["adminUsers", "common"]);
|
||||||
|
const tRef = useTranslationRef(["adminUsers", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const exportLabels = useExportLabels("adminUsers");
|
const exportLabels = useExportLabels("adminUsers");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
@@ -112,7 +116,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
setTotal(listData.meta.total);
|
setTotal(listData.meta.total);
|
||||||
setLastPage(Math.max(1, listData.meta.last_page));
|
setLastPage(Math.max(1, listData.meta.last_page));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||||
setErr(msg);
|
setErr(msg);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
@@ -120,13 +124,11 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, query, t]);
|
}, [page, perPage, query]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [page, perPage, query]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
||||||
setFormCreateRoles((prev) => {
|
setFormCreateRoles((prev) => {
|
||||||
@@ -360,9 +362,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<CardContent className="admin-list-content">
|
||||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
{loading && items.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
<Table id="admin-users-table">
|
<Table id="admin-users-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -377,7 +376,9 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
|
<AdminTableLoadingRow colSpan={7} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-muted-foreground">
|
<TableCell colSpan={7} className="text-muted-foreground">
|
||||||
{t("states.noData", { ns: "common" })}
|
{t("states.noData", { ns: "common" })}
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -26,6 +29,7 @@ import type { AdminAuditLogListData } from "@/types/api/admin-audit";
|
|||||||
|
|
||||||
export function AuditLogsConsole(): React.ReactElement {
|
export function AuditLogsConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["audit", "common"]);
|
const { t } = useTranslation(["audit", "common"]);
|
||||||
|
const tRef = useTranslationRef(["audit", "common"]);
|
||||||
const exportLabels = useExportLabels("auditLogs");
|
const exportLabels = useExportLabels("auditLogs");
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
const [data, setData] = useState<AdminAuditLogListData | null>(null);
|
const [data, setData] = useState<AdminAuditLogListData | null>(null);
|
||||||
@@ -69,18 +73,16 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate, t]);
|
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const meta = data?.meta;
|
const meta = data?.meta;
|
||||||
|
|
||||||
@@ -200,11 +202,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
{loading && !data ? (
|
{(loading && !data) || data ? (
|
||||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
<Table id="audit-logs-table">
|
<Table id="audit-logs-table">
|
||||||
@@ -219,7 +217,9 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{loading && !data ? (
|
||||||
|
<AdminTableLoadingRow colSpan={6} />
|
||||||
|
) : !data || data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-muted-foreground">
|
<TableCell colSpan={6} className="text-muted-foreground">
|
||||||
{t("empty")}
|
{t("empty")}
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
import { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
||||||
@@ -106,6 +109,7 @@ export function OddsConfigDocScreen({
|
|||||||
onVersionIdChange,
|
onVersionIdChange,
|
||||||
}: OddsConfigDocScreenProps) {
|
}: OddsConfigDocScreenProps) {
|
||||||
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
|
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
|
const tRef = useTranslationRef(["config", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
@@ -144,15 +148,16 @@ export function OddsConfigDocScreen({
|
|||||||
const refreshTypes = useCallback(async () => {
|
const refreshTypes = useCallback(async () => {
|
||||||
setLoadingTypes(true);
|
setLoadingTypes(true);
|
||||||
try {
|
try {
|
||||||
const d = await getAdminPlayTypes();
|
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||||
setTypes(d.items);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setTypes([]);
|
setTypes([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingTypes(false);
|
setLoadingTypes(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
const refreshList = useCallback(async () => {
|
||||||
setLoadingList(true);
|
setLoadingList(true);
|
||||||
@@ -161,23 +166,21 @@ export function OddsConfigDocScreen({
|
|||||||
const d = await getAllConfigVersions(getOddsVersions);
|
const d = await getAllConfigVersions(getOddsVersions);
|
||||||
setList(d.items);
|
setList(d.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
const msg =
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setList([]);
|
setList([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingList(false);
|
setLoadingList(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queueMicrotask(() => {
|
void Promise.all([refreshTypes(), refreshList()]);
|
||||||
void refreshTypes();
|
}, [workspace]);
|
||||||
void refreshList();
|
|
||||||
});
|
|
||||||
}, [refreshTypes, refreshList, workspace]);
|
|
||||||
|
|
||||||
const loadDetail = useCallback(async (id: number) => {
|
const loadDetail = useCallback(async (id: number) => {
|
||||||
setLoadingDetail(true);
|
setLoadingDetail(true);
|
||||||
@@ -186,13 +189,15 @@ export function OddsConfigDocScreen({
|
|||||||
setDetail(d);
|
setDetail(d);
|
||||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setDraftRows([]);
|
setDraftRows([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDetail(false);
|
setLoadingDetail(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
@@ -638,9 +643,11 @@ export function OddsConfigDocScreen({
|
|||||||
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
|
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
|
||||||
|
|
||||||
{resolvedLoadingDetail || resolvedLoadingTypes ? (
|
{resolvedLoadingDetail || resolvedLoadingTypes ? (
|
||||||
<p className={cn("text-center text-sm text-muted-foreground", mergedLayout ? "py-6" : "py-8")}>
|
<AdminLoadingState
|
||||||
{t("odds.loadingDetails", { ns: "config" })}
|
className={cn(mergedLayout ? "py-6" : "py-8")}
|
||||||
</p>
|
minHeight="6rem"
|
||||||
|
label={t("odds.loadingDetails", { ns: "config" })}
|
||||||
|
/>
|
||||||
) : resolvedPlayCode ? (
|
) : resolvedPlayCode ? (
|
||||||
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
|
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
||||||
|
|||||||
@@ -41,11 +41,14 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||||
import { PRD_PLAY_SWITCH_MANAGE } from "@/lib/admin-prd";
|
import { PRD_PLAY_SWITCH_MANAGE } from "@/lib/admin-prd";
|
||||||
@@ -138,6 +141,7 @@ function buildPlayConfigSavePayload(
|
|||||||
|
|
||||||
export function PlayConfigDocScreen() {
|
export function PlayConfigDocScreen() {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
|
const tRef = useTranslationRef(["config", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
||||||
@@ -165,19 +169,18 @@ export function PlayConfigDocScreen() {
|
|||||||
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
|
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
const msg =
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setList([]);
|
setList([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingList(false);
|
setLoadingList(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void refreshList();
|
||||||
void refreshList();
|
}, []);
|
||||||
});
|
|
||||||
}, [refreshList]);
|
|
||||||
|
|
||||||
const loadDetail = useCallback(async (id: number) => {
|
const loadDetail = useCallback(async (id: number) => {
|
||||||
const requestSeq = detailRequestSeq.current + 1;
|
const requestSeq = detailRequestSeq.current + 1;
|
||||||
@@ -196,7 +199,9 @@ export function PlayConfigDocScreen() {
|
|||||||
if (detailRequestSeq.current !== requestSeq) {
|
if (detailRequestSeq.current !== requestSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setDraftRows([]);
|
setDraftRows([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -204,7 +209,7 @@ export function PlayConfigDocScreen() {
|
|||||||
setLoadingDetail(false);
|
setLoadingDetail(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
@@ -538,7 +543,7 @@ export function PlayConfigDocScreen() {
|
|||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
{loadingDetail ? (
|
{loadingDetail ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ import {
|
|||||||
ConfigVersionToolbarMeta,
|
ConfigVersionToolbarMeta,
|
||||||
ConfigVersionToolbarMetaEmphasis,
|
ConfigVersionToolbarMetaEmphasis,
|
||||||
} from "@/modules/config/config-version-toolbar-meta";
|
} from "@/modules/config/config-version-toolbar-meta";
|
||||||
import { getAdminSettings, updateAdminSetting } from "@/api/admin-settings";
|
import { updateAdminSetting } from "@/api/admin-settings";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
import {
|
||||||
|
loadApplyRebateToPayoutSetting,
|
||||||
|
setCachedApplyRebateToPayoutSetting,
|
||||||
|
} from "@/lib/admin-settlement-settings-cache";
|
||||||
|
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -33,6 +40,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
@@ -58,7 +66,6 @@ import {
|
|||||||
} from "@/modules/config/doc/odds-rebate-rates";
|
} from "@/modules/config/doc/odds-rebate-rates";
|
||||||
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
||||||
|
|
||||||
const SETTLEMENT_GROUP = "settlement";
|
|
||||||
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
|
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
|
||||||
|
|
||||||
function dimensionDistinctPrimaryScopePercents(
|
function dimensionDistinctPrimaryScopePercents(
|
||||||
@@ -98,6 +105,7 @@ export function RebateConfigDocScreen({
|
|||||||
onVersionIdChange,
|
onVersionIdChange,
|
||||||
}: RebateConfigDocScreenProps) {
|
}: RebateConfigDocScreenProps) {
|
||||||
const { t } = useTranslation(["config", "common"]);
|
const { t } = useTranslation(["config", "common"]);
|
||||||
|
const tRef = useTranslationRef(["config", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
|
||||||
@@ -137,54 +145,52 @@ export function RebateConfigDocScreen({
|
|||||||
|
|
||||||
const refreshTypes = useCallback(async () => {
|
const refreshTypes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await getAdminPlayTypes();
|
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||||
setTypes(d.items);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setTypes([]);
|
setTypes([]);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
const refreshList = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await getAllConfigVersions(getOddsVersions);
|
const d = await getAllConfigVersions(getOddsVersions);
|
||||||
setListRows(d.items);
|
setListRows(d.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setListRows([]);
|
setListRows([]);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
const loadWinEnjoySetting = useCallback(async () => {
|
useAsyncEffect(() => {
|
||||||
setWinEnjoyLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await getAdminSettings(SETTLEMENT_GROUP);
|
|
||||||
const hit = res.items.find((item) => item.key === APPLY_REBATE_TO_PAYOUT_KEY);
|
|
||||||
setApplyRebateToPayout(Boolean(hit?.value));
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
|
||||||
} finally {
|
|
||||||
setWinEnjoyLoading(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queueMicrotask(async () => {
|
void (async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await refreshTypes();
|
await Promise.all([refreshTypes(), refreshList()]);
|
||||||
await refreshList();
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
})();
|
||||||
}, [refreshTypes, refreshList, workspace]);
|
}, [workspace]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void (async () => {
|
||||||
void loadWinEnjoySetting();
|
setWinEnjoyLoading(true);
|
||||||
});
|
try {
|
||||||
}, [loadWinEnjoySetting]);
|
setApplyRebateToPayout(await loadApplyRebateToPayoutSetting());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setWinEnjoyLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -202,6 +208,7 @@ export function RebateConfigDocScreen({
|
|||||||
setWinEnjoySaving(true);
|
setWinEnjoySaving(true);
|
||||||
try {
|
try {
|
||||||
await updateAdminSetting(APPLY_REBATE_TO_PAYOUT_KEY, checked);
|
await updateAdminSetting(APPLY_REBATE_TO_PAYOUT_KEY, checked);
|
||||||
|
setCachedApplyRebateToPayoutSetting(checked);
|
||||||
setApplyRebateToPayout(checked);
|
setApplyRebateToPayout(checked);
|
||||||
toast.success(t("rebate.winEnjoy.saveSuccess", { ns: "config" }));
|
toast.success(t("rebate.winEnjoy.saveSuccess", { ns: "config" }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -214,8 +221,7 @@ export function RebateConfigDocScreen({
|
|||||||
const loadDetail = useCallback(async (id: number) => {
|
const loadDetail = useCallback(async (id: number) => {
|
||||||
setLoadingDetail(true);
|
setLoadingDetail(true);
|
||||||
try {
|
try {
|
||||||
const pt = await getAdminPlayTypes();
|
const typeList = await ensureAdminPlayTypesLoaded(getAdminPlayTypes);
|
||||||
const typeList = pt.items;
|
|
||||||
setTypes(typeList);
|
setTypes(typeList);
|
||||||
const d = await getOddsVersion(id);
|
const d = await getOddsVersion(id);
|
||||||
const rows = d.items.map((it) => ({ ...it }));
|
const rows = d.items.map((it) => ({ ...it }));
|
||||||
@@ -225,13 +231,15 @@ export function RebateConfigDocScreen({
|
|||||||
setP3(inferRebatePercentFromDimension(3, rows, typeList));
|
setP3(inferRebatePercentFromDimension(3, rows, typeList));
|
||||||
setP4(inferRebatePercentFromDimension(4, rows, typeList));
|
setP4(inferRebatePercentFromDimension(4, rows, typeList));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setDraftRows([]);
|
setDraftRows([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDetail(false);
|
setLoadingDetail(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
@@ -614,7 +622,7 @@ export function RebateConfigDocScreen({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{resolvedLoading || resolvedLoadingDetail ? (
|
{resolvedLoading || resolvedLoadingDetail ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +46,9 @@ import {
|
|||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||||
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
|
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
|
||||||
@@ -86,6 +89,7 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
|
|||||||
|
|
||||||
export function RiskCapDocScreen() {
|
export function RiskCapDocScreen() {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
|
const tRef = useTranslationRef(["config", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_RISK_CAP_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_RISK_CAP_MANAGE]);
|
||||||
@@ -113,19 +117,18 @@ export function RiskCapDocScreen() {
|
|||||||
const d = await getAllConfigVersions(getRiskCapVersions);
|
const d = await getAllConfigVersions(getRiskCapVersions);
|
||||||
setList(d.items);
|
setList(d.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
const msg =
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setList([]);
|
setList([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingList(false);
|
setLoadingList(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void refreshList();
|
||||||
void refreshList();
|
}, []);
|
||||||
});
|
|
||||||
}, [refreshList]);
|
|
||||||
|
|
||||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
||||||
const defaultRow = rows.find(isDefaultRiskRow);
|
const defaultRow = rows.find(isDefaultRiskRow);
|
||||||
@@ -151,14 +154,16 @@ export function RiskCapDocScreen() {
|
|||||||
setDraftRows(mapped);
|
setDraftRows(mapped);
|
||||||
syncDefaultCapFromRows(mapped);
|
syncDefaultCapFromRows(mapped);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setDraftRows([]);
|
setDraftRows([]);
|
||||||
syncDefaultCapFromRows([]);
|
syncDefaultCapFromRows([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDetail(false);
|
setLoadingDetail(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
@@ -498,7 +503,7 @@ export function RiskCapDocScreen() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loadingDetail ? (
|
{loadingDetail ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("riskCap.loadingDetails", { ns: "config" })}</p>
|
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
|
||||||
) : specialRows.length === 0 ? (
|
) : specialRows.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import { getAdminSettings, updateAdminSettingsBatch } from "@/api/admin-settings";
|
||||||
getAdminSettings,
|
import { useOptionalAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
|
||||||
updateAdminSetting,
|
import { WALLET_GROUP, WALLET_KEYS } from "@/modules/settings/settings-keys";
|
||||||
} from "@/api/admin-settings";
|
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||||
@@ -15,15 +14,6 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
const WALLET_GROUP = "wallet";
|
|
||||||
|
|
||||||
const KEYS = {
|
|
||||||
IN_MIN: "wallet.transfer_in_min_minor",
|
|
||||||
IN_MAX: "wallet.transfer_in_max_minor",
|
|
||||||
OUT_MIN: "wallet.transfer_out_min_minor",
|
|
||||||
OUT_MAX: "wallet.transfer_out_max_minor",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function minorUnitsToDisplay(n: unknown, decimals = 2): string {
|
function minorUnitsToDisplay(n: unknown, decimals = 2): string {
|
||||||
const num = Number(n);
|
const num = Number(n);
|
||||||
if (!Number.isFinite(num)) return "";
|
if (!Number.isFinite(num)) return "";
|
||||||
@@ -43,12 +33,24 @@ interface Draft {
|
|||||||
outMax: string;
|
outMax: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function draftFromKv(kv: Record<string, unknown>): Draft {
|
||||||
|
return {
|
||||||
|
inMin: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MIN] ?? 100),
|
||||||
|
inMax: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MAX] ?? 0),
|
||||||
|
outMin: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MIN] ?? 100),
|
||||||
|
outMax: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MAX] ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type WalletConfigDocScreenProps = {
|
type WalletConfigDocScreenProps = {
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
|
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
|
const tRef = useRef(t);
|
||||||
|
tRef.current = t;
|
||||||
|
const shared = useOptionalAdminSettingsData();
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const [draft, setDraft] = useState<Draft>({
|
const [draft, setDraft] = useState<Draft>({
|
||||||
inMin: "",
|
inMin: "",
|
||||||
@@ -57,55 +59,81 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
|
|||||||
outMax: "",
|
outMax: "",
|
||||||
});
|
});
|
||||||
const [saved, setSaved] = useState<Draft>({ inMin: "", inMax: "", outMin: "", outMax: "" });
|
const [saved, setSaved] = useState<Draft>({ inMin: "", inMax: "", outMin: "", outMax: "" });
|
||||||
const [loading, setLoading] = useState(true);
|
const [standaloneLoading, setStandaloneLoading] = useState(!embedded);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const dirty =
|
||||||
|
draft.inMin !== saved.inMin ||
|
||||||
|
draft.inMax !== saved.inMax ||
|
||||||
|
draft.outMin !== saved.outMin ||
|
||||||
|
draft.outMax !== saved.outMax;
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const loading = embedded ? (shared?.loading ?? true) : standaloneLoading;
|
||||||
setLoading(true);
|
|
||||||
|
const loadStandalone = useCallback(async () => {
|
||||||
|
setStandaloneLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getAdminSettings(WALLET_GROUP);
|
const res = await getAdminSettings(WALLET_GROUP);
|
||||||
const kv: Record<string, unknown> = {};
|
const kv: Record<string, unknown> = {};
|
||||||
for (const item of res.items) {
|
for (const item of res.items) {
|
||||||
kv[item.key] = item.value;
|
kv[item.key] = item.value;
|
||||||
}
|
}
|
||||||
const d: Draft = {
|
const d = draftFromKv(kv);
|
||||||
inMin: minorUnitsToDisplay(kv[KEYS.IN_MIN] ?? 100),
|
|
||||||
inMax: minorUnitsToDisplay(kv[KEYS.IN_MAX] ?? 0),
|
|
||||||
outMin: minorUnitsToDisplay(kv[KEYS.OUT_MIN] ?? 100),
|
|
||||||
outMax: minorUnitsToDisplay(kv[KEYS.OUT_MAX] ?? 0),
|
|
||||||
};
|
|
||||||
setDraft(d);
|
setDraft(d);
|
||||||
setSaved(d);
|
setSaved(d);
|
||||||
setDirty(false);
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("wallet.loadFailed", { ns: "config" }));
|
toast.error(tRef.current("wallet.loadFailed", { ns: "config" }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setStandaloneLoading(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
if (!embedded) {
|
||||||
void load();
|
void loadStandalone();
|
||||||
});
|
}
|
||||||
}, [load]);
|
}, [embedded, loadStandalone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!embedded || shared?.kv === null || shared?.kv === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = draftFromKv(shared.kv);
|
||||||
|
setDraft(d);
|
||||||
|
setSaved(d);
|
||||||
|
}, [embedded, shared?.kv]);
|
||||||
|
|
||||||
const handleChange = (field: keyof Draft, value: string) => {
|
const handleChange = (field: keyof Draft, value: string) => {
|
||||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||||
setDirty(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
const items = [];
|
||||||
|
if (draft.inMin !== saved.inMin) {
|
||||||
|
items.push({ key: WALLET_KEYS.IN_MIN, value: displayToMinorUnits(draft.inMin) });
|
||||||
|
}
|
||||||
|
if (draft.inMax !== saved.inMax) {
|
||||||
|
items.push({ key: WALLET_KEYS.IN_MAX, value: displayToMinorUnits(draft.inMax) });
|
||||||
|
}
|
||||||
|
if (draft.outMin !== saved.outMin) {
|
||||||
|
items.push({ key: WALLET_KEYS.OUT_MIN, value: displayToMinorUnits(draft.outMin) });
|
||||||
|
}
|
||||||
|
if (draft.outMax !== saved.outMax) {
|
||||||
|
items.push({ key: WALLET_KEYS.OUT_MAX, value: displayToMinorUnits(draft.outMax) });
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await updateAdminSetting(KEYS.IN_MIN, displayToMinorUnits(draft.inMin));
|
await updateAdminSettingsBatch(items);
|
||||||
await updateAdminSetting(KEYS.IN_MAX, displayToMinorUnits(draft.inMax));
|
const updates: Record<string, unknown> = {};
|
||||||
await updateAdminSetting(KEYS.OUT_MIN, displayToMinorUnits(draft.outMin));
|
for (const item of items) {
|
||||||
await updateAdminSetting(KEYS.OUT_MAX, displayToMinorUnits(draft.outMax));
|
updates[item.key] = item.value;
|
||||||
|
}
|
||||||
|
shared?.patchKv(updates);
|
||||||
toast.success(t("wallet.saveSuccess", { ns: "config" }));
|
toast.success(t("wallet.saveSuccess", { ns: "config" }));
|
||||||
setSaved(draft);
|
setSaved(draft);
|
||||||
setDirty(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof LotteryApiBizError ? error.message : t("wallet.saveFailed", { ns: "config" }),
|
error instanceof LotteryApiBizError ? error.message : t("wallet.saveFailed", { ns: "config" }),
|
||||||
@@ -186,13 +214,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
|
|||||||
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
||||||
</Button>
|
</Button>
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<Button
|
<Button variant="outline" onClick={() => setDraft(saved)} disabled={saving}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setDraft(saved);
|
|
||||||
setDirty(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("wallet.discard", { ns: "config" })}
|
{t("wallet.discard", { ns: "config" })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getAdminDraws } from "@/api/admin-draws";
|
import { getAdminDraws } from "@/api/admin-draws";
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { ConfigSection } from "@/modules/config/config-section";
|
import { ConfigSection } from "@/modules/config/config-section";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -37,6 +40,7 @@ type PoolFilter = "all" | "sold_out" | "high_risk";
|
|||||||
|
|
||||||
export function RiskCapRuntimePanel() {
|
export function RiskCapRuntimePanel() {
|
||||||
const { t } = useTranslation(["config", "risk", "draws", "common"]);
|
const { t } = useTranslation(["config", "risk", "draws", "common"]);
|
||||||
|
const tRef = useTranslationRef(["config", "common"]);
|
||||||
const [draws, setDraws] = useState<AdminDrawListItem[]>([]);
|
const [draws, setDraws] = useState<AdminDrawListItem[]>([]);
|
||||||
const [drawsLoading, setDrawsLoading] = useState(true);
|
const [drawsLoading, setDrawsLoading] = useState(true);
|
||||||
const [drawId, setDrawId] = useState<string>("");
|
const [drawId, setDrawId] = useState<string>("");
|
||||||
@@ -64,12 +68,14 @@ export function RiskCapRuntimePanel() {
|
|||||||
setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev));
|
setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setDraws([]);
|
setDraws([]);
|
||||||
} finally {
|
} finally {
|
||||||
setDrawsLoading(false);
|
setDrawsLoading(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
const loadPools = useCallback(async () => {
|
const loadPools = useCallback(async () => {
|
||||||
if (!drawId) {
|
if (!drawId) {
|
||||||
@@ -94,24 +100,22 @@ export function RiskCapRuntimePanel() {
|
|||||||
setPools(data.items);
|
setPools(data.items);
|
||||||
setCurrencyCode(data.currency_code);
|
setCurrencyCode(data.currency_code);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPoolsError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
setPoolsError(
|
||||||
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
|
);
|
||||||
setPools([]);
|
setPools([]);
|
||||||
} finally {
|
} finally {
|
||||||
setPoolsLoading(false);
|
setPoolsLoading(false);
|
||||||
}
|
}
|
||||||
}, [appliedNumber, drawId, poolFilter, t]);
|
}, [appliedNumber, drawId, poolFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void loadDraws();
|
||||||
void loadDraws();
|
}, []);
|
||||||
});
|
|
||||||
}, [loadDraws]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void loadPools();
|
||||||
void loadPools();
|
}, [appliedNumber, drawId, poolFilter]);
|
||||||
});
|
|
||||||
}, [loadPools]);
|
|
||||||
|
|
||||||
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;
|
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;
|
||||||
|
|
||||||
@@ -226,11 +230,7 @@ export function RiskCapRuntimePanel() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{poolsLoading ? (
|
{poolsLoading ? (
|
||||||
<TableRow>
|
<AdminTableLoadingRow colSpan={5} />
|
||||||
<TableCell colSpan={5} className="text-muted-foreground">
|
|
||||||
{t("states.loading", { ns: "common" })}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : pools.length === 0 ? (
|
) : pools.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-muted-foreground">
|
<TableCell colSpan={5} className="text-muted-foreground">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +9,8 @@ import {
|
|||||||
getOddsVersion,
|
getOddsVersion,
|
||||||
getOddsVersions,
|
getOddsVersions,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
|
||||||
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
@@ -42,7 +43,7 @@ export function useOddsConfigWorkspace(
|
|||||||
selectedId: string,
|
selectedId: string,
|
||||||
onSelectedIdChange: (id: string) => void,
|
onSelectedIdChange: (id: string) => void,
|
||||||
): OddsConfigWorkspace {
|
): OddsConfigWorkspace {
|
||||||
const { t } = useTranslation("common");
|
const tRef = useTranslationRef("common");
|
||||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||||
@@ -52,6 +53,7 @@ export function useOddsConfigWorkspace(
|
|||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const detailRequestSeq = useRef(0);
|
const detailRequestSeq = useRef(0);
|
||||||
|
const bootstrappedRef = useRef(false);
|
||||||
|
|
||||||
const applyDetail = useCallback((next: OddsVersionDetail) => {
|
const applyDetail = useCallback((next: OddsVersionDetail) => {
|
||||||
setDetail(next);
|
setDetail(next);
|
||||||
@@ -61,15 +63,14 @@ export function useOddsConfigWorkspace(
|
|||||||
const refreshTypes = useCallback(async () => {
|
const refreshTypes = useCallback(async () => {
|
||||||
setLoadingTypes(true);
|
setLoadingTypes(true);
|
||||||
try {
|
try {
|
||||||
const d = await getAdminPlayTypes();
|
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||||
setTypes(d.items);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed"));
|
||||||
setTypes([]);
|
setTypes([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingTypes(false);
|
setLoadingTypes(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
const refreshList = useCallback(async () => {
|
const refreshList = useCallback(async () => {
|
||||||
setLoadingList(true);
|
setLoadingList(true);
|
||||||
@@ -78,13 +79,13 @@ export function useOddsConfigWorkspace(
|
|||||||
const d = await getAllConfigVersions(getOddsVersions);
|
const d = await getAllConfigVersions(getOddsVersions);
|
||||||
setList(d.items);
|
setList(d.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed");
|
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setList([]);
|
setList([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingList(false);
|
setLoadingList(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
const loadDetail = useCallback(
|
const loadDetail = useCallback(
|
||||||
async (id: number) => {
|
async (id: number) => {
|
||||||
@@ -100,7 +101,7 @@ export function useOddsConfigWorkspace(
|
|||||||
if (seq !== detailRequestSeq.current) {
|
if (seq !== detailRequestSeq.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed"));
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setDraftRows([]);
|
setDraftRows([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -109,7 +110,7 @@ export function useOddsConfigWorkspace(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyDetail, t],
|
[applyDetail],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reloadDetail = useCallback(async () => {
|
const reloadDetail = useCallback(async () => {
|
||||||
@@ -124,8 +125,11 @@ export function useOddsConfigWorkspace(
|
|||||||
}, [loadDetail, selectedId]);
|
}, [loadDetail, selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshTypes();
|
if (bootstrappedRef.current) {
|
||||||
void refreshList();
|
return;
|
||||||
|
}
|
||||||
|
bootstrappedRef.current = true;
|
||||||
|
void Promise.all([refreshTypes(), refreshList()]);
|
||||||
}, [refreshTypes, refreshList]);
|
}, [refreshTypes, refreshList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||||
|
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||||
import {
|
import {
|
||||||
DailyTrendChart,
|
DailyTrendChart,
|
||||||
PlayBreakdownChart,
|
PlayBreakdownChart,
|
||||||
@@ -356,6 +357,112 @@ export function DashboardPlayRankingCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DashboardAgentRankingCard({
|
||||||
|
analytics,
|
||||||
|
}: {
|
||||||
|
analytics: DashboardAnalyticsState;
|
||||||
|
}): ReactNode {
|
||||||
|
const { t } = useTranslation(["dashboard", "common"]);
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
rankingMetric,
|
||||||
|
loading,
|
||||||
|
topAgentRows,
|
||||||
|
currency,
|
||||||
|
formatMoney,
|
||||||
|
formatSignedMoney,
|
||||||
|
} = analytics;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricValue = (row: (typeof topAgentRows)[number]): number => {
|
||||||
|
if (rankingMetric === "payout") {
|
||||||
|
return row.total_payout_minor;
|
||||||
|
}
|
||||||
|
if (rankingMetric === "profit") {
|
||||||
|
return row.approx_house_gross_minor;
|
||||||
|
}
|
||||||
|
return row.total_bet_minor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxAbs = Math.max(1, ...topAgentRows.map((r) => Math.abs(metricValue(r))));
|
||||||
|
|
||||||
|
const formatRowValue = (row: (typeof topAgentRows)[number]): string => {
|
||||||
|
const v = metricValue(row);
|
||||||
|
if (rankingMetric === "profit") {
|
||||||
|
return formatSignedMoney(v, currency);
|
||||||
|
}
|
||||||
|
return formatMoney(v, currency);
|
||||||
|
};
|
||||||
|
|
||||||
|
const barColor = (row: (typeof topAgentRows)[number]): string => {
|
||||||
|
if (rankingMetric === "bet") {
|
||||||
|
return DASHBOARD_CHART_COLORS.primary;
|
||||||
|
}
|
||||||
|
if (rankingMetric === "payout") {
|
||||||
|
return DASHBOARD_CHART_COLORS.rose;
|
||||||
|
}
|
||||||
|
return row.approx_house_gross_minor >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
|
||||||
|
<CardHeader className="space-y-2 border-b border-border/60 px-4 py-3">
|
||||||
|
<CardTitle className="text-sm font-semibold">{t("analytics.agentRanking")}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(`analytics.rankingMetrics.${rankingMetric}`)}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-[210px] w-full" />
|
||||||
|
) : topAgentRows.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{topAgentRows.map((row, idx) => {
|
||||||
|
const v = metricValue(row);
|
||||||
|
const pct = (Math.abs(v) / maxAbs) * 100;
|
||||||
|
const color = barColor(row);
|
||||||
|
return (
|
||||||
|
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
<span className="mt-0.5 w-5 shrink-0 text-center text-[11px] font-semibold text-muted-foreground">
|
||||||
|
#{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p>
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
|
||||||
|
{formatRowValue(row)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted/30">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(2, pct)}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
opacity: 0.35,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noAgentData")}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 单列堆叠布局(兼容旧用法) */
|
/** 单列堆叠布局(兼容旧用法) */
|
||||||
export function DashboardAnalyticsPanel({
|
export function DashboardAnalyticsPanel({
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -20,14 +20,12 @@ import {
|
|||||||
|
|
||||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||||
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||||
import { getAdminPlayTypes } from "@/api/admin-config";
|
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||||
import {
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
getAdminPlayTypesLoadPromise,
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
getCachedAdminPlayTypes,
|
|
||||||
resolveAdminPlayTypeDisplayName,
|
|
||||||
} from "@/lib/admin-play-types";
|
|
||||||
import {
|
import {
|
||||||
DashboardAnalyticsMain,
|
DashboardAnalyticsMain,
|
||||||
|
DashboardAgentRankingCard,
|
||||||
DashboardPlayRankingCard,
|
DashboardPlayRankingCard,
|
||||||
} from "@/modules/dashboard/dashboard-analytics-panel";
|
} from "@/modules/dashboard/dashboard-analytics-panel";
|
||||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||||
@@ -50,7 +48,11 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
|||||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||||
import { normalizeAdminLanguage } from "@/i18n";
|
import { normalizeAdminLanguage } from "@/i18n";
|
||||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
import {
|
||||||
|
coerceAdminMinor,
|
||||||
|
formatAdminMinorUnits,
|
||||||
|
getAdminCurrencyDecimalPlaces,
|
||||||
|
} from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
@@ -66,9 +68,10 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
|||||||
type HotPlayTab = "4D" | "3D" | "2D" | "special";
|
type HotPlayTab = "4D" | "3D" | "2D" | "special";
|
||||||
|
|
||||||
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||||
|
const safeMinor = coerceAdminMinor(minor);
|
||||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||||
const major = minor / 10 ** decimals;
|
const major = safeMinor / 10 ** decimals;
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -77,7 +80,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
|||||||
maximumFractionDigits: decimals,
|
maximumFractionDigits: decimals,
|
||||||
}).format(major);
|
}).format(major);
|
||||||
} catch {
|
} catch {
|
||||||
return formatAdminMinorUnits(minor, code, decimals);
|
return formatAdminMinorUnits(safeMinor, code, decimals);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,28 +165,8 @@ export function DashboardConsole(): ReactElement {
|
|||||||
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
||||||
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
||||||
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
||||||
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
|
const playOptions = useCachedPlayTypeOptions();
|
||||||
|
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||||
const loadPlayOptions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
|
|
||||||
setPlayOptions(
|
|
||||||
getCachedAdminPlayTypes().map((item) => ({
|
|
||||||
code: item.play_code,
|
|
||||||
label:
|
|
||||||
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setPlayOptions([]);
|
|
||||||
}
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
void loadPlayOptions();
|
|
||||||
});
|
|
||||||
}, [loadPlayOptions]);
|
|
||||||
|
|
||||||
const load = useCallback(async (isRefresh = false) => {
|
const load = useCallback(async (isRefresh = false) => {
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
@@ -230,27 +213,30 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
void load(false);
|
||||||
void load(false);
|
}, []);
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const currency =
|
const currency =
|
||||||
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
|
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
|
||||||
const canFinance = capabilities?.draw_finance_risk ?? false;
|
const canFinance = capabilities?.draw_finance_risk ?? false;
|
||||||
const platformLocked = platformRisk?.locked_amount ?? 0;
|
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
|
||||||
const platformCap = platformRisk?.cap_amount ?? 0;
|
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
|
||||||
const platformUsagePct = platformRisk?.usage_percent ?? 0;
|
const rawPlatformUsagePct = platformRisk?.usage_percent;
|
||||||
|
const platformUsagePct =
|
||||||
|
typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct)
|
||||||
|
? Math.min(100, Math.max(0, rawPlatformUsagePct))
|
||||||
|
: platformCap > 0
|
||||||
|
? (platformLocked / platformCap) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||||
|
|
||||||
@@ -359,10 +345,16 @@ export function DashboardConsole(): ReactElement {
|
|||||||
href="/admin/risk"
|
href="/admin/risk"
|
||||||
title={t("riskCapUsage")}
|
title={t("riskCapUsage")}
|
||||||
value={`${platformUsagePct.toFixed(1)}%`}
|
value={`${platformUsagePct.toFixed(1)}%`}
|
||||||
subtitle={t("platformLockedAndCap", {
|
subtitle={
|
||||||
locked: formatMoneyMinor(platformLocked, currency),
|
platformCap > 0
|
||||||
cap: formatMoneyMinor(platformCap, currency),
|
? t("platformLockedAndCap", {
|
||||||
})}
|
locked: formatMoneyMinor(platformLocked, currency),
|
||||||
|
cap: formatMoneyMinor(platformCap, currency),
|
||||||
|
})
|
||||||
|
: t("platformCapNotConfigured", {
|
||||||
|
locked: formatMoneyMinor(platformLocked, currency),
|
||||||
|
})
|
||||||
|
}
|
||||||
actionLabel={t("occupancyDetails")}
|
actionLabel={t("occupancyDetails")}
|
||||||
icon={<Shield className="size-5" aria-hidden />}
|
icon={<Shield className="size-5" aria-hidden />}
|
||||||
accent={
|
accent={
|
||||||
@@ -542,6 +534,7 @@ export function DashboardConsole(): ReactElement {
|
|||||||
{showAnalytics ? (
|
{showAnalytics ? (
|
||||||
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
||||||
<DashboardPlayRankingCard analytics={analytics} />
|
<DashboardPlayRankingCard analytics={analytics} />
|
||||||
|
<DashboardAgentRankingCard analytics={analytics} />
|
||||||
|
|
||||||
<Card className="admin-list-card min-w-0 py-0">
|
<Card className="admin-list-card min-w-0 py-0">
|
||||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from "@/components/ui/chart";
|
} from "@/components/ui/chart";
|
||||||
|
import {
|
||||||
|
coerceAdminMinor,
|
||||||
|
formatAdminMinorDecimal,
|
||||||
|
getAdminCurrencyDecimalPlaces,
|
||||||
|
} from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
buildBatchProgressConfig,
|
buildBatchProgressConfig,
|
||||||
@@ -53,6 +58,74 @@ export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
|
|||||||
|
|
||||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||||
|
|
||||||
|
type DashboardFinanceMetricCell = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
emphasize: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */
|
||||||
|
function formatDashboardMetricAmount(
|
||||||
|
minor: number,
|
||||||
|
currencyCode: string | null,
|
||||||
|
formatMoney: MoneyFormatter,
|
||||||
|
): { display: string; title: string } {
|
||||||
|
const safeMinor = coerceAdminMinor(minor);
|
||||||
|
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||||
|
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||||
|
return {
|
||||||
|
display: formatAdminMinorDecimal(safeMinor, code, decimals),
|
||||||
|
title: formatMoney(safeMinor, currencyCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardFinanceMetricCells({
|
||||||
|
cells,
|
||||||
|
currency,
|
||||||
|
formatMoney,
|
||||||
|
}: {
|
||||||
|
cells: readonly DashboardFinanceMetricCell[];
|
||||||
|
currency: string | null;
|
||||||
|
formatMoney: MoneyFormatter;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{cells.map((cell) => {
|
||||||
|
const { display, title } = formatDashboardMetricAmount(
|
||||||
|
cell.amount,
|
||||||
|
currency,
|
||||||
|
formatMoney,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cell.key}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 rounded-lg px-1 py-2 ring-1",
|
||||||
|
cell.emphasize
|
||||||
|
? "bg-primary/6 ring-primary/15"
|
||||||
|
: "bg-muted/30 ring-border/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="line-clamp-2 text-center text-[10px] leading-tight text-muted-foreground">
|
||||||
|
{cell.label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 truncate text-center text-[10px] font-bold tabular-nums leading-tight",
|
||||||
|
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function usageBarFill(pct: number): string {
|
function usageBarFill(pct: number): string {
|
||||||
if (pct >= 95) {
|
if (pct >= 95) {
|
||||||
return DASHBOARD_CHART_COLORS.rose;
|
return DASHBOARD_CHART_COLORS.rose;
|
||||||
@@ -485,10 +558,11 @@ export function PayoutPanelSnapshot({
|
|||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const currency = finance.currency_code;
|
const currency = finance.currency_code;
|
||||||
const bet = finance.total_bet_minor;
|
const bet = coerceAdminMinor(finance.total_bet_minor);
|
||||||
const win = finance.total_win_payout_minor;
|
const win = coerceAdminMinor(finance.total_win_payout_minor);
|
||||||
const jackpot = finance.total_jackpot_win_minor;
|
const jackpot = coerceAdminMinor(finance.total_jackpot_win_minor);
|
||||||
const hasPayout = win + jackpot > 0;
|
const payout = coerceAdminMinor(finance.total_payout_minor);
|
||||||
|
const hasPayout = payout > 0 || win + jackpot > 0;
|
||||||
|
|
||||||
if (bet <= 0 && !hasPayout) {
|
if (bet <= 0 && !hasPayout) {
|
||||||
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
|
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
|
||||||
@@ -502,29 +576,7 @@ export function PayoutPanelSnapshot({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
|
||||||
{cells.map((cell) => (
|
|
||||||
<div
|
|
||||||
key={cell.key}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg px-1.5 py-2 ring-1",
|
|
||||||
cell.emphasize
|
|
||||||
? "bg-primary/6 ring-primary/15"
|
|
||||||
: "bg-muted/30 ring-border/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
|
|
||||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatMoney(cell.amount, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{hasPayout ? (
|
{hasPayout ? (
|
||||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
|
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
|
||||||
) : (
|
) : (
|
||||||
@@ -983,7 +1035,10 @@ export function ResultBatchQueueSummary({
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const { pending_review_total, pending_draw_count, published_total, batch_total } = queue;
|
const pendingReviewTotal = coerceAdminMinor(queue.pending_review_total);
|
||||||
|
const pendingDrawCount = coerceAdminMinor(queue.pending_draw_count);
|
||||||
|
const publishedTotal = coerceAdminMinor(queue.published_total);
|
||||||
|
const batchTotal = coerceAdminMinor(queue.batch_total);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
@@ -994,7 +1049,7 @@ export function ResultBatchQueueSummary({
|
|||||||
compact ? "text-lg" : "text-2xl",
|
compact ? "text-lg" : "text-2xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pending_review_total}
|
{pendingReviewTotal}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
|
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1005,18 +1060,16 @@ export function ResultBatchQueueSummary({
|
|||||||
compact ? "text-lg" : "text-2xl",
|
compact ? "text-lg" : "text-2xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{published_total}
|
{publishedTotal}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
|
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
|
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
|
||||||
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
|
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
|
||||||
{batch_total}
|
{pendingDrawCount > 0 ? pendingDrawCount : batchTotal}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||||
{pending_draw_count > 0
|
{pendingDrawCount > 0 ? t("batchPendingDraws") : t("batchTotal")}
|
||||||
? t("batchPendingDrawsCount", { count: pending_draw_count })
|
|
||||||
: t("batchTotal")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1032,10 +1085,14 @@ export function PlatformLifetimePayoutSnapshot({
|
|||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const currency = finance.currency_code;
|
const currency = finance.currency_code;
|
||||||
const bet = finance.total_bet_minor;
|
const bet = coerceAdminMinor(finance.total_bet_minor);
|
||||||
const win = finance.total_win_minor;
|
const payout = coerceAdminMinor(finance.total_payout_minor);
|
||||||
const jackpot = finance.total_jackpot_minor;
|
let win = coerceAdminMinor(finance.total_win_minor);
|
||||||
const hasPayout = win + jackpot > 0;
|
let jackpot = coerceAdminMinor(finance.total_jackpot_minor);
|
||||||
|
if (payout > 0 && win + jackpot === 0) {
|
||||||
|
win = payout;
|
||||||
|
}
|
||||||
|
const hasPayout = payout > 0 || win + jackpot > 0;
|
||||||
|
|
||||||
if (bet <= 0 && !hasPayout) {
|
if (bet <= 0 && !hasPayout) {
|
||||||
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
|
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
|
||||||
@@ -1049,29 +1106,7 @@ export function PlatformLifetimePayoutSnapshot({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
|
||||||
{cells.map((cell) => (
|
|
||||||
<div
|
|
||||||
key={cell.key}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg px-1.5 py-2 ring-1",
|
|
||||||
cell.emphasize
|
|
||||||
? "bg-primary/6 ring-primary/15"
|
|
||||||
: "bg-muted/30 ring-border/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
|
|
||||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatMoney(cell.amount, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{!hasPayout ? (
|
{!hasPayout ? (
|
||||||
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
|
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
|
||||||
{t("platformNoPayoutYet")}
|
{t("platformNoPayoutYet")}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { format, subDays } from "date-fns";
|
import { format, subDays } from "date-fns";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
||||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
import {
|
||||||
|
coerceAdminMinor,
|
||||||
|
formatAdminMinorUnits,
|
||||||
|
getAdminCurrencyDecimalPlaces,
|
||||||
|
} from "@/lib/money";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
AdminDashboardAnalyticsData,
|
AdminDashboardAnalyticsData,
|
||||||
|
AdminDashboardAnalyticsAgentRow,
|
||||||
DashboardAnalyticsMetric,
|
DashboardAnalyticsMetric,
|
||||||
DashboardAnalyticsPeriod,
|
DashboardAnalyticsPeriod,
|
||||||
} from "@/types/api/admin-dashboard-analytics";
|
} from "@/types/api/admin-dashboard-analytics";
|
||||||
@@ -27,9 +34,10 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
|
|||||||
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
|
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
|
||||||
|
|
||||||
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
|
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||||
|
const safeMinor = coerceAdminMinor(minor);
|
||||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||||
const major = minor / 10 ** decimals;
|
const major = safeMinor / 10 ** decimals;
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -38,7 +46,7 @@ export function formatDashboardMoneyMinor(minor: number, currencyCode: string |
|
|||||||
maximumFractionDigits: decimals,
|
maximumFractionDigits: decimals,
|
||||||
}).format(major);
|
}).format(major);
|
||||||
} catch {
|
} catch {
|
||||||
return formatAdminMinorUnits(minor, code, decimals);
|
return formatAdminMinorUnits(safeMinor, code, decimals);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +66,7 @@ export function useDashboardAnalytics({
|
|||||||
playOptions: { code: string; label: string }[];
|
playOptions: { code: string; label: string }[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["dashboard", "common"]);
|
const { t } = useTranslation(["dashboard", "common"]);
|
||||||
|
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||||
const playLabel = useAdminPlayCodeLabel();
|
const playLabel = useAdminPlayCodeLabel();
|
||||||
|
|
||||||
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
||||||
@@ -94,19 +103,18 @@ export function useDashboardAnalytics({
|
|||||||
const needsAuthSync =
|
const needsAuthSync =
|
||||||
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
||||||
setError(
|
setError(
|
||||||
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
|
needsAuthSync
|
||||||
|
? tRef.current("warnings.apiResourceMissing")
|
||||||
|
: raw || tRef.current("warnings.loadFailed"),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [enabled, period, playCode, customFrom, customTo, t]);
|
}, [enabled, period, playCode, customFrom, customTo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
void load();
|
||||||
void load();
|
}, [enabled, period, playCode, customFrom, customTo]);
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const currency = data?.currency_code ?? null;
|
const currency = data?.currency_code ?? null;
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
@@ -152,6 +160,28 @@ export function useDashboardAnalytics({
|
|||||||
return rows.slice(0, 5);
|
return rows.slice(0, 5);
|
||||||
}, [data, rankingMetric]);
|
}, [data, rankingMetric]);
|
||||||
|
|
||||||
|
const metricAgentValue = useCallback(
|
||||||
|
(row: AdminDashboardAnalyticsAgentRow): number => {
|
||||||
|
if (rankingMetric === "payout") {
|
||||||
|
return row.total_payout_minor;
|
||||||
|
}
|
||||||
|
if (rankingMetric === "profit") {
|
||||||
|
return row.approx_house_gross_minor;
|
||||||
|
}
|
||||||
|
return row.total_bet_minor;
|
||||||
|
},
|
||||||
|
[rankingMetric],
|
||||||
|
);
|
||||||
|
|
||||||
|
const topAgentRows = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = [...data.agent_breakdown];
|
||||||
|
rows.sort((a, b) => metricAgentValue(b) - metricAgentValue(a));
|
||||||
|
return rows.slice(0, 5);
|
||||||
|
}, [data, metricAgentValue]);
|
||||||
|
|
||||||
const sparklines = useMemo(() => {
|
const sparklines = useMemo(() => {
|
||||||
const series = data?.daily_series ?? [];
|
const series = data?.daily_series ?? [];
|
||||||
return {
|
return {
|
||||||
@@ -183,6 +213,7 @@ export function useDashboardAnalytics({
|
|||||||
playOptions,
|
playOptions,
|
||||||
resolvePlayLabel,
|
resolvePlayLabel,
|
||||||
topPlayRows,
|
topPlayRows,
|
||||||
|
topAgentRows,
|
||||||
sparklines,
|
sparklines,
|
||||||
formatMoney: formatDashboardMoneyMinor,
|
formatMoney: formatDashboardMoneyMinor,
|
||||||
formatSignedMoney: formatDashboardSignedMoneyMinor,
|
formatSignedMoney: formatDashboardSignedMoneyMinor,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -50,6 +53,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
|
|
||||||
export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
|
const tRef = useTranslationRef(["draws", "common"]);
|
||||||
const idNum = Number(drawId);
|
const idNum = Number(drawId);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||||
@@ -67,7 +71,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum)) {
|
||||||
setError(t("invalidDrawId"));
|
setError(tRef.current("invalidDrawId"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,11 +81,11 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
setData(await getAdminDraw(idNum));
|
setData(await getAdminDraw(idNum));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setData(null);
|
setData(null);
|
||||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [idNum, t]);
|
}, [idNum]);
|
||||||
|
|
||||||
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
||||||
if (!Number.isFinite(idNum)) return;
|
if (!Number.isFinite(idNum)) return;
|
||||||
@@ -97,15 +101,12 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
void load();
|
||||||
void load();
|
}, [idNum]);
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||||
@@ -11,6 +13,7 @@ import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -37,6 +40,7 @@ import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
|||||||
|
|
||||||
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||||
const { t } = useTranslation(["draws", "settlement", "common"]);
|
const { t } = useTranslation(["draws", "settlement", "common"]);
|
||||||
|
const tRef = useTranslationRef(["draws", "settlement", "common"]);
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const idNum = Number(drawId);
|
const idNum = Number(drawId);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
@@ -54,7 +58,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum) || idNum < 1) {
|
if (!Number.isFinite(idNum) || idNum < 1) {
|
||||||
setErr(t("invalidDrawId"));
|
setErr(tRef.current("invalidDrawId"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,12 +67,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
try {
|
try {
|
||||||
setData(await getAdminDrawFinanceSummary(idNum));
|
setData(await getAdminDrawFinanceSummary(idNum));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [idNum, t]);
|
}, [idNum]);
|
||||||
|
|
||||||
async function runSettlement(): Promise<void> {
|
async function runSettlement(): Promise<void> {
|
||||||
if (!Number.isFinite(idNum) || idNum < 1) return;
|
if (!Number.isFinite(idNum) || idNum < 1) return;
|
||||||
@@ -84,14 +88,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [idNum]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>;
|
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err || !data) {
|
if (err || !data) {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -34,6 +37,7 @@ import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
|||||||
|
|
||||||
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
|
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
|
const tRef = useTranslationRef(["draws", "common"]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||||
@@ -50,7 +54,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum)) {
|
||||||
setError(t("invalidDrawId"));
|
setError(tRef.current("invalidDrawId"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,18 +64,15 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
|||||||
setData(await getAdminDrawResultBatches(idNum));
|
setData(await getAdminDrawResultBatches(idNum));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setData(null);
|
setData(null);
|
||||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [idNum, t]);
|
}, [idNum]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
void load();
|
||||||
void load();
|
}, [idNum]);
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
|
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
|
||||||
if (!Number.isFinite(batchNum)) return undefined;
|
if (!Number.isFinite(batchNum)) return undefined;
|
||||||
@@ -115,7 +116,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminDrawResultBatches } from "@/api/admin-draws";
|
import { getAdminDrawResultBatches } from "@/api/admin-draws";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -28,6 +31,7 @@ import { DrawStatusBadge } from "./draw-status-badge";
|
|||||||
|
|
||||||
export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
|
const tRef = useTranslationRef(["draws", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||||
PRD_DRAW_RESULT_MANAGE,
|
PRD_DRAW_RESULT_MANAGE,
|
||||||
@@ -39,7 +43,7 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum)) {
|
||||||
setError(t("invalidDrawId"));
|
setError(tRef.current("invalidDrawId"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,21 +53,18 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
|||||||
setData(await getAdminDrawResultBatches(idNum));
|
setData(await getAdminDrawResultBatches(idNum));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setData(null);
|
setData(null);
|
||||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [idNum, t]);
|
}, [idNum]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
void load();
|
||||||
void load();
|
}, [idNum]);
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Dices, Rocket, Trash2 } from "lucide-react";
|
import { Dices, Rocket, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +16,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -56,6 +59,7 @@ function randomDrawNumber4d(): string {
|
|||||||
|
|
||||||
export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
|
const tRef = useTranslationRef(["draws", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||||
PRD_DRAW_RESULT_MANAGE,
|
PRD_DRAW_RESULT_MANAGE,
|
||||||
@@ -73,7 +77,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum)) {
|
||||||
setError(t("invalidDrawId"));
|
setError(tRef.current("invalidDrawId"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,18 +87,15 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
setData(await getAdminDrawResultBatches(idNum));
|
setData(await getAdminDrawResultBatches(idNum));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setData(null);
|
setData(null);
|
||||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [idNum, t]);
|
}, [idNum]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
void load();
|
||||||
void load();
|
}, [idNum]);
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
|
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
|
||||||
data,
|
data,
|
||||||
@@ -148,7 +149,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Ban, Eye, Pencil, Trash2 } from "lucide-react";
|
import { Ban, Eye, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,10 +16,12 @@ import {
|
|||||||
} from "@/api/admin-draws";
|
} from "@/api/admin-draws";
|
||||||
import { formatAdminInstant } from "@/lib/admin-datetime";
|
import { formatAdminInstant } from "@/lib/admin-datetime";
|
||||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
|
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -86,6 +90,7 @@ function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): s
|
|||||||
|
|
||||||
export function DrawsIndexConsole() {
|
export function DrawsIndexConsole() {
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
|
const tRef = useTranslationRef(["draws", "common"]);
|
||||||
const exportLabels = useExportLabels("drawsList");
|
const exportLabels = useExportLabels("drawsList");
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const defaultCurrency = "NPR";
|
const defaultCurrency = "NPR";
|
||||||
@@ -106,6 +111,8 @@ export function DrawsIndexConsole() {
|
|||||||
const [draftStatus, setDraftStatus] = useState("");
|
const [draftStatus, setDraftStatus] = useState("");
|
||||||
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
||||||
const [appliedStatus, setAppliedStatus] = useState("");
|
const [appliedStatus, setAppliedStatus] = useState("");
|
||||||
|
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
|
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState<number>(10);
|
const [perPage, setPerPage] = useState<number>(10);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
@@ -137,17 +144,18 @@ export function DrawsIndexConsole() {
|
|||||||
appliedStatus.trim() === "" || appliedStatus === DRAW_FILTER_ALL
|
appliedStatus.trim() === "" || appliedStatus === DRAW_FILTER_ALL
|
||||||
? undefined
|
? undefined
|
||||||
: appliedStatus.trim(),
|
: appliedStatus.trim(),
|
||||||
|
agent_node_id: appliedAgentNodeId,
|
||||||
});
|
});
|
||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
|
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||||
|
|
||||||
async function generatePlan(): Promise<void> {
|
async function generatePlan(): Promise<void> {
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
@@ -168,12 +176,9 @@ export function DrawsIndexConsole() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
void load();
|
||||||
void load();
|
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const handleSelectAll = useCallback((checked: boolean) => {
|
const handleSelectAll = useCallback((checked: boolean) => {
|
||||||
if (checked && data) {
|
if (checked && data) {
|
||||||
@@ -293,6 +298,12 @@ export function DrawsIndexConsole() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<CardContent className="admin-list-content">
|
||||||
<div className="admin-list-toolbar">
|
<div className="admin-list-toolbar">
|
||||||
|
<AdminAgentFilter
|
||||||
|
id="draws-agent-filter"
|
||||||
|
className="admin-list-field sm:w-[14rem]"
|
||||||
|
value={agentNodeId}
|
||||||
|
onChange={setAgentNodeId}
|
||||||
|
/>
|
||||||
<div className="admin-list-field xl:min-w-0">
|
<div className="admin-list-field xl:min-w-0">
|
||||||
<Label htmlFor="draw-filter-no" className="sm:w-10 sm:shrink-0">
|
<Label htmlFor="draw-filter-no" className="sm:w-10 sm:shrink-0">
|
||||||
{t("drawNo")}
|
{t("drawNo")}
|
||||||
@@ -347,6 +358,7 @@ export function DrawsIndexConsole() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAppliedDrawNo(draftDrawNo);
|
setAppliedDrawNo(draftDrawNo);
|
||||||
setAppliedStatus(draftStatus);
|
setAppliedStatus(draftStatus);
|
||||||
|
setAppliedAgentNodeId(agentNodeId);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -358,8 +370,10 @@ export function DrawsIndexConsole() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDraftDrawNo("");
|
setDraftDrawNo("");
|
||||||
setDraftStatus("");
|
setDraftStatus("");
|
||||||
|
setAgentNodeId(undefined);
|
||||||
setAppliedDrawNo("");
|
setAppliedDrawNo("");
|
||||||
setAppliedStatus("");
|
setAppliedStatus("");
|
||||||
|
setAppliedAgentNodeId(undefined);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -410,11 +424,7 @@ export function DrawsIndexConsole() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<AdminTableLoadingRow colSpan={10} />
|
||||||
<TableCell colSpan={10} className="text-muted-foreground">
|
|
||||||
{t("states.loading", { ns: "common" })}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : data === null || data.items.length === 0 ? (
|
) : data === null || data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={10} className="text-muted-foreground">
|
<TableCell colSpan={10} className="text-muted-foreground">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
|
import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +39,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { getAdminPageBundle } from "@/lib/admin-permission-bundles";
|
import { getAdminPageBundle } from "@/lib/admin-permission-bundles";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
@@ -138,6 +141,7 @@ function formToPayload(
|
|||||||
|
|
||||||
export function IntegrationSitesConsole() {
|
export function IntegrationSitesConsole() {
|
||||||
const { t } = useTranslation("config");
|
const { t } = useTranslation("config");
|
||||||
|
const tRef = useTranslationRef("config");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(
|
const canManage = adminHasAnyPermission(
|
||||||
profile?.permissions,
|
profile?.permissions,
|
||||||
@@ -174,18 +178,16 @@ export function IntegrationSitesConsole() {
|
|||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
|
error instanceof LotteryApiBizError ? error.message : tRef.current("integrationSites.loadFailed"),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, []);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
setMode("create");
|
setMode("create");
|
||||||
@@ -352,7 +354,7 @@ export function IntegrationSitesConsole() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("integrationSites.loading")}</p>
|
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
|
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAdminJackpotPoolAdjustments,
|
getAdminJackpotPoolAdjustments,
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
contribution_rate: string;
|
contribution_rate: string;
|
||||||
@@ -78,6 +81,7 @@ type JackpotPoolsConsoleProps = {
|
|||||||
|
|
||||||
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
|
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
|
||||||
const { t } = useTranslation(["jackpot", "common"]);
|
const { t } = useTranslation(["jackpot", "common"]);
|
||||||
|
const tRef = useTranslationRef(["jackpot", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
|
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
|
||||||
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);
|
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);
|
||||||
@@ -114,17 +118,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
setAdjustmentDrafts(adjDrafts);
|
setAdjustmentDrafts(adjDrafts);
|
||||||
setAdjustmentRows(adjRows);
|
setAdjustmentRows(adjRows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, []);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const updateDraft = (id: number, patch: Partial<Draft>) => {
|
const updateDraft = (id: number, patch: Partial<Draft>) => {
|
||||||
setDrafts((prev) => ({
|
setDrafts((prev) => ({
|
||||||
@@ -229,7 +231,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
|
|
||||||
const poolList = (
|
const poolList = (
|
||||||
<div className={embedded ? "space-y-4" : "space-y-8"}>
|
<div className={embedded ? "space-y-4" : "space-y-8"}>
|
||||||
{loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null}
|
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
|
||||||
{!loading && items.length === 0 ? (
|
{!loading && items.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
|
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
|
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
@@ -13,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -74,7 +77,7 @@ function JackpotRecordTableSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{loading && !hasData ? (
|
{loading && !hasData ? (
|
||||||
<p className="px-4 py-6 text-sm text-muted-foreground">{t("states.loading")}</p>
|
<AdminLoadingState minHeight="6rem" className="px-4 py-6" />
|
||||||
) : (
|
) : (
|
||||||
<div className={TABLE_IN_SHELL_CLASS}>{children}</div>
|
<div className={TABLE_IN_SHELL_CLASS}>{children}</div>
|
||||||
)}
|
)}
|
||||||
@@ -83,8 +86,12 @@ function JackpotRecordTableSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecordTab = "payout" | "contribution";
|
||||||
|
|
||||||
export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) {
|
export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) {
|
||||||
const { t } = useTranslation(["jackpot", "common"]);
|
const { t } = useTranslation(["jackpot", "common"]);
|
||||||
|
const tRef = useTranslationRef(["jackpot"]);
|
||||||
|
const [recordTab, setRecordTab] = useState<RecordTab>("payout");
|
||||||
const payoutExport = useExportLabels("jackpotPayouts");
|
const payoutExport = useExportLabels("jackpotPayouts");
|
||||||
const contributionExport = useExportLabels("jackpotContributions");
|
const contributionExport = useExportLabels("jackpotContributions");
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
@@ -100,7 +107,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
const [cPer, setCPer] = useState(10);
|
const [cPer, setCPer] = useState(10);
|
||||||
|
|
||||||
const [loadingP, setLoadingP] = useState(true);
|
const [loadingP, setLoadingP] = useState(true);
|
||||||
const [loadingC, setLoadingC] = useState(true);
|
const [loadingC, setLoadingC] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadPayouts = useCallback(async () => {
|
const loadPayouts = useCallback(async () => {
|
||||||
@@ -113,11 +120,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
});
|
});
|
||||||
setPayouts(d);
|
setPayouts(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("payoutLoadFailed"));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("payoutLoadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingP(false);
|
setLoadingP(false);
|
||||||
}
|
}
|
||||||
}, [pPage, pPer, appliedDrawNo, t]);
|
}, [pPage, pPer, appliedDrawNo]);
|
||||||
|
|
||||||
const loadContribs = useCallback(async () => {
|
const loadContribs = useCallback(async () => {
|
||||||
setLoadingC(true);
|
setLoadingC(true);
|
||||||
@@ -129,23 +136,22 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
});
|
});
|
||||||
setContribs(d);
|
setContribs(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("contributionLoadFailed"));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("contributionLoadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingC(false);
|
setLoadingC(false);
|
||||||
}
|
}
|
||||||
}, [cPage, cPer, appliedDrawNo, t]);
|
}, [cPage, cPer, appliedDrawNo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void loadPayouts();
|
||||||
void loadPayouts();
|
}, [pPage, pPer, appliedDrawNo]);
|
||||||
});
|
|
||||||
}, [loadPayouts]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
if (recordTab !== "contribution") {
|
||||||
void loadContribs();
|
return;
|
||||||
});
|
}
|
||||||
}, [loadContribs]);
|
void loadContribs();
|
||||||
|
}, [recordTab, cPage, cPer, appliedDrawNo]);
|
||||||
|
|
||||||
const applyDraw = () => {
|
const applyDraw = () => {
|
||||||
setAppliedDrawNo(drawNo);
|
setAppliedDrawNo(drawNo);
|
||||||
@@ -328,9 +334,27 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
{filterBlock}
|
{filterBlock}
|
||||||
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-b border-border/70 pb-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={recordTab === "payout" ? "default" : "outline"}
|
||||||
|
onClick={() => setRecordTab("payout")}
|
||||||
|
>
|
||||||
|
{t("payoutRecords")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={recordTab === "contribution" ? "default" : "outline"}
|
||||||
|
onClick={() => setRecordTab("contribution")}
|
||||||
|
>
|
||||||
|
{t("contributionRecords")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{payoutTable}
|
{recordTab === "payout" ? payoutTable : contributionTable}
|
||||||
{contributionTable}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +18,8 @@ import {
|
|||||||
postAdminPlayerUnfreeze,
|
postAdminPlayerUnfreeze,
|
||||||
putAdminPlayer,
|
putAdminPlayer,
|
||||||
} from "@/api/admin-player";
|
} from "@/api/admin-player";
|
||||||
|
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||||
|
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
@@ -33,6 +37,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
@@ -81,6 +86,7 @@ const PLAYER_STATUS_OPTIONS = [
|
|||||||
|
|
||||||
export function PlayersConsole(): React.ReactElement {
|
export function PlayersConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["players", "common"]);
|
const { t } = useTranslation(["players", "common"]);
|
||||||
|
const tRef = useTranslationRef(["players", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const exportLabels = useExportLabels("players");
|
const exportLabels = useExportLabels("players");
|
||||||
@@ -95,6 +101,8 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [siteCode, setSiteCode] = useState("");
|
const [siteCode, setSiteCode] = useState("");
|
||||||
const [appliedSiteCode, setAppliedSiteCode] = useState("");
|
const [appliedSiteCode, setAppliedSiteCode] = useState("");
|
||||||
|
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
|
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -131,12 +139,13 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
keyword: query.trim() || undefined,
|
keyword: query.trim() || undefined,
|
||||||
site_code: appliedSiteCode.trim() || undefined,
|
site_code: appliedSiteCode.trim() || undefined,
|
||||||
|
agent_node_id: appliedAgentNodeId,
|
||||||
});
|
});
|
||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
setTotal(data.meta.total);
|
setTotal(data.meta.total);
|
||||||
setLastPage(Math.max(1, data.meta.last_page));
|
setLastPage(Math.max(1, data.meta.last_page));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||||
setErr(msg);
|
setErr(msg);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
@@ -144,13 +153,11 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, query, appliedSiteCode, t]);
|
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
function openCreateAccount(): void {
|
function openCreateAccount(): void {
|
||||||
setAccountMode("create");
|
setAccountMode("create");
|
||||||
@@ -334,6 +341,12 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<AdminAgentFilter
|
||||||
|
id="players-agent-filter"
|
||||||
|
className="admin-list-field sm:w-[14rem]"
|
||||||
|
value={agentNodeId}
|
||||||
|
onChange={setAgentNodeId}
|
||||||
|
/>
|
||||||
<div className="admin-list-field xl:min-w-0">
|
<div className="admin-list-field xl:min-w-0">
|
||||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||||
{t("search")}
|
{t("search")}
|
||||||
@@ -349,6 +362,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setQuery(keyword.trim());
|
setQuery(keyword.trim());
|
||||||
setAppliedSiteCode(siteCode.trim());
|
setAppliedSiteCode(siteCode.trim());
|
||||||
|
setAppliedAgentNodeId(agentNodeId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -365,6 +379,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setQuery(keyword.trim());
|
setQuery(keyword.trim());
|
||||||
setAppliedSiteCode(siteCode.trim());
|
setAppliedSiteCode(siteCode.trim());
|
||||||
|
setAppliedAgentNodeId(agentNodeId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("search")}
|
{t("search")}
|
||||||
@@ -377,15 +392,13 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<CardContent className="admin-list-content">
|
||||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
{loading && items.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
<Table id="players-table">
|
<Table id="players-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||||
<TableHead>{t("site")}</TableHead>
|
<TableHead>{t("site")}</TableHead>
|
||||||
|
<AdminAgentHead />
|
||||||
<TableHead>{t("sitePlayerId")}</TableHead>
|
<TableHead>{t("sitePlayerId")}</TableHead>
|
||||||
<TableHead>{t("username")}</TableHead>
|
<TableHead>{t("username")}</TableHead>
|
||||||
<TableHead>{t("nickname")}</TableHead>
|
<TableHead>{t("nickname")}</TableHead>
|
||||||
@@ -398,9 +411,11 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 && !loading ? (
|
{loading && items.length === 0 ? (
|
||||||
|
<AdminTableLoadingRow colSpan={12} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={11} className="text-muted-foreground">
|
<TableCell colSpan={12} className="text-muted-foreground">
|
||||||
{t("states.noData", { ns: "common" })}
|
{t("states.noData", { ns: "common" })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -413,6 +428,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="font-mono text-xs">{row.site_code}</span>
|
<span className="font-mono text-xs">{row.site_code}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<AdminAgentCell row={row} />
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="font-mono text-xs">{row.site_player_id}</span>
|
<span className="font-mono text-xs">{row.site_player_id}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye } from "lucide-react";
|
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -36,6 +39,7 @@ import { useAdminProfile } from "@/stores/admin-session";
|
|||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||||
import type {
|
import type {
|
||||||
|
AdminReconcileJobRow,
|
||||||
AdminReconcileItemsData,
|
AdminReconcileItemsData,
|
||||||
AdminReconcileJobListData,
|
AdminReconcileJobListData,
|
||||||
} from "@/types/api/admin-reconcile";
|
} from "@/types/api/admin-reconcile";
|
||||||
@@ -80,8 +84,23 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
||||||
|
const raw = summary?.[key];
|
||||||
|
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPeriodRange(
|
||||||
|
row: Pick<AdminReconcileJobRow, "period_start" | "period_end">,
|
||||||
|
formatTs: (value: string | null | undefined) => string,
|
||||||
|
): string {
|
||||||
|
const from = row.period_start ? formatTs(row.period_start) : "—";
|
||||||
|
const to = row.period_end ? formatTs(row.period_end) : "—";
|
||||||
|
return `${from} ~ ${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function ReconcileConsole(): React.ReactElement {
|
export function ReconcileConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["reconcile", "common"]);
|
const { t } = useTranslation(["reconcile", "common"]);
|
||||||
|
const tRef = useTranslationRef(["reconcile", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
||||||
@@ -115,18 +134,16 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
const d = await getAdminReconcileJobs({ page, per_page: perPage });
|
const d = await getAdminReconcileJobs({ page, per_page: perPage });
|
||||||
setJobs(d);
|
setJobs(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setJobsErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
setJobsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||||
setJobs(null);
|
setJobs(null);
|
||||||
} finally {
|
} finally {
|
||||||
setJobsLoading(false);
|
setJobsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, t]);
|
}, [page, perPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void loadJobs();
|
||||||
void loadJobs();
|
}, [page, perPage]);
|
||||||
});
|
|
||||||
}, [loadJobs]);
|
|
||||||
|
|
||||||
const loadItems = useCallback(async () => {
|
const loadItems = useCallback(async () => {
|
||||||
if (selectedId == null) {
|
if (selectedId == null) {
|
||||||
@@ -141,18 +158,16 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
setItems(d);
|
setItems(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadItemsFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadItemsFailed"));
|
||||||
setItems(null);
|
setItems(null);
|
||||||
} finally {
|
} finally {
|
||||||
setItemsLoading(false);
|
setItemsLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedId, itemsPage, itemsPerPage, t]);
|
}, [selectedId, itemsPage, itemsPerPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void loadItems();
|
||||||
void loadItems();
|
}, [selectedId, itemsPage, itemsPerPage]);
|
||||||
});
|
|
||||||
}, [loadItems]);
|
|
||||||
|
|
||||||
const loadPlayers = useCallback(async (keyword: string) => {
|
const loadPlayers = useCallback(async (keyword: string) => {
|
||||||
const q = keyword.trim();
|
const q = keyword.trim();
|
||||||
@@ -218,6 +233,9 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
const jm = jobs?.meta;
|
const jm = jobs?.meta;
|
||||||
const im = items?.meta;
|
const im = items?.meta;
|
||||||
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
|
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
|
||||||
|
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
|
||||||
|
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
|
||||||
|
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<div className="flex w-full max-w-none flex-col gap-6">
|
||||||
@@ -225,28 +243,157 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
<Card className="admin-list-card">
|
<Card className="admin-list-card">
|
||||||
<CardHeader className="admin-list-header">
|
<CardHeader className="admin-list-header">
|
||||||
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("createDesc")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content pt-4">
|
<CardContent className="admin-list-content pt-4">
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,0.9fr)_minmax(220px,0.95fr)_auto] lg:items-end">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
<div className="grid gap-1.5">
|
<div className="rounded-xl border bg-muted/15 p-4">
|
||||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
<div className="mb-4 flex items-start gap-3">
|
||||||
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
<div className="rounded-lg bg-background p-2 text-muted-foreground">
|
||||||
|
<CalendarRange className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{t("scopeTitle")}</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("scopeDescription")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||||
|
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
||||||
|
<p className="text-xs text-muted-foreground">{t("reconcileTypeHint")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<AdminDateRangeField
|
||||||
|
id="rc-date-range"
|
||||||
|
label={t("dateRange")}
|
||||||
|
from={dateFrom}
|
||||||
|
to={dateTo}
|
||||||
|
onRangeChange={({ from, to }) => {
|
||||||
|
setDateFrom(from);
|
||||||
|
setDateTo(to);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("dateRangeHint")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<AdminDateRangeField
|
<div className="rounded-xl border bg-background p-4">
|
||||||
id="rc-date-range"
|
<div className="mb-4 flex items-start gap-3">
|
||||||
label={t("dateRange")}
|
<div className="rounded-lg bg-muted/20 p-2 text-muted-foreground">
|
||||||
from={dateFrom}
|
<UserRound className="size-4" />
|
||||||
to={dateTo}
|
</div>
|
||||||
onRangeChange={({ from, to }) => {
|
<div className="min-w-0">
|
||||||
setDateFrom(from);
|
<div className="text-sm font-medium">{t("playerScopeTitle")}</div>
|
||||||
setDateTo(to);
|
<p className="text-sm text-muted-foreground">{t("playerSearchHint")}</p>
|
||||||
}}
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
||||||
|
<Input
|
||||||
|
id="rc-player-search"
|
||||||
|
value={playerSearch}
|
||||||
|
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||||
|
placeholder={t("playerSearchPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPlayer ? (
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-foreground">
|
||||||
|
{selectedPlayer.site_player_id}
|
||||||
|
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
||||||
|
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{t("playerSelected")} · {selectedPlayer.site_code}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPlayer(null);
|
||||||
|
setPlayerSearch("");
|
||||||
|
setPlayerResults([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("playerClear")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
||||||
|
<div className="mt-4 rounded-lg border bg-background">
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
{playerLoading ? (
|
||||||
|
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
|
||||||
|
) : playerResults.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{playerResults.map((player) => {
|
||||||
|
const active = selectedPlayer?.id === player.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={player.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
||||||
|
active && "bg-muted/30",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPlayer(player);
|
||||||
|
setPlayerSearch(player.site_player_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-foreground">
|
||||||
|
{player.site_player_id}
|
||||||
|
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{player.username ?? "—"} · {player.site_code}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{active ? t("playerSelectedShort") : t("playerChoose")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 rounded-lg border border-dashed bg-muted/10 px-3 py-3 text-sm text-muted-foreground">
|
||||||
|
{t("playerAllPlayersHint")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3">
|
||||||
|
<div className="min-w-0 text-sm text-muted-foreground">
|
||||||
|
{selectedPlayer
|
||||||
|
? t("createSummaryPlayer", {
|
||||||
|
player: selectedPlayer.site_player_id,
|
||||||
|
from: dateFrom || "—",
|
||||||
|
to: dateTo || "—",
|
||||||
|
})
|
||||||
|
: t("createSummaryAll", {
|
||||||
|
from: dateFrom || "—",
|
||||||
|
to: dateTo || "—",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full lg:w-auto"
|
className="w-full sm:w-auto"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestConfirm({
|
requestConfirm({
|
||||||
@@ -263,82 +410,6 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
{submitting ? t("submitting") : t("createTask")}
|
{submitting ? t("submitting") : t("createTask")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5 pt-4">
|
|
||||||
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
|
||||||
<Input
|
|
||||||
id="rc-player-search"
|
|
||||||
value={playerSearch}
|
|
||||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
|
||||||
placeholder={t("playerSearchPlaceholder")}
|
|
||||||
/>
|
|
||||||
{selectedPlayer ? (
|
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium text-foreground">
|
|
||||||
{selectedPlayer.site_player_id}
|
|
||||||
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
|
||||||
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedPlayer(null);
|
|
||||||
setPlayerSearch("");
|
|
||||||
setPlayerResults([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("playerClear")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
|
||||||
<div className="rounded-lg border bg-background">
|
|
||||||
<div className="max-h-56 overflow-y-auto">
|
|
||||||
{playerLoading ? (
|
|
||||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("loadingPlayers")}</div>
|
|
||||||
) : playerResults.length === 0 ? (
|
|
||||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{playerResults.map((player) => {
|
|
||||||
const active = selectedPlayer?.id === player.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={player.id}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
|
||||||
active && "bg-muted/30",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedPlayer(player);
|
|
||||||
setPlayerSearch(player.site_player_id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium text-foreground">
|
|
||||||
{player.site_player_id}
|
|
||||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
|
||||||
</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
|
||||||
{player.username ?? "—"} · {player.site_code}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{active ? t("playerSelectedShort") : t("playerChoose")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -349,6 +420,7 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("jobsDesc")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
||||||
{t("refresh")}
|
{t("refresh")}
|
||||||
@@ -356,9 +428,6 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content pt-4">
|
<CardContent className="admin-list-content pt-4">
|
||||||
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
||||||
{jobsLoading && !jobs ? (
|
|
||||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : null}
|
|
||||||
{jobs ? (
|
{jobs ? (
|
||||||
<>
|
<>
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
@@ -373,7 +442,10 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>{t("type")}</TableHead>
|
<TableHead>{t("type")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
|
<TableHead className="text-center">{t("itemCount")}</TableHead>
|
||||||
|
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
|
||||||
<TableHead>{t("period")}</TableHead>
|
<TableHead>{t("period")}</TableHead>
|
||||||
|
<TableHead>{t("finishedAt")}</TableHead>
|
||||||
<TableHead>{t("createdAt")}</TableHead>
|
<TableHead>{t("createdAt")}</TableHead>
|
||||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted/20 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
<TableHead className="sticky right-0 z-20 w-14 bg-muted/20 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
{t("operate")}
|
{t("operate")}
|
||||||
@@ -381,9 +453,11 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{jobs.items.length === 0 ? (
|
{jobsLoading && !jobs ? (
|
||||||
|
<AdminTableLoadingRow colSpan={10} />
|
||||||
|
) : jobs.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-muted-foreground">
|
<TableCell colSpan={10} className="text-muted-foreground">
|
||||||
{t("states.noData", { ns: "common" })}
|
{t("states.noData", { ns: "common" })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -402,12 +476,28 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
{jobStatusLabel(row.status, t)}
|
{jobStatusLabel(row.status, t)}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-center tabular-nums">
|
||||||
|
{getJobSummaryValue(row.summary_json, "item_count")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center tabular-nums">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
getJobSummaryValue(row.summary_json, "mismatch_count") > 0
|
||||||
|
? "font-medium text-amber-700"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getJobSummaryValue(row.summary_json, "mismatch_count")}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
|
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
|
||||||
<span className="line-clamp-2">
|
<span className="line-clamp-2">
|
||||||
{row.period_start ? formatTs(row.period_start) : "—"} ~{" "}
|
{renderPeriodRange(row, formatTs)}
|
||||||
{row.period_end ? formatTs(row.period_end) : "—"}
|
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||||
|
{formatTs(row.finished_at)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||||
{formatTs(row.created_at)}
|
{formatTs(row.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -475,10 +565,27 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
||||||
{itemsLoading && !items ? (
|
{itemsLoading && !items ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||||
) : null}
|
) : null}
|
||||||
{items ? (
|
{items ? (
|
||||||
<>
|
<>
|
||||||
|
<div className="mb-4 grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border bg-background px-4 py-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("itemCount")}</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobItemCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-background px-4 py-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("mismatchCount")}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xl font-semibold tabular-nums text-amber-700">
|
||||||
|
<ShieldAlert className="size-4" />
|
||||||
|
{selectedJobMismatchCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-background px-4 py-3">
|
||||||
|
<div className="text-xs text-muted-foreground">{t("matchedCount")}</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobMatchedCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span>{t("jobNo")} {items.job_no}</span>
|
<span>{t("jobNo")} {items.job_no}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -493,7 +600,7 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{t("period")} {selectedJob ? `${selectedJob.period_start ? formatTs(selectedJob.period_start) : "—"} ~ ${selectedJob.period_end ? formatTs(selectedJob.period_end) : "—"}` : "—"}</span>
|
<span>{t("period")} {selectedJob ? renderPeriodRange(selectedJob, formatTs) : "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-background">
|
<div className="rounded-lg border bg-background">
|
||||||
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
||||||
@@ -502,29 +609,47 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||||
<TableHead>{t("sideARef")}</TableHead>
|
<TableHead>{t("sideARef")}</TableHead>
|
||||||
<TableHead>{t("sideBRef")}</TableHead>
|
<TableHead>{t("sideBRef")}</TableHead>
|
||||||
<TableHead>{t("differenceAmount")}</TableHead>
|
<TableHead className="text-right">{t("differenceAmount")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
|
<TableHead>{t("detectedAt")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.items.length === 0 ? (
|
{items.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-muted-foreground">
|
<TableCell colSpan={6} className="text-muted-foreground">
|
||||||
{t("noDetails")}
|
{t("noDetails")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
items.items.map((r) => (
|
items.items.map((r) => (
|
||||||
<TableRow key={r.id}>
|
<TableRow
|
||||||
|
key={r.id}
|
||||||
|
className={cn(
|
||||||
|
r.status === "mismatch" && "bg-amber-500/5",
|
||||||
|
r.status === "matched" && "bg-emerald-500/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<TableCell>{r.id}</TableCell>
|
<TableCell>{r.id}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
|
||||||
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
|
<TableCell className="text-right tabular-nums">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.difference_amount}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<AdminStatusBadge status={r.status}>
|
<AdminStatusBadge status={r.status}>
|
||||||
{itemStatusLabel(r.status, t)}
|
{itemStatusLabel(r.status, t)}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||||
|
{formatTs(r.created_at)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Download, RefreshCw } from "lucide-react";
|
import { Download, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
|||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -40,6 +43,7 @@ type ReportJobsPanelProps = {
|
|||||||
|
|
||||||
export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) {
|
export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) {
|
||||||
const { t } = useTranslation(["reports", "common"]);
|
const { t } = useTranslation(["reports", "common"]);
|
||||||
|
const tRef = useTranslationRef(["reports", "common"]);
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
const [jobs, setJobs] = useState<AdminReportJobRow[]>([]);
|
const [jobs, setJobs] = useState<AdminReportJobRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -51,18 +55,16 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
|||||||
const data = await getAdminReportJobs({ page: 1, per_page: 10 });
|
const data = await getAdminReportJobs({ page: 1, per_page: 10 });
|
||||||
setJobs(data.items);
|
setJobs(data.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("tasks.loadFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed"));
|
||||||
setJobs([]);
|
setJobs([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void loadJobs();
|
||||||
void loadJobs();
|
}, [refreshToken]);
|
||||||
});
|
|
||||||
}, [loadJobs, refreshToken]);
|
|
||||||
|
|
||||||
async function handleDownload(job: AdminReportJobRow): Promise<void> {
|
async function handleDownload(job: AdminReportJobRow): Promise<void> {
|
||||||
if (!canExport || job.status !== "completed") {
|
if (!canExport || job.status !== "completed") {
|
||||||
@@ -111,11 +113,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<AdminTableLoadingRow colSpan={6} />
|
||||||
<TableCell colSpan={6} className="text-muted-foreground">
|
|
||||||
{t("states.loading", { ns: "common" })}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : jobs.length === 0 ? (
|
) : jobs.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-muted-foreground">
|
<TableCell colSpan={6} className="text-muted-foreground">
|
||||||
|
|||||||
@@ -20,13 +20,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||||
import { getAdminPlayTypes } from "@/api/admin-config";
|
|
||||||
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||||
import {
|
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||||
getAdminPlayTypesLoadPromise,
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
getCachedAdminPlayTypes,
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
resolveAdminPlayTypeDisplayName,
|
|
||||||
} from "@/lib/admin-play-types";
|
|
||||||
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||||
import { getAdminPlayers } from "@/api/admin-player";
|
import { getAdminPlayers } from "@/api/admin-player";
|
||||||
import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs";
|
import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs";
|
||||||
@@ -49,6 +46,8 @@ import { getAdminTransferOrders } from "@/api/admin-wallet";
|
|||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||||
|
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -56,6 +55,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -121,12 +121,24 @@ type ReportDefinition = {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PreviewColumns = {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
metricA: string;
|
||||||
|
metricB: string;
|
||||||
|
metricC: string;
|
||||||
|
status: string;
|
||||||
|
extra: string;
|
||||||
|
time: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ReportFilters = {
|
type ReportFilters = {
|
||||||
drawNo: string;
|
drawNo: string;
|
||||||
drawId: number | null;
|
drawId: number | null;
|
||||||
number: string;
|
number: string;
|
||||||
player: string;
|
player: string;
|
||||||
playerId: number | null;
|
playerId: number | null;
|
||||||
|
agentNodeId: number | undefined;
|
||||||
play: string;
|
play: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
operatorId: number | null;
|
operatorId: number | null;
|
||||||
@@ -190,6 +202,7 @@ const emptyFilters: ReportFilters = {
|
|||||||
number: "",
|
number: "",
|
||||||
player: "",
|
player: "",
|
||||||
playerId: null,
|
playerId: null,
|
||||||
|
agentNodeId: undefined,
|
||||||
play: "",
|
play: "",
|
||||||
operator: "",
|
operator: "",
|
||||||
operatorId: null,
|
operatorId: null,
|
||||||
@@ -302,6 +315,10 @@ function formatPlainMoney(value: number, currencyCode: string | null | undefined
|
|||||||
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUsagePercent(ratio: number | null | undefined): string {
|
||||||
|
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
function optionText(...parts: Array<string | number | null | undefined>): string {
|
function optionText(...parts: Array<string | number | null | undefined>): string {
|
||||||
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
||||||
}
|
}
|
||||||
@@ -314,6 +331,7 @@ function reportListParams(filters: ReportFilters, page: number, perPage: number)
|
|||||||
date_to: filters.dateTo || undefined,
|
date_to: filters.dateTo || undefined,
|
||||||
player_id: filters.playerId ?? undefined,
|
player_id: filters.playerId ?? undefined,
|
||||||
play_code: filters.play.trim() || undefined,
|
play_code: filters.play.trim() || undefined,
|
||||||
|
agent_node_id: filters.agentNodeId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,23 +346,22 @@ function parsePositiveInteger(value: string): number | null {
|
|||||||
|
|
||||||
async function resolveDraw(
|
async function resolveDraw(
|
||||||
filters: ReportFilters,
|
filters: ReportFilters,
|
||||||
t: (key: string, options?: { ns?: string; drawNo?: string }) => string,
|
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
|
||||||
): Promise<{ id: number; draw_no: string }> {
|
): Promise<{ id: number; draw_no: string }> {
|
||||||
if (filters.drawId && filters.drawNo.trim()) {
|
if (filters.drawId != null && filters.drawId > 0) {
|
||||||
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
|
const drawNo = filters.drawNo.trim();
|
||||||
|
return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawNo = filters.drawNo.trim();
|
const drawNo = filters.drawNo.trim();
|
||||||
if (!drawNo) {
|
if (!drawNo) {
|
||||||
throw new LotteryApiBizError(t("validation.drawNoRequired", { ns: "reports" }), -1, null);
|
throw new LotteryApiBizError(messages.drawNoRequired, -1, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
||||||
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
throw new LotteryApiBizError(t("validation.drawNoNotFound", { ns: "reports", drawNo }), -1, {
|
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
|
||||||
drawNo,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return { id: matched.id, draw_no: matched.draw_no };
|
return { id: matched.id, draw_no: matched.draw_no };
|
||||||
}
|
}
|
||||||
@@ -403,10 +420,131 @@ export function ReportsConsole() {
|
|||||||
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
||||||
const [jobRefreshToken, setJobRefreshToken] = useState(0);
|
const [jobRefreshToken, setJobRefreshToken] = useState(0);
|
||||||
const [search, setSearch] = useState<SearchState>(emptySearch);
|
const [search, setSearch] = useState<SearchState>(emptySearch);
|
||||||
const [playOptions, setPlayOptions] = useState<PlayOption[]>([]);
|
const playOptions = useCachedPlayTypeOptions();
|
||||||
|
const tRef = useTranslationRef(["reports", "common"]);
|
||||||
|
|
||||||
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
|
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
|
||||||
|
|
||||||
|
const pageScopedLabel = useCallback(
|
||||||
|
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const previewColumns = useMemo<PreviewColumns>(() => {
|
||||||
|
switch (selectedReport.key) {
|
||||||
|
case "draw_profit":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.drawProfit.primary"),
|
||||||
|
secondary: t("preview.columns.drawProfit.secondary"),
|
||||||
|
metricA: t("preview.columns.drawProfit.metricA"),
|
||||||
|
metricB: t("preview.columns.drawProfit.metricB"),
|
||||||
|
metricC: t("preview.columns.drawProfit.metricC"),
|
||||||
|
status: t("preview.columns.drawProfit.status"),
|
||||||
|
extra: t("preview.columns.drawProfit.extra"),
|
||||||
|
time: t("preview.columns.drawProfit.time"),
|
||||||
|
};
|
||||||
|
case "daily_profit":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.dailyProfit.primary"),
|
||||||
|
secondary: t("preview.columns.dailyProfit.secondary"),
|
||||||
|
metricA: t("preview.columns.dailyProfit.metricA"),
|
||||||
|
metricB: t("preview.columns.dailyProfit.metricB"),
|
||||||
|
metricC: t("preview.columns.dailyProfit.metricC"),
|
||||||
|
status: t("preview.columns.dailyProfit.status"),
|
||||||
|
extra: t("preview.columns.dailyProfit.extra"),
|
||||||
|
time: t("preview.columns.dailyProfit.time"),
|
||||||
|
};
|
||||||
|
case "player_win_loss":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.playerWinLoss.primary"),
|
||||||
|
secondary: t("agentColumns.agent", { ns: "common" }),
|
||||||
|
metricA: t("preview.columns.playerWinLoss.metricA"),
|
||||||
|
metricB: t("preview.columns.playerWinLoss.metricB"),
|
||||||
|
metricC: t("preview.columns.playerWinLoss.metricC"),
|
||||||
|
status: t("preview.columns.playerWinLoss.status"),
|
||||||
|
extra: t("preview.columns.playerWinLoss.extra"),
|
||||||
|
time: t("preview.columns.playerWinLoss.time"),
|
||||||
|
};
|
||||||
|
case "player_transfer":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.playerTransfer.primary"),
|
||||||
|
secondary: t("preview.columns.playerTransfer.secondary"),
|
||||||
|
metricA: t("preview.columns.playerTransfer.metricA"),
|
||||||
|
metricB: t("preview.columns.playerTransfer.metricB"),
|
||||||
|
metricC: t("preview.columns.playerTransfer.metricC"),
|
||||||
|
status: t("preview.columns.playerTransfer.status"),
|
||||||
|
extra: t("preview.columns.playerTransfer.extra"),
|
||||||
|
time: t("preview.columns.playerTransfer.time"),
|
||||||
|
};
|
||||||
|
case "hot_number_risk":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.hotNumberRisk.primary"),
|
||||||
|
secondary: t("preview.columns.hotNumberRisk.secondary"),
|
||||||
|
metricA: t("preview.columns.hotNumberRisk.metricA"),
|
||||||
|
metricB: t("preview.columns.hotNumberRisk.metricB"),
|
||||||
|
metricC: t("preview.columns.hotNumberRisk.metricC"),
|
||||||
|
status: t("preview.columns.hotNumberRisk.status"),
|
||||||
|
extra: t("preview.columns.hotNumberRisk.extra"),
|
||||||
|
time: t("preview.columns.hotNumberRisk.time"),
|
||||||
|
};
|
||||||
|
case "play_dimension":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.playDimension.primary"),
|
||||||
|
secondary: t("preview.columns.playDimension.secondary"),
|
||||||
|
metricA: t("preview.columns.playDimension.metricA"),
|
||||||
|
metricB: t("preview.columns.playDimension.metricB"),
|
||||||
|
metricC: t("preview.columns.playDimension.metricC"),
|
||||||
|
status: t("preview.columns.playDimension.status"),
|
||||||
|
extra: t("preview.columns.playDimension.extra"),
|
||||||
|
time: t("preview.columns.playDimension.time"),
|
||||||
|
};
|
||||||
|
case "sold_out_number":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.soldOut.primary"),
|
||||||
|
secondary: t("preview.columns.soldOut.secondary"),
|
||||||
|
metricA: t("preview.columns.soldOut.metricA"),
|
||||||
|
metricB: t("preview.columns.soldOut.metricB"),
|
||||||
|
metricC: t("preview.columns.soldOut.metricC"),
|
||||||
|
status: t("preview.columns.soldOut.status"),
|
||||||
|
extra: t("preview.columns.soldOut.extra"),
|
||||||
|
time: t("preview.columns.soldOut.time"),
|
||||||
|
};
|
||||||
|
case "rebate_commission":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.rebateCommission.primary"),
|
||||||
|
secondary: t("preview.columns.rebateCommission.secondary"),
|
||||||
|
metricA: t("preview.columns.rebateCommission.metricA"),
|
||||||
|
metricB: t("preview.columns.rebateCommission.metricB"),
|
||||||
|
metricC: t("preview.columns.rebateCommission.metricC"),
|
||||||
|
status: t("preview.columns.rebateCommission.status"),
|
||||||
|
extra: t("preview.columns.rebateCommission.extra"),
|
||||||
|
time: t("preview.columns.rebateCommission.time"),
|
||||||
|
};
|
||||||
|
case "admin_audit":
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.adminAudit.primary"),
|
||||||
|
secondary: t("preview.columns.adminAudit.secondary"),
|
||||||
|
metricA: t("preview.columns.adminAudit.metricA"),
|
||||||
|
metricB: t("preview.columns.adminAudit.metricB"),
|
||||||
|
metricC: t("preview.columns.adminAudit.metricC"),
|
||||||
|
status: t("preview.columns.adminAudit.status"),
|
||||||
|
extra: t("preview.columns.adminAudit.extra"),
|
||||||
|
time: t("preview.columns.adminAudit.time"),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
primary: t("preview.columns.primary"),
|
||||||
|
secondary: t("preview.columns.secondary"),
|
||||||
|
metricA: t("preview.columns.metricA"),
|
||||||
|
metricB: t("preview.columns.metricB"),
|
||||||
|
metricC: t("preview.columns.metricC"),
|
||||||
|
status: t("preview.columns.status"),
|
||||||
|
extra: t("preview.columns.extra"),
|
||||||
|
time: t("preview.columns.time"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [selectedReport.key, t]);
|
||||||
|
|
||||||
const exportFileBase = useMemo(() => {
|
const exportFileBase = useMemo(() => {
|
||||||
const segments: string[] = [selectedReport.key];
|
const segments: string[] = [selectedReport.key];
|
||||||
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
||||||
@@ -419,29 +557,6 @@ export function ReportsConsole() {
|
|||||||
return normalizeFilenamePart(segments.join("-")) || selectedReport.key;
|
return normalizeFilenamePart(segments.join("-")) || selectedReport.key;
|
||||||
}, [selectedReport.key, filters]);
|
}, [selectedReport.key, filters]);
|
||||||
|
|
||||||
const loadPlayOptions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
|
|
||||||
setPlayOptions(
|
|
||||||
getCachedAdminPlayTypes().map((item) => ({
|
|
||||||
code: item.play_code,
|
|
||||||
label: optionText(
|
|
||||||
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item),
|
|
||||||
item.play_code,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setPlayOptions([]);
|
|
||||||
}
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
void loadPlayOptions();
|
|
||||||
});
|
|
||||||
}, [loadPlayOptions]);
|
|
||||||
|
|
||||||
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
|
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
|
||||||
setSearch((prev) => ({ ...prev, loading: true }));
|
setSearch((prev) => ({ ...prev, loading: true }));
|
||||||
try {
|
try {
|
||||||
@@ -481,7 +596,11 @@ export function ReportsConsole() {
|
|||||||
try {
|
try {
|
||||||
switch (selectedReport.key) {
|
switch (selectedReport.key) {
|
||||||
case "draw_profit": {
|
case "draw_profit": {
|
||||||
const draw = await resolveDraw(filters, t);
|
const draw = await resolveDraw(filters, {
|
||||||
|
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||||
|
drawNoNotFound: (drawNo) =>
|
||||||
|
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||||
|
});
|
||||||
const summary = await getAdminDrawFinanceSummary(draw.id);
|
const summary = await getAdminDrawFinanceSummary(draw.id);
|
||||||
setResult({
|
setResult({
|
||||||
key: "draw_profit",
|
key: "draw_profit",
|
||||||
@@ -519,10 +638,10 @@ export function ReportsConsole() {
|
|||||||
meta: metaFromList(payload.meta),
|
meta: metaFromList(payload.meta),
|
||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(totalBet, "NPR") },
|
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||||
{
|
{
|
||||||
label: t("preview.stats.houseGross"),
|
label: pageScopedLabel("houseGross"),
|
||||||
value: formatPlainMoney(totalGross, "NPR"),
|
value: formatPlainMoney(totalGross, "NPR"),
|
||||||
tone: totalGross >= 0 ? "good" : "bad",
|
tone: totalGross >= 0 ? "good" : "bad",
|
||||||
},
|
},
|
||||||
@@ -548,7 +667,7 @@ export function ReportsConsole() {
|
|||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||||
{
|
{
|
||||||
label: t("preview.stats.houseGross"),
|
label: pageScopedLabel("houseGross"),
|
||||||
value: formatPlainMoney(
|
value: formatPlainMoney(
|
||||||
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
||||||
"NPR",
|
"NPR",
|
||||||
@@ -592,17 +711,21 @@ export function ReportsConsole() {
|
|||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.records"), value: String(payload.total) },
|
{ label: t("preview.stats.records"), value: String(payload.total) },
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||||
{ label: t("preview.stats.transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
|
{ label: pageScopedLabel("transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
|
||||||
{ label: t("preview.stats.transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
|
{ label: pageScopedLabel("transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "hot_number_risk": {
|
case "hot_number_risk": {
|
||||||
if (!filters.number.trim()) {
|
if (!filters.number.trim()) {
|
||||||
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
|
throw new LotteryApiBizError(tRef.current("validation.drawNoNumberRequired"), -1, null);
|
||||||
}
|
}
|
||||||
const draw = await resolveDraw(filters, t);
|
const draw = await resolveDraw(filters, {
|
||||||
|
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||||
|
drawNoNotFound: (drawNo) =>
|
||||||
|
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||||
|
});
|
||||||
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
||||||
const rows: ExportRow[] = [
|
const rows: ExportRow[] = [
|
||||||
{
|
{
|
||||||
@@ -642,14 +765,18 @@ export function ReportsConsole() {
|
|||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
|
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
|
||||||
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
|
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
|
||||||
{ label: t("preview.stats.usage"), value: detail.pool.usage_ratio == null ? "-" : `${detail.pool.usage_ratio}%`, tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
||||||
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "sold_out_number": {
|
case "sold_out_number": {
|
||||||
const draw = await resolveDraw(filters, t);
|
const draw = await resolveDraw(filters, {
|
||||||
|
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||||
|
drawNoNotFound: (drawNo) =>
|
||||||
|
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||||
|
});
|
||||||
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
||||||
const rows = payload.items.map((item) => ({
|
const rows = payload.items.map((item) => ({
|
||||||
draw_id: payload.draw_id,
|
draw_id: payload.draw_id,
|
||||||
@@ -695,8 +822,8 @@ export function ReportsConsole() {
|
|||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
|
{ label: pageScopedLabel("bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
|
||||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
|
{ label: pageScopedLabel("payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -717,8 +844,8 @@ export function ReportsConsole() {
|
|||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||||
{ label: t("preview.stats.rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
|
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
|
||||||
{ label: t("preview.stats.orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
|
{ label: pageScopedLabel("orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -761,15 +888,15 @@ export function ReportsConsole() {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setError(t("loadFailed"));
|
setError(tRef.current("loadFailed"));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setError(err instanceof LotteryApiBizError ? err.message : t("loadFailed"));
|
setError(err instanceof LotteryApiBizError ? err.message : tRef.current("loadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [canViewReports, filters, page, perPage, selectedReport, t]);
|
}, [canViewReports, filters, page, perPage, selectedReport]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -928,7 +1055,7 @@ export function ReportsConsole() {
|
|||||||
/>
|
/>
|
||||||
<div className="mt-2 max-h-64 overflow-auto">
|
<div className="mt-2 max-h-64 overflow-auto">
|
||||||
{search.loading ? (
|
{search.loading ? (
|
||||||
<p className="px-2 py-2 text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
<AdminLoadingInline className="py-2" />
|
||||||
) : null}
|
) : null}
|
||||||
{!search.loading && kind === "draw" ? (
|
{!search.loading && kind === "draw" ? (
|
||||||
search.draws.map((item) => (
|
search.draws.map((item) => (
|
||||||
@@ -1067,11 +1194,7 @@ export function ReportsConsole() {
|
|||||||
}
|
}
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<AdminTableLoadingRow colSpan={8} />
|
||||||
<TableCell colSpan={8} className="text-muted-foreground">
|
|
||||||
{t("states.loading", { ns: "common" })}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -1148,7 +1271,7 @@ export function ReportsConsole() {
|
|||||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
||||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
||||||
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||||
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
|
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
|
||||||
<TableCell>v{result.raw.pool.version}</TableCell>
|
<TableCell>v{result.raw.pool.version}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{result.raw.logs.items.map((item) => (
|
{result.raw.logs.items.map((item) => (
|
||||||
@@ -1176,7 +1299,7 @@ export function ReportsConsole() {
|
|||||||
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
|
||||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||||
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
|
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
|
||||||
<TableCell>v{item.version}</TableCell>
|
<TableCell>v{item.version}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
));
|
));
|
||||||
@@ -1201,7 +1324,10 @@ export function ReportsConsole() {
|
|||||||
return result.raw.map((item) => (
|
return result.raw.map((item) => (
|
||||||
<TableRow key={item.player_id}>
|
<TableRow key={item.player_id}>
|
||||||
<TableCell className="font-medium">{item.username}</TableCell>
|
<TableCell className="font-medium">{item.username}</TableCell>
|
||||||
<TableCell>ID {item.player_id}</TableCell>
|
<TableCell className="text-xs">
|
||||||
|
{adminAgentDisplayLabel(item)}
|
||||||
|
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
|
||||||
@@ -1305,6 +1431,13 @@ export function ReportsConsole() {
|
|||||||
<CardContent className="space-y-4 pt-4">
|
<CardContent className="space-y-4 pt-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{selectedReport.fields.map(renderField)}
|
{selectedReport.fields.map(renderField)}
|
||||||
|
{selectedReport.category === "profit" || selectedReport.category === "wallet" ? (
|
||||||
|
<AdminAgentFilter
|
||||||
|
id="report-agent-filter"
|
||||||
|
value={filters.agentNodeId}
|
||||||
|
onChange={(id) => setFilters((prev) => ({ ...prev, agentNodeId: id }))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
|
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
@@ -1395,17 +1528,20 @@ export function ReportsConsole() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pt-4">
|
<CardContent className="space-y-4 pt-4">
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950">
|
||||||
|
{t("preview.summaryScopeHint")}
|
||||||
|
</div>
|
||||||
<Table id="reports-preview-table">
|
<Table id="reports-preview-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("preview.columns.primary")}</TableHead>
|
<TableHead>{previewColumns.primary}</TableHead>
|
||||||
<TableHead>{t("preview.columns.secondary")}</TableHead>
|
<TableHead>{previewColumns.secondary}</TableHead>
|
||||||
<TableHead className="text-center">{t("preview.columns.metricA")}</TableHead>
|
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
||||||
<TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
|
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
||||||
<TableHead className="text-center">{t("preview.columns.metricC")}</TableHead>
|
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
||||||
<TableHead>{t("preview.columns.status")}</TableHead>
|
<TableHead>{previewColumns.status}</TableHead>
|
||||||
<TableHead>{t("preview.columns.extra")}</TableHead>
|
<TableHead>{previewColumns.extra}</TableHead>
|
||||||
<TableHead>{t("preview.columns.time")}</TableHead>
|
<TableHead>{previewColumns.time}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>{renderTable()}</TableBody>
|
<TableBody>{renderTable()}</TableBody>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminDraw } from "@/api/admin-draws";
|
import { getAdminDraw } from "@/api/admin-draws";
|
||||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display";
|
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display";
|
||||||
@@ -11,6 +14,7 @@ import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
|||||||
|
|
||||||
export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
||||||
const { t } = useTranslation(["risk", "draws"]);
|
const { t } = useTranslation(["risk", "draws"]);
|
||||||
|
const tRef = useTranslationRef(["risk", "draws"]);
|
||||||
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
|
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -21,24 +25,22 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
|||||||
setDraw(d);
|
setDraw(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : t("drawInfoLoadFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("drawInfoLoadFailed");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setDraw(null);
|
setDraw(null);
|
||||||
}
|
}
|
||||||
}, [drawId, t]);
|
}, [drawId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [drawId]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <p className="text-sm text-destructive">{error}</p>;
|
return <p className="text-sm text-destructive">{error}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!draw) {
|
if (!draw) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("loadingDraw")}</p>;
|
return <AdminLoadingInline className="py-4" label={t("loadingDraw")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Shield } from "lucide-react";
|
import { Shield } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminDraws } from "@/api/admin-draws";
|
import { getAdminDraws } from "@/api/admin-draws";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
@@ -13,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -48,6 +51,7 @@ const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
|
|||||||
|
|
||||||
export function RiskIndexConsole() {
|
export function RiskIndexConsole() {
|
||||||
const { t } = useTranslation(["risk", "common"]);
|
const { t } = useTranslation(["risk", "common"]);
|
||||||
|
const tRef = useTranslationRef(["risk", "common"]);
|
||||||
const exportLabels = useExportLabels("riskIndex");
|
const exportLabels = useExportLabels("riskIndex");
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||||
@@ -81,19 +85,17 @@ export function RiskIndexConsole() {
|
|||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : t("loadDrawListFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDrawListFailed");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, drawNoQuery, statusFilter, t]);
|
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
function applySearch(): void {
|
function applySearch(): void {
|
||||||
setDrawNoQuery(drawNoInput.trim());
|
setDrawNoQuery(drawNoInput.trim());
|
||||||
@@ -174,10 +176,7 @@ export function RiskIndexConsole() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<CardContent className="admin-list-content">
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
<div className="admin-table-shell">
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : (
|
|
||||||
<div className="admin-table-shell">
|
|
||||||
<Table id="risk-index-table">
|
<Table id="risk-index-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -188,7 +187,9 @@ export function RiskIndexConsole() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(data?.items ?? []).length === 0 ? (
|
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||||
|
<AdminTableLoadingRow colSpan={4} />
|
||||||
|
) : (data?.items ?? []).length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-muted-foreground">
|
<TableCell colSpan={4} className="text-muted-foreground">
|
||||||
{t("states.noData", { ns: "common" })}
|
{t("states.noData", { ns: "common" })}
|
||||||
@@ -222,7 +223,6 @@ export function RiskIndexConsole() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<AdminListPaginationFooter
|
<AdminListPaginationFooter
|
||||||
selectId="risk-index-draws-per-page"
|
selectId="risk-index-draws-per-page"
|
||||||
total={total}
|
total={total}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
@@ -11,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -48,6 +51,7 @@ function riskActionFilterLabel(
|
|||||||
|
|
||||||
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||||
const { t } = useTranslation(["risk", "common"]);
|
const { t } = useTranslation(["risk", "common"]);
|
||||||
|
const tRef = useTranslationRef(["risk", "common"]);
|
||||||
const exportLabels = useExportLabels("riskLockLogs");
|
const exportLabels = useExportLabels("riskLockLogs");
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const playCodeLabel = useAdminPlayCodeLabel();
|
const playCodeLabel = useAdminPlayCodeLabel();
|
||||||
@@ -79,19 +83,17 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
|||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : t("loadLogsFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("loadLogsFailed");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [drawId, page, perPage, appliedAction, appliedNumber, t]);
|
}, [drawId, page, perPage, appliedAction, appliedNumber]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [drawId, page, perPage, appliedAction, appliedNumber]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="admin-list-card">
|
<Card className="admin-list-card">
|
||||||
@@ -157,10 +159,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
|||||||
|
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
{loading && !data ? (
|
<>
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
<Table id={`risk-lock-logs-table-${drawId}`}>
|
<Table id={`risk-lock-logs-table-${drawId}`}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -175,6 +174,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||||
{(data?.items ?? []).map((row: AdminRiskLockLogRow) => (
|
{(data?.items ?? []).map((row: AdminRiskLockLogRow) => (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
@@ -214,7 +214,6 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminRiskPoolDetail } from "@/api/admin-risk";
|
import { getAdminRiskPoolDetail } from "@/api/admin-risk";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -35,6 +38,7 @@ export function RiskPoolDetailConsole({
|
|||||||
number4d: string;
|
number4d: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["risk", "common"]);
|
const { t } = useTranslation(["risk", "common"]);
|
||||||
|
const tRef = useTranslationRef(["risk", "common"]);
|
||||||
const exportLabels = useExportLabels("riskPoolDetail", { number: number4d });
|
const exportLabels = useExportLabels("riskPoolDetail", { number: number4d });
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const playCodeLabel = useAdminPlayCodeLabel();
|
const playCodeLabel = useAdminPlayCodeLabel();
|
||||||
@@ -53,19 +57,17 @@ export function RiskPoolDetailConsole({
|
|||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : t("loadDetailFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDetailFailed");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [drawId, number4d, page, perPage, t]);
|
}, [drawId, number4d, page, perPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [drawId, number4d, page, perPage]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (error && !data) {
|
if (error && !data) {
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +89,7 @@ export function RiskPoolDetailConsole({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye, Lock, Unlock } from "lucide-react";
|
import { Eye, Lock, Unlock } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -81,6 +84,7 @@ export function RiskPoolsConsole({
|
|||||||
allowSortChange = false,
|
allowSortChange = false,
|
||||||
}: RiskPoolsConsoleProps) {
|
}: RiskPoolsConsoleProps) {
|
||||||
const { t } = useTranslation(["risk", "common"]);
|
const { t } = useTranslation(["risk", "common"]);
|
||||||
|
const tRef = useTranslationRef(["risk", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [
|
const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [
|
||||||
@@ -115,19 +119,17 @@ export function RiskPoolsConsole({
|
|||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : t("loadPoolsFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("loadPoolsFailed");
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [drawId, filter, number, page, perPage, sort, t]);
|
}, [drawId, filter, number, page, perPage, sort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [drawId, filter, number, page, perPage, sort]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const handleManualStatus = useCallback(
|
const handleManualStatus = useCallback(
|
||||||
async (row: AdminRiskPoolRow) => {
|
async (row: AdminRiskPoolRow) => {
|
||||||
@@ -240,10 +242,7 @@ export function RiskPoolsConsole({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<CardContent className="admin-list-content">
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
{loading && !data ? (
|
<>
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
<Table id={`risk-pools-table-${drawId}`}>
|
<Table id={`risk-pools-table-${drawId}`}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -258,6 +257,7 @@ export function RiskPoolsConsole({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||||
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
|
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
|
||||||
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
|
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
|
||||||
const acting = actingNumber === row.normalized_number;
|
const acting = actingNumber === row.normalized_number;
|
||||||
@@ -359,7 +359,6 @@ export function RiskPoolsConsole({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -68,6 +70,7 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
|
|||||||
|
|
||||||
export function CurrencySettingsPanel() {
|
export function CurrencySettingsPanel() {
|
||||||
const { t } = useTranslation(["config", "adminUsers"]);
|
const { t } = useTranslation(["config", "adminUsers"]);
|
||||||
|
const tRef = useTranslationRef(["config", "adminUsers"]);
|
||||||
const exportLabels = useExportLabels("currencies");
|
const exportLabels = useExportLabels("currencies");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
|
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
|
||||||
@@ -96,18 +99,16 @@ export function CurrencySettingsPanel() {
|
|||||||
toast.error(
|
toast.error(
|
||||||
error instanceof LotteryApiBizError
|
error instanceof LotteryApiBizError
|
||||||
? error.message
|
? error.message
|
||||||
: t("currencies.loadFailed", { ns: "config" }),
|
: tRef.current("currencies.loadFailed", { ns: "config" }),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [canManage, t]);
|
}, [canManage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [canManage]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
setMode("create");
|
setMode("create");
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getAdminSettings,
|
|
||||||
updateAdminSetting,
|
|
||||||
} from "@/api/admin-settings";
|
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|
||||||
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
||||||
import { Button } from "@/components/ui/button";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
|
||||||
import { Label } from "@/components/ui/label";
|
import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const DRAW_GROUP = "draw";
|
function SystemSettingsContent() {
|
||||||
const SETTLEMENT_GROUP = "settlement";
|
const { t } = useTranslation(["config"]);
|
||||||
|
|
||||||
const DRAW_KEYS = {
|
|
||||||
DEFAULT_CURRENCY: "currency.default_code",
|
|
||||||
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
|
||||||
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
|
||||||
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
|
||||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
|
||||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
|
||||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
|
||||||
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
|
||||||
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
|
||||||
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
|
||||||
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
|
||||||
AUTO_APPROVE: "settlement.auto_approve_on_tick",
|
|
||||||
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
|
|
||||||
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const FRONTEND_GROUP = "frontend";
|
|
||||||
const FRONTEND_KEYS = {
|
|
||||||
PLAY_RULES_HTML: "frontend.play_rules_html",
|
|
||||||
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
|
|
||||||
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
|
|
||||||
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
interface RuntimeDraft {
|
|
||||||
defaultCurrency: string;
|
|
||||||
drawIntervalMinutes: string;
|
|
||||||
drawBettingWindowSeconds: string;
|
|
||||||
drawCloseBeforeDrawSeconds: string;
|
|
||||||
drawBufferDrawsAhead: string;
|
|
||||||
requireManualReview: boolean;
|
|
||||||
cooldownMinutes: string;
|
|
||||||
currencyDisplayDecimals: string;
|
|
||||||
currencyDecimalSeparator: string;
|
|
||||||
currencyThousandsSeparator: string;
|
|
||||||
autoSettlement: boolean;
|
|
||||||
autoApprove: boolean;
|
|
||||||
autoPayout: boolean;
|
|
||||||
applyRebateToPayout: boolean;
|
|
||||||
playRulesHtmlZh: string;
|
|
||||||
playRulesHtmlEn: string;
|
|
||||||
playRulesHtmlNe: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RUNTIME_DRAFT_KEYS = [
|
|
||||||
"defaultCurrency",
|
|
||||||
"drawIntervalMinutes",
|
|
||||||
"drawBettingWindowSeconds",
|
|
||||||
"drawCloseBeforeDrawSeconds",
|
|
||||||
"drawBufferDrawsAhead",
|
|
||||||
"requireManualReview",
|
|
||||||
"cooldownMinutes",
|
|
||||||
"currencyDisplayDecimals",
|
|
||||||
"currencyDecimalSeparator",
|
|
||||||
"currencyThousandsSeparator",
|
|
||||||
"autoSettlement",
|
|
||||||
"autoApprove",
|
|
||||||
"autoPayout",
|
|
||||||
"applyRebateToPayout",
|
|
||||||
] as const satisfies readonly (keyof RuntimeDraft)[];
|
|
||||||
|
|
||||||
const FRONTEND_DRAFT_KEYS = [
|
|
||||||
"playRulesHtmlZh",
|
|
||||||
"playRulesHtmlEn",
|
|
||||||
"playRulesHtmlNe",
|
|
||||||
] as const satisfies readonly (keyof RuntimeDraft)[];
|
|
||||||
|
|
||||||
function isSectionDirty<const K extends keyof RuntimeDraft>(
|
|
||||||
draft: RuntimeDraft,
|
|
||||||
saved: RuntimeDraft,
|
|
||||||
keys: readonly K[],
|
|
||||||
): boolean {
|
|
||||||
return keys.some((key) => draft[key] !== saved[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDraftFields<const K extends keyof RuntimeDraft>(
|
|
||||||
base: RuntimeDraft,
|
|
||||||
source: RuntimeDraft,
|
|
||||||
keys: readonly K[],
|
|
||||||
): RuntimeDraft {
|
|
||||||
const next = { ...base };
|
|
||||||
for (const key of keys) {
|
|
||||||
next[key] = source[key];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SaveActions({
|
|
||||||
dirty,
|
|
||||||
loading,
|
|
||||||
saving,
|
|
||||||
onSave,
|
|
||||||
onDiscard,
|
|
||||||
saveLabel,
|
|
||||||
savingLabel,
|
|
||||||
discardLabel,
|
|
||||||
}: {
|
|
||||||
dirty: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
saving: boolean;
|
|
||||||
onSave: () => void;
|
|
||||||
onDiscard: () => void;
|
|
||||||
saveLabel: string;
|
|
||||||
savingLabel: string;
|
|
||||||
discardLabel: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
|
||||||
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
|
|
||||||
{saving ? savingLabel : saveLabel}
|
|
||||||
</Button>
|
|
||||||
{dirty ? (
|
|
||||||
<Button type="button" variant="outline" onClick={onDiscard}>
|
|
||||||
{discardLabel}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SystemSettingsScreen() {
|
|
||||||
const { t } = useTranslation(["common", "config", "adminUsers"]);
|
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
|
||||||
const [draft, setDraft] = useState<RuntimeDraft>({
|
|
||||||
defaultCurrency: "NPR",
|
|
||||||
drawIntervalMinutes: "5",
|
|
||||||
drawBettingWindowSeconds: "270",
|
|
||||||
drawCloseBeforeDrawSeconds: "30",
|
|
||||||
drawBufferDrawsAhead: "8",
|
|
||||||
requireManualReview: false,
|
|
||||||
cooldownMinutes: "15",
|
|
||||||
currencyDisplayDecimals: "2",
|
|
||||||
currencyDecimalSeparator: ".",
|
|
||||||
currencyThousandsSeparator: ",",
|
|
||||||
autoSettlement: true,
|
|
||||||
autoApprove: true,
|
|
||||||
autoPayout: true,
|
|
||||||
applyRebateToPayout: false,
|
|
||||||
playRulesHtmlZh: "",
|
|
||||||
playRulesHtmlEn: "",
|
|
||||||
playRulesHtmlNe: "",
|
|
||||||
});
|
|
||||||
const [saved, setSaved] = useState<RuntimeDraft>({
|
|
||||||
defaultCurrency: "NPR",
|
|
||||||
drawIntervalMinutes: "5",
|
|
||||||
drawBettingWindowSeconds: "270",
|
|
||||||
drawCloseBeforeDrawSeconds: "30",
|
|
||||||
drawBufferDrawsAhead: "8",
|
|
||||||
requireManualReview: false,
|
|
||||||
cooldownMinutes: "15",
|
|
||||||
currencyDisplayDecimals: "2",
|
|
||||||
currencyDecimalSeparator: ".",
|
|
||||||
currencyThousandsSeparator: ",",
|
|
||||||
autoSettlement: true,
|
|
||||||
autoApprove: true,
|
|
||||||
autoPayout: true,
|
|
||||||
applyRebateToPayout: false,
|
|
||||||
playRulesHtmlZh: "",
|
|
||||||
playRulesHtmlEn: "",
|
|
||||||
playRulesHtmlNe: "",
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [savingRuntime, setSavingRuntime] = useState(false);
|
|
||||||
const [savingFrontend, setSavingFrontend] = useState(false);
|
|
||||||
|
|
||||||
const runtimeDirty = useMemo(
|
|
||||||
() => isSectionDirty(draft, saved, RUNTIME_DRAFT_KEYS),
|
|
||||||
[draft, saved],
|
|
||||||
);
|
|
||||||
const frontendDirty = useMemo(
|
|
||||||
() => isSectionDirty(draft, saved, FRONTEND_DRAFT_KEYS),
|
|
||||||
[draft, saved],
|
|
||||||
);
|
|
||||||
const anyDirty = runtimeDirty || frontendDirty;
|
|
||||||
const saving = savingRuntime || savingFrontend;
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [drawRes, settlementRes, frontendRes] = await Promise.all([
|
|
||||||
getAdminSettings(DRAW_GROUP),
|
|
||||||
getAdminSettings(SETTLEMENT_GROUP),
|
|
||||||
getAdminSettings(FRONTEND_GROUP),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const kv: Record<string, unknown> = {};
|
|
||||||
for (const item of [...drawRes.items, ...settlementRes.items, ...frontendRes.items]) {
|
|
||||||
kv[item.key] = item.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
|
|
||||||
const nextDraft: RuntimeDraft = {
|
|
||||||
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
|
||||||
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
|
||||||
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
|
||||||
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
|
||||||
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
|
||||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
|
||||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
|
||||||
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
|
||||||
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
|
||||||
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
|
||||||
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
|
|
||||||
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
|
|
||||||
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
|
|
||||||
applyRebateToPayout: Boolean(kv[DRAW_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
|
|
||||||
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
|
||||||
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
|
||||||
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
|
||||||
};
|
|
||||||
setDraft(nextDraft);
|
|
||||||
setSaved(nextDraft);
|
|
||||||
} catch {
|
|
||||||
toast.error(t("system.loadFailed", { ns: "config" }));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
void load();
|
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
|
|
||||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const discardSection = <const K extends keyof RuntimeDraft>(keys: readonly K[]) => {
|
|
||||||
setDraft((prev) => applyDraftFields(prev, saved, keys));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveRuntime = async () => {
|
|
||||||
setSavingRuntime(true);
|
|
||||||
try {
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.DEFAULT_CURRENCY,
|
|
||||||
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
|
||||||
);
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
|
||||||
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
|
||||||
);
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
|
||||||
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
|
||||||
);
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
|
||||||
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
|
||||||
);
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
|
||||||
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
|
||||||
);
|
|
||||||
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
|
||||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
|
||||||
);
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
|
||||||
Math.max(0, Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2)),
|
|
||||||
);
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
|
||||||
(draft.currencyDecimalSeparator || ".").slice(0, 1),
|
|
||||||
);
|
|
||||||
await updateAdminSetting(
|
|
||||||
DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
|
||||||
(draft.currencyThousandsSeparator || ",").slice(0, 1),
|
|
||||||
);
|
|
||||||
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
|
|
||||||
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
|
|
||||||
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
|
|
||||||
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
|
|
||||||
toast.success(t("system.saveRuntimeSuccess", { ns: "config" }));
|
|
||||||
setSaved((prev) => applyDraftFields(prev, draft, RUNTIME_DRAFT_KEYS));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setSavingRuntime(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveFrontend = async () => {
|
|
||||||
setSavingFrontend(true);
|
|
||||||
try {
|
|
||||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
|
|
||||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
|
|
||||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
|
|
||||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
|
|
||||||
toast.success(t("system.saveFrontendSuccess", { ns: "config" }));
|
|
||||||
setSaved((prev) => applyDraftFields(prev, draft, FRONTEND_DRAFT_KEYS));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setSavingFrontend(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveLabel = t("actions.save", { ns: "adminUsers" });
|
|
||||||
const savingLabel = t("saving", { ns: "adminUsers" });
|
|
||||||
const discardLabel = t("system.discard", { ns: "config" });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<div className="flex w-full max-w-none flex-col gap-6">
|
||||||
{anyDirty ? (
|
<DrawSettingsPanel />
|
||||||
<div className="sticky top-0 z-20 -mx-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 shadow-sm backdrop-blur-sm">
|
<CurrencyFormatSettingsPanel />
|
||||||
<p className="text-sm font-medium text-amber-950 dark:text-amber-100">
|
<SettlementSettingsPanel />
|
||||||
{t("system.unsavedChanges", { ns: "config" })}
|
|
||||||
{runtimeDirty && frontendDirty
|
|
||||||
? ` · ${t("system.title", { ns: "config" })} / ${t("system.frontendConfig", { ns: "config" })}`
|
|
||||||
: runtimeDirty
|
|
||||||
? ` · ${t("system.title", { ns: "config" })}`
|
|
||||||
: ` · ${t("system.frontendConfig", { ns: "config" })}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AdminPageCard
|
|
||||||
title={t("system.title", { ns: "config" })}
|
|
||||||
description={t("system.description", { ns: "config" })}
|
|
||||||
>
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
|
||||||
<Switch
|
|
||||||
checked={draft.requireManualReview}
|
|
||||||
disabled={loading || saving}
|
|
||||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
|
||||||
onCheckedChange={(value) => updateDraft("requireManualReview", value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="default-currency" className="text-sm font-medium">
|
|
||||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="default-currency"
|
|
||||||
value={draft.defaultCurrency}
|
|
||||||
onChange={(e) => updateDraft("defaultCurrency", e.target.value.toUpperCase())}
|
|
||||||
disabled={loading || saving}
|
|
||||||
maxLength={16}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
|
|
||||||
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="draw-interval-minutes"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="1440"
|
|
||||||
step="1"
|
|
||||||
value={draft.drawIntervalMinutes}
|
|
||||||
onChange={(e) => updateDraft("drawIntervalMinutes", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
|
|
||||||
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="draw-betting-window-seconds"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
step="1"
|
|
||||||
value={draft.drawBettingWindowSeconds}
|
|
||||||
onChange={(e) => updateDraft("drawBettingWindowSeconds", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
|
|
||||||
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="draw-close-before-seconds"
|
|
||||||
type="number"
|
|
||||||
min="5"
|
|
||||||
step="1"
|
|
||||||
value={draft.drawCloseBeforeDrawSeconds}
|
|
||||||
onChange={(e) => updateDraft("drawCloseBeforeDrawSeconds", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
|
|
||||||
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="draw-buffer-ahead"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
value={draft.drawBufferDrawsAhead}
|
|
||||||
onChange={(e) => updateDraft("drawBufferDrawsAhead", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
|
||||||
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="currency-display-decimals"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="12"
|
|
||||||
step="1"
|
|
||||||
value={draft.currencyDisplayDecimals}
|
|
||||||
onChange={(e) => updateDraft("currencyDisplayDecimals", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
|
||||||
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="currency-decimal-separator"
|
|
||||||
value={draft.currencyDecimalSeparator}
|
|
||||||
onChange={(e) => updateDraft("currencyDecimalSeparator", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
maxLength={1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
|
||||||
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="currency-thousands-separator"
|
|
||||||
value={draft.currencyThousandsSeparator}
|
|
||||||
onChange={(e) => updateDraft("currencyThousandsSeparator", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
maxLength={1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
|
||||||
<Switch
|
|
||||||
checked={draft.autoSettlement}
|
|
||||||
disabled={loading || saving}
|
|
||||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
|
||||||
onCheckedChange={(value) => updateDraft("autoSettlement", value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
|
||||||
<Switch
|
|
||||||
checked={draft.autoApprove}
|
|
||||||
disabled={loading || saving}
|
|
||||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
|
||||||
onCheckedChange={(value) => updateDraft("autoApprove", value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
|
||||||
<Switch
|
|
||||||
checked={draft.autoPayout}
|
|
||||||
disabled={loading || saving}
|
|
||||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
|
||||||
onCheckedChange={(value) => updateDraft("autoPayout", value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0 space-y-1 pr-4">
|
|
||||||
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={draft.applyRebateToPayout}
|
|
||||||
disabled={loading || saving}
|
|
||||||
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
|
||||||
onCheckedChange={(value) => updateDraft("applyRebateToPayout", value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
|
||||||
|
|
||||||
<div className="grid max-w-xs gap-2">
|
|
||||||
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
|
||||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="cooldown-minutes"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
value={draft.cooldownMinutes}
|
|
||||||
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SaveActions
|
|
||||||
dirty={runtimeDirty}
|
|
||||||
loading={loading}
|
|
||||||
saving={savingRuntime}
|
|
||||||
onSave={() =>
|
|
||||||
requestConfirm({
|
|
||||||
title: t("system.confirmSaveRuntimeTitle", { ns: "config" }),
|
|
||||||
description: t("system.confirmSaveRuntimeDescription", { ns: "config" }),
|
|
||||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
|
||||||
onConfirm: () => handleSaveRuntime(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onDiscard={() => discardSection(RUNTIME_DRAFT_KEYS)}
|
|
||||||
saveLabel={saveLabel}
|
|
||||||
savingLabel={savingLabel}
|
|
||||||
discardLabel={discardLabel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AdminPageCard>
|
|
||||||
|
|
||||||
<AdminPageCard
|
<AdminPageCard
|
||||||
title={t("wallet.title", { ns: "config" })}
|
title={t("wallet.title", { ns: "config" })}
|
||||||
@@ -571,73 +25,15 @@ export function SystemSettingsScreen() {
|
|||||||
<WalletConfigDocScreen embedded />
|
<WalletConfigDocScreen embedded />
|
||||||
</AdminPageCard>
|
</AdminPageCard>
|
||||||
|
|
||||||
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
<FrontendSettingsPanel />
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label className="text-sm font-medium">
|
|
||||||
{t("system.fields.playRulesHtml", { ns: "config" })}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
|
||||||
</p>
|
|
||||||
<Tabs defaultValue="zh" className="w-full">
|
|
||||||
<TabsList className="w-full max-w-md">
|
|
||||||
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
|
||||||
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
|
||||||
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="zh" className="mt-3">
|
|
||||||
<Textarea
|
|
||||||
id="play-rules-html-zh"
|
|
||||||
value={draft.playRulesHtmlZh}
|
|
||||||
onChange={(e) => updateDraft("playRulesHtmlZh", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
className="min-h-[200px] font-mono text-xs"
|
|
||||||
placeholder="<div>...</div>"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="en" className="mt-3">
|
|
||||||
<Textarea
|
|
||||||
id="play-rules-html-en"
|
|
||||||
value={draft.playRulesHtmlEn}
|
|
||||||
onChange={(e) => updateDraft("playRulesHtmlEn", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
className="min-h-[200px] font-mono text-xs"
|
|
||||||
placeholder="<div>...</div>"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="ne" className="mt-3">
|
|
||||||
<Textarea
|
|
||||||
id="play-rules-html-ne"
|
|
||||||
value={draft.playRulesHtmlNe}
|
|
||||||
onChange={(e) => updateDraft("playRulesHtmlNe", e.target.value)}
|
|
||||||
disabled={loading || saving}
|
|
||||||
className="min-h-[200px] font-mono text-xs"
|
|
||||||
placeholder="<div>...</div>"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<SaveActions
|
|
||||||
dirty={frontendDirty}
|
|
||||||
loading={loading}
|
|
||||||
saving={savingFrontend}
|
|
||||||
onSave={() =>
|
|
||||||
requestConfirm({
|
|
||||||
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
|
|
||||||
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
|
|
||||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
|
||||||
onConfirm: () => handleSaveFrontend(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onDiscard={() => discardSection(FRONTEND_DRAFT_KEYS)}
|
|
||||||
saveLabel={saveLabel}
|
|
||||||
savingLabel={savingLabel}
|
|
||||||
discardLabel={discardLabel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AdminPageCard>
|
|
||||||
|
|
||||||
<ConfirmDialog />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SystemSettingsScreen() {
|
||||||
|
return (
|
||||||
|
<AdminSettingsDataProvider>
|
||||||
|
<SystemSettingsContent />
|
||||||
|
</AdminSettingsDataProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +16,8 @@ import {
|
|||||||
postAdminRejectSettlementBatch,
|
postAdminRejectSettlementBatch,
|
||||||
} from "@/api/admin-settlement";
|
} from "@/api/admin-settlement";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
|
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||||
|
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
@@ -37,6 +41,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
@@ -72,6 +77,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
|
|||||||
|
|
||||||
export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||||
const { t } = useTranslation(["settlement", "common"]);
|
const { t } = useTranslation(["settlement", "common"]);
|
||||||
|
const tRef = useTranslationRef(["settlement", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const playCodeLabel = useAdminPlayCodeLabel();
|
const playCodeLabel = useAdminPlayCodeLabel();
|
||||||
@@ -84,6 +90,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
|
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
|
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
const [acting, setActing] = useState<string | null>(null);
|
const [acting, setActing] = useState<string | null>(null);
|
||||||
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
||||||
const [reviewRemark, setReviewRemark] = useState("");
|
const [reviewRemark, setReviewRemark] = useState("");
|
||||||
@@ -95,18 +103,22 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
try {
|
try {
|
||||||
const [s, d] = await Promise.all([
|
const [s, d] = await Promise.all([
|
||||||
getAdminSettlementBatch(batchId),
|
getAdminSettlementBatch(batchId),
|
||||||
getAdminSettlementBatchDetails(batchId, { page, per_page: perPage }),
|
getAdminSettlementBatchDetails(batchId, {
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
agent_node_id: appliedAgentNodeId,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
setSummary(s);
|
setSummary(s);
|
||||||
setDetails(d);
|
setDetails(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||||
setSummary(null);
|
setSummary(null);
|
||||||
setDetails(null);
|
setDetails(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [batchId, page, perPage, t]);
|
}, [batchId, page, perPage, appliedAgentNodeId]);
|
||||||
|
|
||||||
async function runAction(label: string, action: () => Promise<unknown>): Promise<void> {
|
async function runAction(label: string, action: () => Promise<unknown>): Promise<void> {
|
||||||
setActing(label);
|
setActing(label);
|
||||||
@@ -173,10 +185,9 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const t = window.setTimeout(() => void load(), 0);
|
void load();
|
||||||
return () => window.clearTimeout(t);
|
}, [batchId, page, perPage, appliedAgentNodeId]);
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold>
|
<ModuleScaffold>
|
||||||
@@ -322,7 +333,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<p className="text-muted-foreground text-sm">{t("loadingSummary")}</p>
|
<AdminLoadingState minHeight="6rem" className="py-4" label={t("loadingSummary")} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -332,11 +343,30 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{details ? (
|
{details ? (
|
||||||
<>
|
<>
|
||||||
|
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||||
|
<AdminAgentFilter
|
||||||
|
id="settlement-details-agent-filter"
|
||||||
|
className="w-[14rem]"
|
||||||
|
value={agentNodeId}
|
||||||
|
onChange={setAgentNodeId}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setAppliedAgentNodeId(agentNodeId);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("search", { ns: "common", defaultValue: "Search" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Table id={`settlement-details-table-${batchId}`}>
|
<Table id={`settlement-details-table-${batchId}`}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("ticketNo")}</TableHead>
|
<TableHead>{t("ticketNo")}</TableHead>
|
||||||
<TableHead>{t("playCode")}</TableHead>
|
<TableHead>{t("playCode")}</TableHead>
|
||||||
|
<AdminAgentIdentityHeads />
|
||||||
<AdminPlayerIdentityHeads />
|
<AdminPlayerIdentityHeads />
|
||||||
<TableHead>{t("matchedTier")}</TableHead>
|
<TableHead>{t("matchedTier")}</TableHead>
|
||||||
<TableHead className="text-center">{t("regularPayout")}</TableHead>
|
<TableHead className="text-center">{t("regularPayout")}</TableHead>
|
||||||
@@ -348,6 +378,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
<TableRow key={r.id}>
|
<TableRow key={r.id}>
|
||||||
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
||||||
<TableCell className="text-xs">{playCodeLabel(r.play_code)}</TableCell>
|
<TableCell className="text-xs">{playCodeLabel(r.play_code)}</TableCell>
|
||||||
|
<AdminAgentIdentityCells row={r} />
|
||||||
<AdminPlayerIdentityCells row={r} />
|
<AdminPlayerIdentityCells row={r} />
|
||||||
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
|
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
|
||||||
<TableCell className="text-center font-mono text-xs tabular-nums">
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||||
@@ -379,7 +410,11 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{loading ? t("loadingDetails") : t("states.noData", { ns: "common" })}
|
{loading ? (
|
||||||
|
<AdminLoadingInline label={t("loadingDetails")} />
|
||||||
|
) : (
|
||||||
|
t("states.noData", { ns: "common" })
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Eye, HandCoins, X } from "lucide-react";
|
import { Check, Eye, HandCoins, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
postAdminPayoutSettlementBatch,
|
postAdminPayoutSettlementBatch,
|
||||||
postAdminRejectSettlementBatch,
|
postAdminRejectSettlementBatch,
|
||||||
} from "@/api/admin-settlement";
|
} from "@/api/admin-settlement";
|
||||||
|
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
@@ -45,6 +48,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
@@ -87,6 +91,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
|
|||||||
|
|
||||||
export function SettlementBatchesConsole() {
|
export function SettlementBatchesConsole() {
|
||||||
const { t } = useTranslation(["settlement", "common"]);
|
const { t } = useTranslation(["settlement", "common"]);
|
||||||
|
const tRef = useTranslationRef(["settlement", "common"]);
|
||||||
const exportLabels = useExportLabels("settlementBatches");
|
const exportLabels = useExportLabels("settlementBatches");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
@@ -99,6 +104,8 @@ export function SettlementBatchesConsole() {
|
|||||||
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
||||||
const [draftStatus, setDraftStatus] = useState(STATUS_ALL);
|
const [draftStatus, setDraftStatus] = useState(STATUS_ALL);
|
||||||
const [appliedStatus, setAppliedStatus] = useState(STATUS_ALL);
|
const [appliedStatus, setAppliedStatus] = useState(STATUS_ALL);
|
||||||
|
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
|
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
const [actingId, setActingId] = useState<number | null>(null);
|
const [actingId, setActingId] = useState<number | null>(null);
|
||||||
@@ -117,24 +124,25 @@ export function SettlementBatchesConsole() {
|
|||||||
appliedStatus === STATUS_ALL || appliedStatus.trim() === ""
|
appliedStatus === STATUS_ALL || appliedStatus.trim() === ""
|
||||||
? undefined
|
? undefined
|
||||||
: appliedStatus.trim(),
|
: appliedStatus.trim(),
|
||||||
|
agent_node_id: appliedAgentNodeId,
|
||||||
});
|
});
|
||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
|
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
const t = window.setTimeout(() => void load(), 0);
|
void load();
|
||||||
return () => window.clearTimeout(t);
|
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
setAppliedDrawNo(draftDrawNo);
|
setAppliedDrawNo(draftDrawNo);
|
||||||
setAppliedStatus(draftStatus);
|
setAppliedStatus(draftStatus);
|
||||||
|
setAppliedAgentNodeId(agentNodeId);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,6 +201,12 @@ export function SettlementBatchesConsole() {
|
|||||||
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
|
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-list-toolbar">
|
<div className="admin-list-toolbar">
|
||||||
|
<AdminAgentFilter
|
||||||
|
id="settlement-batches-agent-filter"
|
||||||
|
className="admin-list-field sm:w-[14rem]"
|
||||||
|
value={agentNodeId}
|
||||||
|
onChange={setAgentNodeId}
|
||||||
|
/>
|
||||||
<div className="admin-list-field">
|
<div className="admin-list-field">
|
||||||
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
|
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
|
||||||
{t("drawNo")}
|
{t("drawNo")}
|
||||||
@@ -234,10 +248,7 @@ export function SettlementBatchesConsole() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content pt-0">
|
<CardContent className="admin-list-content pt-0">
|
||||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||||
{loading && !data ? (
|
<div className="admin-table-shell">
|
||||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : (
|
|
||||||
<div className="admin-table-shell">
|
|
||||||
<Table id="settlement-batches-table">
|
<Table id="settlement-batches-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -253,6 +264,7 @@ export function SettlementBatchesConsole() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{loading && !data ? <AdminTableLoadingRow colSpan={9} /> : null}
|
||||||
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||||
@@ -333,7 +345,6 @@ export function SettlementBatchesConsole() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{data ? (
|
{data ? (
|
||||||
<AdminListPaginationFooter
|
<AdminListPaginationFooter
|
||||||
selectId="settlement-batches-per-page"
|
selectId="settlement-batches-per-page"
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
|
||||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
|
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||||
|
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
@@ -21,6 +25,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -58,6 +63,7 @@ const TICKET_STATUS_OPTIONS = [
|
|||||||
|
|
||||||
type TicketFilters = {
|
type TicketFilters = {
|
||||||
siteCode: string;
|
siteCode: string;
|
||||||
|
agentNodeId: number | undefined;
|
||||||
playerQuery: string;
|
playerQuery: string;
|
||||||
drawNo: string;
|
drawNo: string;
|
||||||
numberKeyword: string;
|
numberKeyword: string;
|
||||||
@@ -68,6 +74,7 @@ type TicketFilters = {
|
|||||||
|
|
||||||
const emptyTicketFilters: TicketFilters = {
|
const emptyTicketFilters: TicketFilters = {
|
||||||
siteCode: "",
|
siteCode: "",
|
||||||
|
agentNodeId: undefined,
|
||||||
playerQuery: "",
|
playerQuery: "",
|
||||||
drawNo: "",
|
drawNo: "",
|
||||||
numberKeyword: "",
|
numberKeyword: "",
|
||||||
@@ -101,6 +108,7 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
|||||||
|
|
||||||
export function PlayerTicketsConsole(): React.ReactElement {
|
export function PlayerTicketsConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["tickets", "common"]);
|
const { t } = useTranslation(["tickets", "common"]);
|
||||||
|
const tRef = useTranslationRef(["tickets", "common"]);
|
||||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||||
const playCodeLabel = useAdminPlayCodeLabel();
|
const playCodeLabel = useAdminPlayCodeLabel();
|
||||||
const exportLabels = useExportLabels("tickets");
|
const exportLabels = useExportLabels("tickets");
|
||||||
@@ -131,6 +139,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
...query,
|
...query,
|
||||||
site_code: applied.siteCode.trim() || undefined,
|
site_code: applied.siteCode.trim() || undefined,
|
||||||
|
agent_node_id: applied.agentNodeId,
|
||||||
draw_no: applied.drawNo.trim() || undefined,
|
draw_no: applied.drawNo.trim() || undefined,
|
||||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||||
number: applied.numberKeyword.trim() || undefined,
|
number: applied.numberKeyword.trim() || undefined,
|
||||||
@@ -139,24 +148,23 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [applied, page, perPage, t]);
|
}, [applied, page, perPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [applied, page, perPage]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const runSearch = () => {
|
const runSearch = () => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setApplied({
|
setApplied({
|
||||||
...draft,
|
...draft,
|
||||||
siteCode: draft.siteCode.trim(),
|
siteCode: draft.siteCode.trim(),
|
||||||
|
agentNodeId: draft.agentNodeId,
|
||||||
playerQuery: draft.playerQuery.trim(),
|
playerQuery: draft.playerQuery.trim(),
|
||||||
drawNo: draft.drawNo.trim(),
|
drawNo: draft.drawNo.trim(),
|
||||||
numberKeyword: draft.numberKeyword.trim(),
|
numberKeyword: draft.numberKeyword.trim(),
|
||||||
@@ -222,6 +230,12 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<AdminAgentFilter
|
||||||
|
id="tickets-agent-filter"
|
||||||
|
className="admin-list-field sm:w-[14rem]"
|
||||||
|
value={draft.agentNodeId}
|
||||||
|
onChange={(id) => setDraft((current) => ({ ...current, agentNodeId: id }))}
|
||||||
|
/>
|
||||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||||
<Label htmlFor="pt-player" className="sm:shrink-0">
|
<Label htmlFor="pt-player" className="sm:shrink-0">
|
||||||
{t("playerId")}
|
{t("playerId")}
|
||||||
@@ -344,17 +358,14 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||||
{loading ? (
|
{loading || data ? (
|
||||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="admin-table-shell">
|
<div className="admin-table-shell">
|
||||||
<Table id="tickets-table">
|
<Table id="tickets-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("ticketNo")}</TableHead>
|
<TableHead>{t("ticketNo")}</TableHead>
|
||||||
|
<AdminAgentIdentityHeads />
|
||||||
<AdminPlayerIdentityHeads />
|
<AdminPlayerIdentityHeads />
|
||||||
<TableHead>{t("orderNo")}</TableHead>
|
<TableHead>{t("orderNo")}</TableHead>
|
||||||
<TableHead>{t("drawNo")}</TableHead>
|
<TableHead>{t("drawNo")}</TableHead>
|
||||||
@@ -370,9 +381,11 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{loading && !data ? (
|
||||||
|
<AdminTableLoadingRow colSpan={16} />
|
||||||
|
) : !data || data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={15} className="text-muted-foreground">
|
<TableCell colSpan={16} className="text-muted-foreground">
|
||||||
{t("states.noData", { ns: "common" })}
|
{t("states.noData", { ns: "common" })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -384,6 +397,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<TableRow key={row.ticket_no}>
|
<TableRow key={row.ticket_no}>
|
||||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||||
|
<AdminAgentIdentityCells row={row} />
|
||||||
<AdminPlayerIdentityCells row={row} />
|
<AdminPlayerIdentityCells row={row} />
|
||||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||||
@@ -413,19 +427,21 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<AdminListPaginationFooter
|
{data ? (
|
||||||
selectId="player-tickets-per-page"
|
<AdminListPaginationFooter
|
||||||
total={data.total}
|
selectId="player-tickets-per-page"
|
||||||
page={data.page}
|
total={data.total}
|
||||||
lastPage={Math.max(1, data.last_page)}
|
page={data.page}
|
||||||
perPage={data.per_page}
|
lastPage={Math.max(1, data.last_page)}
|
||||||
loading={loading}
|
perPage={data.per_page}
|
||||||
onPerPageChange={(n) => {
|
loading={loading}
|
||||||
setPerPage(n);
|
onPerPageChange={(n) => {
|
||||||
setPage(1);
|
setPerPage(n);
|
||||||
}}
|
setPage(1);
|
||||||
onPageChange={setPage}
|
}}
|
||||||
/>
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Copy, RotateCcw, Wrench } from "lucide-react";
|
import { Copy, RotateCcw, Wrench } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +17,8 @@ import {
|
|||||||
} from "@/api/admin-wallet";
|
} from "@/api/admin-wallet";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
|
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||||
|
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
@@ -24,6 +28,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -125,6 +130,7 @@ function statusLabelT(status: string, t: (key: string) => string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TransferFilters = {
|
type TransferFilters = {
|
||||||
|
agentNodeId: number | undefined;
|
||||||
playerId: string;
|
playerId: string;
|
||||||
playerAccount: string;
|
playerAccount: string;
|
||||||
transferNo: string;
|
transferNo: string;
|
||||||
@@ -136,6 +142,7 @@ type TransferFilters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emptyTransferFilters: TransferFilters = {
|
const emptyTransferFilters: TransferFilters = {
|
||||||
|
agentNodeId: undefined,
|
||||||
playerId: "",
|
playerId: "",
|
||||||
playerAccount: "",
|
playerAccount: "",
|
||||||
transferNo: "",
|
transferNo: "",
|
||||||
@@ -147,6 +154,7 @@ const emptyTransferFilters: TransferFilters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TxnFilters = {
|
type TxnFilters = {
|
||||||
|
agentNodeId: number | undefined;
|
||||||
playerId: string;
|
playerId: string;
|
||||||
playerAccount: string;
|
playerAccount: string;
|
||||||
txnNo: string;
|
txnNo: string;
|
||||||
@@ -159,6 +167,7 @@ type TxnFilters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emptyTxnFilters: TxnFilters = {
|
const emptyTxnFilters: TxnFilters = {
|
||||||
|
agentNodeId: undefined,
|
||||||
playerId: "",
|
playerId: "",
|
||||||
playerAccount: "",
|
playerAccount: "",
|
||||||
txnNo: "",
|
txnNo: "",
|
||||||
@@ -306,6 +315,7 @@ function TransferOrderRowActions({
|
|||||||
|
|
||||||
export function TransferOrdersPanel(): React.ReactElement {
|
export function TransferOrdersPanel(): React.ReactElement {
|
||||||
const { t } = useTranslation(["wallet", "common"]);
|
const { t } = useTranslation(["wallet", "common"]);
|
||||||
|
const tRef = useTranslationRef(["wallet", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
|
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
|
||||||
@@ -386,21 +396,20 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
created_from: applied.createdFrom.trim() || undefined,
|
created_from: applied.createdFrom.trim() || undefined,
|
||||||
created_to: applied.createdTo.trim() || undefined,
|
created_to: applied.createdTo.trim() || undefined,
|
||||||
status: applied.statusCsv.trim() || undefined,
|
status: applied.statusCsv.trim() || undefined,
|
||||||
|
agent_node_id: applied.agentNodeId,
|
||||||
});
|
});
|
||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, applied, t]);
|
}, [page, perPage, applied]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [page, perPage, applied]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const runSearch = () => {
|
const runSearch = () => {
|
||||||
setApplied({ ...draft });
|
setApplied({ ...draft });
|
||||||
@@ -421,6 +430,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<AdminAgentFilter
|
||||||
|
id="transfer-agent-filter"
|
||||||
|
value={draft.agentNodeId}
|
||||||
|
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
|
||||||
|
/>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
|
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -531,11 +545,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
{loading && !data ? (
|
{(loading && !data) || data ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table id="wallet-transfer-orders-table" className="table-fixed">
|
<Table id="wallet-transfer-orders-table" className="table-fixed">
|
||||||
@@ -543,6 +553,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
|
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
|
||||||
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
||||||
|
<AdminAgentIdentityHeads />
|
||||||
<AdminPlayerIdentityHeads />
|
<AdminPlayerIdentityHeads />
|
||||||
<TableHead className="w-14">{t("direction")}</TableHead>
|
<TableHead className="w-14">{t("direction")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
||||||
@@ -554,9 +565,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{loading && !data ? (
|
||||||
|
<AdminTableLoadingRow colSpan={13} />
|
||||||
|
) : !data || data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={12} className="text-muted-foreground">
|
<TableCell colSpan={13} className="text-muted-foreground">
|
||||||
{t("states.noData", { ns: "common" })}
|
{t("states.noData", { ns: "common" })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -569,6 +582,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
|
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<AdminAgentIdentityCells row={row} />
|
||||||
<AdminPlayerIdentityCells row={row} />
|
<AdminPlayerIdentityCells row={row} />
|
||||||
<TableCell>{row.direction}</TableCell>
|
<TableCell>{row.direction}</TableCell>
|
||||||
<TableCell className="tabular-nums">
|
<TableCell className="tabular-nums">
|
||||||
@@ -605,19 +619,21 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<AdminListPaginationFooter
|
{data ? (
|
||||||
selectId="wallet-transfer-orders-per-page"
|
<AdminListPaginationFooter
|
||||||
total={data.total}
|
selectId="wallet-transfer-orders-per-page"
|
||||||
page={page}
|
total={data.total}
|
||||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
page={page}
|
||||||
perPage={perPage}
|
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||||
loading={loading}
|
perPage={perPage}
|
||||||
onPerPageChange={(next) => {
|
loading={loading}
|
||||||
setPerPage(next);
|
onPerPageChange={(next) => {
|
||||||
setPage(1);
|
setPerPage(next);
|
||||||
}}
|
setPage(1);
|
||||||
onPageChange={setPage}
|
}}
|
||||||
/>
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -629,6 +645,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
|
|
||||||
export function WalletTxnsPanel(): React.ReactElement {
|
export function WalletTxnsPanel(): React.ReactElement {
|
||||||
const { t } = useTranslation(["wallet", "common"]);
|
const { t } = useTranslation(["wallet", "common"]);
|
||||||
|
const tRef = useTranslationRef(["wallet", "common"]);
|
||||||
const exportLabels = useExportLabels("walletTransactions");
|
const exportLabels = useExportLabels("walletTransactions");
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
||||||
@@ -660,21 +677,20 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
created_to: applied.createdTo.trim() || undefined,
|
created_to: applied.createdTo.trim() || undefined,
|
||||||
biz_type: applied.bizType.trim() || undefined,
|
biz_type: applied.bizType.trim() || undefined,
|
||||||
status: applied.statusCsv.trim() || undefined,
|
status: applied.statusCsv.trim() || undefined,
|
||||||
|
agent_node_id: applied.agentNodeId,
|
||||||
});
|
});
|
||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, applied, t]);
|
}, [page, perPage, applied]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAsyncEffect(() => {
|
||||||
queueMicrotask(() => {
|
void load();
|
||||||
void load();
|
}, [page, perPage, applied]);
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const runSearch = () => {
|
const runSearch = () => {
|
||||||
setApplied({ ...draft });
|
setApplied({ ...draft });
|
||||||
@@ -694,6 +710,11 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<AdminAgentFilter
|
||||||
|
id="wallet-txn-agent-filter"
|
||||||
|
value={draft.agentNodeId}
|
||||||
|
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
|
||||||
|
/>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="tx-no">{t("txnNo")}</Label>
|
<Label htmlFor="tx-no">{t("txnNo")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -835,11 +856,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||||
{loading && !data ? (
|
{(loading && !data) || data ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table id="wallet-transactions-table" className="table-fixed">
|
<Table id="wallet-transactions-table" className="table-fixed">
|
||||||
@@ -847,6 +864,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
|
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
|
||||||
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
||||||
|
<AdminAgentIdentityHeads />
|
||||||
<AdminPlayerIdentityHeads />
|
<AdminPlayerIdentityHeads />
|
||||||
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
||||||
@@ -856,9 +874,11 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{loading && !data ? (
|
||||||
|
<AdminTableLoadingRow colSpan={11} />
|
||||||
|
) : !data || data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={10} className="text-muted-foreground">
|
<TableCell colSpan={11} className="text-muted-foreground">
|
||||||
{t("states.noData", { ns: "common" })}
|
{t("states.noData", { ns: "common" })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -871,6 +891,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
|
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<AdminAgentIdentityCells row={row} />
|
||||||
<AdminPlayerIdentityCells row={row} />
|
<AdminPlayerIdentityCells row={row} />
|
||||||
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
|
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
|
||||||
<TableCell className="tabular-nums text-xs">
|
<TableCell className="tabular-nums text-xs">
|
||||||
@@ -891,19 +912,21 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<AdminListPaginationFooter
|
{data ? (
|
||||||
selectId="wallet-transactions-per-page"
|
<AdminListPaginationFooter
|
||||||
total={data.total}
|
selectId="wallet-transactions-per-page"
|
||||||
page={page}
|
total={data.total}
|
||||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
page={page}
|
||||||
perPage={perPage}
|
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||||
loading={loading}
|
perPage={perPage}
|
||||||
onPerPageChange={(next) => {
|
loading={loading}
|
||||||
setPerPage(next);
|
onPerPageChange={(next) => {
|
||||||
setPage(1);
|
setPerPage(next);
|
||||||
}}
|
setPage(1);
|
||||||
onPageChange={setPage}
|
}}
|
||||||
/>
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -913,6 +936,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
|||||||
|
|
||||||
export function PlayerWalletPanel(): React.ReactElement {
|
export function PlayerWalletPanel(): React.ReactElement {
|
||||||
const { t } = useTranslation(["wallet", "common"]);
|
const { t } = useTranslation(["wallet", "common"]);
|
||||||
|
const tRef = useTranslationRef(["wallet", "common"]);
|
||||||
const exportLabels = useExportLabels("playerWallets");
|
const exportLabels = useExportLabels("playerWallets");
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const [playerId, setPlayerId] = useState("");
|
const [playerId, setPlayerId] = useState("");
|
||||||
@@ -923,7 +947,7 @@ export function PlayerWalletPanel(): React.ReactElement {
|
|||||||
const query = useCallback(async () => {
|
const query = useCallback(async () => {
|
||||||
const id = Number(playerId.trim());
|
const id = Number(playerId.trim());
|
||||||
if (Number.isNaN(id) || id < 1) {
|
if (Number.isNaN(id) || id < 1) {
|
||||||
setErr(t("invalidPlayerId"));
|
setErr(tRef.current("invalidPlayerId"));
|
||||||
setResult(null);
|
setResult(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -933,12 +957,12 @@ export function PlayerWalletPanel(): React.ReactElement {
|
|||||||
const d = await getAdminPlayerWallets(id);
|
const d = await getAdminPlayerWallets(id);
|
||||||
setResult(d);
|
setResult(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : t("queryFailed"));
|
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("queryFailed"));
|
||||||
setResult(null);
|
setResult(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [playerId, t]);
|
}, [playerId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
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;
|
captcha_code: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import type { AdminAgentContext } from "@/types/api/admin-agent";
|
||||||
|
|
||||||
/** 登录成功后缓存于会话(localStorage)的管理员摘要 */
|
/** 登录成功后缓存于会话(localStorage)的管理员摘要 */
|
||||||
export type AdminProfile = {
|
export type AdminProfile = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -24,6 +26,11 @@ export type AdminProfile = {
|
|||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
/** 当前管理员可见的后台菜单,由 Laravel 注册表统一下发。 */
|
/** 当前管理员可见的后台菜单,由 Laravel 注册表统一下发。 */
|
||||||
navigation?: AdminNavItem[];
|
navigation?: AdminNavItem[];
|
||||||
|
/** 代理账号绑定节点;超管为 null */
|
||||||
|
agent?: AdminAgentContext | null;
|
||||||
|
is_super_admin?: boolean;
|
||||||
|
/** 当前代理可下放给下级的 prd.* 上限(未配置 grants 时与操作权限一致) */
|
||||||
|
delegation_ceiling?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export type AdminDashboardAnalyticsPlayRow = {
|
|||||||
approx_house_gross_minor: number;
|
approx_house_gross_minor: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardAnalyticsAgentRow = {
|
||||||
|
agent_node_id: number;
|
||||||
|
agent_code: string;
|
||||||
|
agent_name: string;
|
||||||
|
total_bet_minor: number;
|
||||||
|
total_payout_minor: number;
|
||||||
|
approx_house_gross_minor: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminDashboardAnalyticsChartMeta = {
|
export type AdminDashboardAnalyticsChartMeta = {
|
||||||
chart_date_from: string;
|
chart_date_from: string;
|
||||||
chart_date_to: string;
|
chart_date_to: string;
|
||||||
@@ -45,6 +54,7 @@ export type AdminDashboardAnalyticsData = {
|
|||||||
daily_series: AdminReportDailyProfitRow[];
|
daily_series: AdminReportDailyProfitRow[];
|
||||||
chart_meta: AdminDashboardAnalyticsChartMeta;
|
chart_meta: AdminDashboardAnalyticsChartMeta;
|
||||||
play_breakdown: AdminDashboardAnalyticsPlayRow[];
|
play_breakdown: AdminDashboardAnalyticsPlayRow[];
|
||||||
|
agent_breakdown: AdminDashboardAnalyticsAgentRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminDashboardAnalyticsQuery = {
|
export type AdminDashboardAnalyticsQuery = {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export type AdminPlayerWalletRow = {
|
|||||||
|
|
||||||
export type AdminPlayerRow = {
|
export type AdminPlayerRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
agent_node_id?: number | null;
|
||||||
|
agent_code?: string | null;
|
||||||
|
agent_name?: string | null;
|
||||||
site_code: string;
|
site_code: string;
|
||||||
site_player_id: string;
|
site_player_id: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export type AdminReportDailyProfitRow = {
|
|||||||
|
|
||||||
export type AdminReportPlayerWinLossRow = {
|
export type AdminReportPlayerWinLossRow = {
|
||||||
player_id: number;
|
player_id: number;
|
||||||
|
agent_node_id?: number | null;
|
||||||
|
agent_code?: string | null;
|
||||||
|
agent_name?: string | null;
|
||||||
username: string;
|
username: string;
|
||||||
total_bet_minor: number;
|
total_bet_minor: number;
|
||||||
total_payout_minor: number;
|
total_payout_minor: number;
|
||||||
@@ -45,4 +48,5 @@ export type AdminReportQueryParams = {
|
|||||||
date_to?: string;
|
date_to?: string;
|
||||||
player_id?: number;
|
player_id?: number;
|
||||||
play_code?: string;
|
play_code?: string;
|
||||||
|
agent_node_id?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export type AdminSettlementBatchShowData = {
|
|||||||
export type AdminSettlementDetailRow = {
|
export type AdminSettlementDetailRow = {
|
||||||
id: number;
|
id: number;
|
||||||
ticket_item_id: number;
|
ticket_item_id: number;
|
||||||
|
agent_node_id?: number | null;
|
||||||
|
agent_code?: string | null;
|
||||||
|
agent_name?: string | null;
|
||||||
ticket_no: string | null;
|
ticket_no: string | null;
|
||||||
play_code: string | null;
|
play_code: string | null;
|
||||||
currency_code: string | null;
|
currency_code: string | null;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
export type AdminTicketItemRow = {
|
export type AdminTicketItemRow = {
|
||||||
id: number;
|
id: number;
|
||||||
ticket_no: string;
|
ticket_no: string;
|
||||||
|
agent_node_id?: number | null;
|
||||||
|
agent_code?: string | null;
|
||||||
|
agent_name?: string | null;
|
||||||
player_id: number;
|
player_id: number;
|
||||||
site_code: string | null;
|
site_code: string | null;
|
||||||
site_player_id: string | null;
|
site_player_id: string | null;
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export type AdminRoleRow = {
|
|||||||
status: number;
|
status: number;
|
||||||
is_system: boolean;
|
is_system: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
scope_type?: string;
|
||||||
|
owner_agent_id?: number | null;
|
||||||
|
delegated_from_role_id?: number | null;
|
||||||
|
is_read_only_template?: boolean;
|
||||||
permission_slugs: string[];
|
permission_slugs: string[];
|
||||||
user_count: number;
|
user_count: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
export type AdminTransferOrderItem = {
|
export type AdminTransferOrderItem = {
|
||||||
id: number;
|
id: number;
|
||||||
transfer_no: string;
|
transfer_no: string;
|
||||||
|
agent_node_id?: number | null;
|
||||||
|
agent_code?: string | null;
|
||||||
|
agent_name?: string | null;
|
||||||
player_id: number;
|
player_id: number;
|
||||||
site_code: string | null;
|
site_code: string | null;
|
||||||
site_player_id: string | null;
|
site_player_id: string | null;
|
||||||
@@ -34,6 +37,9 @@ export type AdminTransferOrderListData = {
|
|||||||
export type AdminWalletTxnItem = {
|
export type AdminWalletTxnItem = {
|
||||||
id: number;
|
id: number;
|
||||||
txn_no: string;
|
txn_no: string;
|
||||||
|
agent_node_id?: number | null;
|
||||||
|
agent_code?: string | null;
|
||||||
|
agent_name?: string | null;
|
||||||
player_id: number;
|
player_id: number;
|
||||||
site_code: string | null;
|
site_code: string | null;
|
||||||
site_player_id: string | null;
|
site_player_id: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user