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:
@@ -3,9 +3,11 @@ import {
|
||||
CalendarClock,
|
||||
CircleDollarSign,
|
||||
FileSpreadsheet,
|
||||
Globe,
|
||||
Landmark,
|
||||
LayoutDashboard,
|
||||
LogIn,
|
||||
Network,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Settings,
|
||||
@@ -23,6 +25,7 @@ import type { AdminNavItem } from "@/modules/_config/admin-nav";
|
||||
export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon> =
|
||||
{
|
||||
dashboard: LayoutDashboard,
|
||||
agents: Network,
|
||||
players: Users,
|
||||
draws: CalendarClock,
|
||||
rules_plays: SlidersHorizontal,
|
||||
@@ -39,6 +42,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
admin_users: ShieldCheck,
|
||||
admin_roles: ShieldCheck,
|
||||
currencies: CircleDollarSign,
|
||||
integration: Globe,
|
||||
settings: Settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
export const ADMIN_BASE = "/admin" as const;
|
||||
|
||||
export type AdminNavGroup =
|
||||
| "overview"
|
||||
| "agent"
|
||||
| "operations"
|
||||
| "finance"
|
||||
| "rules"
|
||||
| "platform";
|
||||
|
||||
export type AdminNavSegment =
|
||||
| "dashboard"
|
||||
| "agents"
|
||||
| "players"
|
||||
| "draws"
|
||||
| "rules_plays"
|
||||
@@ -18,12 +27,15 @@ export type AdminNavSegment =
|
||||
| "audit"
|
||||
| "admin_users"
|
||||
| "admin_roles"
|
||||
| "currencies";
|
||||
| "currencies"
|
||||
| "integration";
|
||||
|
||||
export type AdminNavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
segment: AdminNavSegment;
|
||||
nav_group?: AdminNavGroup;
|
||||
platform_only?: boolean;
|
||||
activeMatchPrefix?: string;
|
||||
requiredAny?: readonly string[];
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -59,6 +62,7 @@ function permissionLabel(slug: string, fallback: string, t: (key: string) => str
|
||||
|
||||
export function AdminRolesConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
|
||||
@@ -118,19 +122,17 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
setCatalog(catalogData);
|
||||
setRoles(roleData.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("roleLoadFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("roleLoadFailed");
|
||||
setErr(msg);
|
||||
setRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function isDirectGroupOpen(key: string): boolean {
|
||||
return directMenuExpanded[key] === true;
|
||||
@@ -329,9 +331,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && roles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table id="admin-roles-table">
|
||||
<TableHeader>
|
||||
@@ -347,7 +346,9 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.length === 0 ? (
|
||||
{loading && roles.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -51,6 +54,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AdminUsersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const exportLabels = useExportLabels("adminUsers");
|
||||
const profile = useAdminProfile();
|
||||
@@ -112,7 +116,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
setTotal(listData.meta.total);
|
||||
setLastPage(Math.max(1, listData.meta.last_page));
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||
setErr(msg);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
@@ -120,13 +124,11 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, t]);
|
||||
}, [page, perPage, query]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, query]);
|
||||
|
||||
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
||||
setFormCreateRoles((prev) => {
|
||||
@@ -360,9 +362,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table id="admin-users-table">
|
||||
<TableHeader>
|
||||
@@ -377,7 +376,9 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
{loading && items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={7} />
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
|
||||
1084
src/modules/agents/agents-console.tsx
Normal file
1084
src/modules/agents/agents-console.tsx
Normal file
@@ -0,0 +1,1084 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, KeyRound, Pencil, Plus, Search, Shield, Trash2, Users } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteAgentNode,
|
||||
deleteAgentRole,
|
||||
getAgentNodeAdminUsers,
|
||||
getAgentNodeRoles,
|
||||
getAgentTree,
|
||||
postAgentAdminUser,
|
||||
postAgentNode,
|
||||
postAgentRole,
|
||||
putAgentAdminUserRoles,
|
||||
putAgentNode,
|
||||
putAgentRole,
|
||||
putAgentRolePermissions,
|
||||
getAgentDelegationGrants,
|
||||
putAgentDelegationGrants,
|
||||
} from "@/api/admin-agents";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { getAdminUserPermissionCatalog } from "@/api/admin-users";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_MANAGE,
|
||||
PRD_AGENT_ROLE_MANAGE,
|
||||
PRD_AGENT_ROLE_VIEW,
|
||||
PRD_AGENT_USER_MANAGE,
|
||||
PRD_AGENT_USER_VIEW,
|
||||
} from "@/lib/admin-prd";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type { AgentDelegationGrantRow, AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import type { AdminPermissionCatalogData, AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
const out: AgentNodeRow[] = [];
|
||||
const walk = (list: AgentNodeRow[]) => {
|
||||
for (const node of list) {
|
||||
out.push(node);
|
||||
if (node.children?.length) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterTree(nodes: AgentNodeRow[], keyword: string): AgentNodeRow[] {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const filterNode = (node: AgentNodeRow): AgentNodeRow | null => {
|
||||
const children = node.children
|
||||
?.map((child) => filterNode(child))
|
||||
.filter((child): child is AgentNodeRow => child !== null) ?? [];
|
||||
const selfMatch =
|
||||
node.name.toLowerCase().includes(normalized) || node.code.toLowerCase().includes(normalized);
|
||||
if (!selfMatch && children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
return nodes.map((node) => filterNode(node)).filter((node): node is AgentNodeRow => node !== null);
|
||||
}
|
||||
|
||||
function AgentTreeNodes({
|
||||
nodes,
|
||||
depth,
|
||||
selectedId,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
}: {
|
||||
nodes: AgentNodeRow[];
|
||||
depth: number;
|
||||
selectedId: number | null;
|
||||
expandedIds: Set<number>;
|
||||
onToggleExpand: (nodeId: number) => void;
|
||||
onSelect: (node: AgentNodeRow) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ul className={depth === 0 ? "space-y-0.5" : "ml-3 border-l border-border pl-2"}>
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md pr-2",
|
||||
selectedId === node.id ? "bg-primary/5 ring-1 ring-primary/20" : "hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle children"
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className="ml-1 rounded-sm p-0.5 text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform",
|
||||
expandedIds.has(node.id) && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="ml-1 size-4 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node)}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-1 rounded-md py-1.5 text-left text-sm",
|
||||
selectedId === node.id && "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className="ml-auto font-mono text-[11px] text-muted-foreground">{node.code}</span>
|
||||
</button>
|
||||
</div>
|
||||
{node.children && node.children.length > 0 && expandedIds.has(node.id) ? (
|
||||
<AgentTreeNodes
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "adminUsers", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
const canManageNode = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_MANAGE]);
|
||||
const canViewRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_VIEW, PRD_AGENT_ROLE_MANAGE]);
|
||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_MANAGE]);
|
||||
const canViewUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_VIEW, PRD_AGENT_USER_MANAGE]);
|
||||
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_MANAGE]);
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
|
||||
const [siteOptions, setSiteOptions] = useState<{ id: number; label: string }[]>([]);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const [tree, setTree] = useState<AgentNodeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [treeKeyword, setTreeKeyword] = useState("");
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||
const [users, setUsers] = useState<AdminUserPermissionRow[]>([]);
|
||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||
|
||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||
const [nodeCode, setNodeCode] = useState("");
|
||||
const [nodeName, setNodeName] = useState("");
|
||||
const [nodeStatus, setNodeStatus] = useState(1);
|
||||
const [nodeSaving, setNodeSaving] = useState(false);
|
||||
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
||||
const [roleSlug, setRoleSlug] = useState("");
|
||||
const [roleName, setRoleName] = useState("");
|
||||
const [rolePerms, setRolePerms] = useState<string[]>([]);
|
||||
const [roleSaving, setRoleSaving] = useState(false);
|
||||
|
||||
const [permDialogOpen, setPermDialogOpen] = useState(false);
|
||||
const [permRoleId, setPermRoleId] = useState<number | null>(null);
|
||||
const [draftPerms, setDraftPerms] = useState<string[]>([]);
|
||||
const [permSaving, setPermSaving] = useState(false);
|
||||
|
||||
const [userDialogOpen, setUserDialogOpen] = useState(false);
|
||||
const [userUsername, setUserUsername] = useState("");
|
||||
const [userNickname, setUserNickname] = useState("");
|
||||
const [userPassword, setUserPassword] = useState("");
|
||||
const [userRoleIds, setUserRoleIds] = useState<number[]>([]);
|
||||
const [userSaving, setUserSaving] = useState(false);
|
||||
|
||||
const [delegationGrants, setDelegationGrants] = useState<AgentDelegationGrantRow[]>([]);
|
||||
const [delegationSaving, setDelegationSaving] = useState(false);
|
||||
|
||||
const flatNodes = useMemo(() => flattenTree(tree), [tree]);
|
||||
const filteredTree = useMemo(() => filterTree(tree, treeKeyword), [tree, treeKeyword]);
|
||||
const selected = useMemo(
|
||||
() => flatNodes.find((n) => n.id === selectedId) ?? null,
|
||||
[flatNodes, selectedId],
|
||||
);
|
||||
const selectedChildrenCount = selected?.children?.length ?? 0;
|
||||
const selectedDescendantCount = useMemo(() => {
|
||||
if (!selected?.children?.length) {
|
||||
return 0;
|
||||
}
|
||||
return flattenTree(selected.children).length;
|
||||
}, [selected]);
|
||||
|
||||
const canManageDelegation =
|
||||
canManageNode &&
|
||||
selected !== null &&
|
||||
!selected.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === selected.parent_id);
|
||||
|
||||
const assignablePermissionSlugs = useMemo(() => {
|
||||
const mine = new Set(profile?.permissions ?? []);
|
||||
const slugs: string[] = [];
|
||||
for (const group of catalog?.permission_menu_groups ?? []) {
|
||||
for (const p of group.permissions) {
|
||||
if (mine.has(p.slug)) {
|
||||
slugs.push(p.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (slugs.length === 0) {
|
||||
for (const p of catalog?.permissions ?? []) {
|
||||
if (mine.has(p.slug)) {
|
||||
slugs.push(p.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}, [catalog, profile?.permissions]);
|
||||
|
||||
const loadTree = useCallback(async (siteId?: number | null) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await getAgentTree(siteId ?? undefined);
|
||||
setTree(data.tree);
|
||||
setAdminSiteId(data.admin_site_id);
|
||||
setExpandedNodeIds(new Set(flattenTree(data.tree).map((node) => node.id)));
|
||||
if (selectedId === null && data.tree.length > 0) {
|
||||
const first = flattenTree(data.tree)[0];
|
||||
if (first) {
|
||||
setSelectedId(first.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedId, tRef]);
|
||||
|
||||
const loadDetail = useCallback(async (nodeId: number) => {
|
||||
if (canViewRoles) {
|
||||
const roleData = await getAgentNodeRoles(nodeId);
|
||||
setRoles(roleData.items);
|
||||
} else {
|
||||
setRoles([]);
|
||||
}
|
||||
if (canViewUsers) {
|
||||
const userData = await getAgentNodeAdminUsers(nodeId);
|
||||
setUsers(userData.items);
|
||||
} else {
|
||||
setUsers([]);
|
||||
}
|
||||
const node = flattenTree(tree).find((n) => n.id === nodeId);
|
||||
const showDelegation =
|
||||
canManageNode &&
|
||||
node !== undefined &&
|
||||
!node.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === node.parent_id);
|
||||
if (showDelegation) {
|
||||
const grantData = await getAgentDelegationGrants(nodeId);
|
||||
setDelegationGrants(grantData.grants);
|
||||
} else {
|
||||
setDelegationGrants([]);
|
||||
}
|
||||
}, [canManageNode, canViewRoles, canViewUsers, isSuperAdmin, profile?.agent?.id, tree]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
void getAdminIntegrationSites()
|
||||
.then((data) => {
|
||||
setSiteOptions(
|
||||
data.items.map((row) => ({ id: row.id, label: `${row.name} (${row.code})` })),
|
||||
);
|
||||
if (data.items.length > 0 && adminSiteId === null) {
|
||||
setAdminSiteId(data.items[0]?.id ?? null);
|
||||
}
|
||||
})
|
||||
.catch(() => setSiteOptions([]));
|
||||
} else if (profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
}
|
||||
void getAdminUserPermissionCatalog().then(setCatalog).catch(() => setCatalog(null));
|
||||
}, [isSuperAdmin, profile?.agent?.admin_site_id]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId === null && !isSuperAdmin && profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
return;
|
||||
}
|
||||
if (adminSiteId !== null || !isSuperAdmin) {
|
||||
void loadTree(adminSiteId);
|
||||
}
|
||||
}, [adminSiteId, isSuperAdmin, loadTree, profile?.agent?.admin_site_id]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (selectedId !== null) {
|
||||
void loadDetail(selectedId).catch(() => {
|
||||
toast.error(tRef.current("loadFailed"));
|
||||
});
|
||||
}
|
||||
}, [selectedId, loadDetail, tRef]);
|
||||
|
||||
const openCreateChild = () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setNodeDialogMode("create");
|
||||
setNodeCode("");
|
||||
setNodeName("");
|
||||
setNodeStatus(1);
|
||||
setNodeDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditNode = () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setNodeDialogMode("edit");
|
||||
setNodeCode(selected.code);
|
||||
setNodeName(selected.name);
|
||||
setNodeStatus(selected.status);
|
||||
setNodeDialogOpen(true);
|
||||
};
|
||||
|
||||
const saveNode = async () => {
|
||||
if (!nodeName.trim() || (nodeDialogMode === "create" && !nodeCode.trim())) {
|
||||
toast.error(t("codeRequired"));
|
||||
return;
|
||||
}
|
||||
setNodeSaving(true);
|
||||
try {
|
||||
if (nodeDialogMode === "create" && selected) {
|
||||
await postAgentNode({
|
||||
parent_id: selected.id,
|
||||
code: nodeCode.trim(),
|
||||
name: nodeName.trim(),
|
||||
status: nodeStatus,
|
||||
});
|
||||
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
||||
} else if (selected) {
|
||||
await putAgentNode(selected.id, { name: nodeName.trim(), status: nodeStatus });
|
||||
toast.success(t("updateSuccess", { name: nodeName.trim() }));
|
||||
}
|
||||
setNodeDialogOpen(false);
|
||||
await loadTree(adminSiteId);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setNodeSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNewRole = async () => {
|
||||
if (!selected || !roleSlug.trim() || !roleName.trim()) {
|
||||
return;
|
||||
}
|
||||
setRoleSaving(true);
|
||||
try {
|
||||
await postAgentRole(selected.id, {
|
||||
slug: roleSlug.trim(),
|
||||
name: roleName.trim(),
|
||||
permission_slugs: rolePerms,
|
||||
});
|
||||
toast.success(t("roles.createSuccess", { name: roleName.trim() }));
|
||||
setRoleDialogOpen(false);
|
||||
await loadDetail(selected.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setRoleSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRolePermissions = async () => {
|
||||
if (permRoleId === null) {
|
||||
return;
|
||||
}
|
||||
setPermSaving(true);
|
||||
try {
|
||||
await putAgentRolePermissions(permRoleId, draftPerms);
|
||||
toast.success(t("roles.permissionSaveSuccess"));
|
||||
setPermDialogOpen(false);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setPermSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDelegation = async () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setDelegationSaving(true);
|
||||
try {
|
||||
const data = await putAgentDelegationGrants(selected.id, {
|
||||
grants: delegationGrants.map((g) => ({
|
||||
menu_action_id: g.menu_action_id,
|
||||
can_delegate: g.can_delegate,
|
||||
})),
|
||||
});
|
||||
setDelegationGrants(data.grants);
|
||||
toast.success(t("delegation.saveSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setDelegationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNewUser = async () => {
|
||||
if (!selected || !userUsername.trim() || !userPassword.trim()) {
|
||||
return;
|
||||
}
|
||||
setUserSaving(true);
|
||||
try {
|
||||
await postAgentAdminUser(selected.id, {
|
||||
username: userUsername.trim(),
|
||||
nickname: userNickname.trim() || userUsername.trim(),
|
||||
password: userPassword,
|
||||
role_ids: userRoleIds,
|
||||
});
|
||||
toast.success(t("users.createSuccess", { name: userNickname.trim() || userUsername.trim() }));
|
||||
setUserDialogOpen(false);
|
||||
await loadDetail(selected.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setUserSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && tree.length === 0) {
|
||||
return <AdminLoadingState label={t("treeTitle")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConfirmDialog />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
{isSuperAdmin && siteOptions.length > 0 ? (
|
||||
<Select
|
||||
value={adminSiteId !== null ? String(adminSiteId) : undefined}
|
||||
onValueChange={(v) => setAdminSiteId(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder={t("siteLabel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,280px)_1fr]">
|
||||
<AdminPageCard title={t("treeTitle")}>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={treeKeyword}
|
||||
onChange={(e) => setTreeKeyword(e.target.value)}
|
||||
className="pl-8"
|
||||
placeholder={t("treeSearch", { defaultValue: "搜索代理编码/名称" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setExpandedNodeIds(new Set(flatNodes.map((node) => node.id)))}
|
||||
>
|
||||
{t("expandAll", { defaultValue: "展开全部" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
selected ? setExpandedNodeIds(new Set([selected.id])) : setExpandedNodeIds(new Set())
|
||||
}
|
||||
>
|
||||
{t("collapseAll", { defaultValue: "收起全部" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="mt-3 h-[min(66vh,620px)] pr-2">
|
||||
<AgentTreeNodes
|
||||
nodes={filteredTree}
|
||||
depth={0}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedNodeIds}
|
||||
onToggleExpand={(nodeId) => {
|
||||
setExpandedNodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSelect={(node) => setSelectedId(node.id)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</AdminPageCard>
|
||||
|
||||
<AdminPageCard title={selected ? selected.name : t("detailTitle")}>
|
||||
{!selected ? (
|
||||
<p className="text-sm text-muted-foreground">{t("selectNode")}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<div className="mb-4 grid gap-3 rounded-xl border bg-muted/20 p-3 md:grid-cols-4">
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("status")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(selected.status)}>
|
||||
{selected.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "Enabled" })
|
||||
: t("common:status.disabled", { defaultValue: "Disabled" })}
|
||||
</AdminStatusBadge>
|
||||
{selected.is_root ? (
|
||||
<Badge variant="secondary">{t("isRoot", { defaultValue: "Root" })}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("childrenCount", { defaultValue: "直属下级" })}</p>
|
||||
<p className="text-lg font-semibold">{selectedChildrenCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("descendantsCount", { defaultValue: "全部下级" })}</p>
|
||||
<p className="text-lg font-semibold">{selectedDescendantCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("nodeCode", { defaultValue: "节点编码" })}</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{selected.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{t("tabs.overview")}</TabsTrigger>
|
||||
{canViewRoles ? <TabsTrigger value="roles">{t("tabs.roles")}</TabsTrigger> : null}
|
||||
{canViewUsers ? <TabsTrigger value="users">{t("tabs.users")}</TabsTrigger> : null}
|
||||
{canManageDelegation ? (
|
||||
<TabsTrigger value="delegation">{t("tabs.delegation")}</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={openEditNode}>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
{t("editNode")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode ? (
|
||||
<Button type="button" size="sm" onClick={openCreateChild}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("createChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="space-y-2 text-sm">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="space-y-2 rounded-xl border bg-background p-4">
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("code")}:</span> {selected.code}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("depth")}:</span> {selected.depth}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("path")}:</span>{" "}
|
||||
<code className="text-xs">{selected.path}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-xl border bg-background p-4">
|
||||
<p className="text-sm font-medium">{t("quickActions", { defaultValue: "常用操作" })}</p>
|
||||
{canManageNode ? (
|
||||
<Button type="button" className="w-full justify-start" onClick={openCreateChild}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("createChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button type="button" variant="outline" className="w-full justify-start" onClick={openEditNode}>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
{t("editNode")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
requestConfirm({
|
||||
title: selected.name,
|
||||
description: t("deleteNodeConfirm", {
|
||||
defaultValue: "删除后不可恢复,请确认该节点无下级、无账号、无角色绑定。",
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
await deleteAgentNode(selected.id);
|
||||
toast.success(t("deleteSuccess", { name: selected.name }));
|
||||
setSelectedId(selected.parent_id ?? null);
|
||||
await loadTree(adminSiteId);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
{t("deleteNode", { defaultValue: "删除节点" })}
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewRoles ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRolePerms([]);
|
||||
setRoleDialogOpen(true);
|
||||
}}
|
||||
disabled={!canManageRoles}
|
||||
>
|
||||
<Shield className="mr-1 size-3.5" />
|
||||
{t("roles.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewUsers ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
setUserUsername("");
|
||||
setUserNickname("");
|
||||
setUserPassword("");
|
||||
setUserRoleIds([]);
|
||||
setUserDialogOpen(true);
|
||||
}}
|
||||
disabled={!canManageUsers}
|
||||
>
|
||||
<Users className="mr-1 size-3.5" />
|
||||
{t("users.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{canViewRoles ? (
|
||||
<TabsContent value="roles">
|
||||
<div className="mb-3 flex justify-end">
|
||||
{canManageRoles ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRolePerms([]);
|
||||
setRoleDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("roles.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("roles.slug")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("roles.userCount")}</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono text-xs">{role.slug}</TableCell>
|
||||
<TableCell>{role.name}</TableCell>
|
||||
<TableCell>{role.user_count}</TableCell>
|
||||
<TableCell>
|
||||
{canManageRoles && !role.is_read_only_template ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "permissions",
|
||||
label: t("roles.permissions"),
|
||||
icon: KeyRound,
|
||||
onClick: () => {
|
||||
setPermRoleId(role.id);
|
||||
setDraftPerms([...role.permission_slugs]);
|
||||
setPermDialogOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("common:actions.delete", { defaultValue: "Delete" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
requestConfirm({
|
||||
title: role.name,
|
||||
description: t("common:confirm.deleteDescription", {
|
||||
defaultValue: "This cannot be undone.",
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
await deleteAgentRole(role.id);
|
||||
toast.success(t("roles.deleteSuccess", { name: role.name }));
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : role.is_read_only_template ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("roles.readOnlyTemplate")}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canViewUsers ? (
|
||||
<TabsContent value="users">
|
||||
<div className="mb-3 flex justify-end">
|
||||
{canManageUsers ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUserUsername("");
|
||||
setUserNickname("");
|
||||
setUserPassword("");
|
||||
setUserRoleIds([]);
|
||||
setUserDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Users className="mr-1 size-3.5" />
|
||||
{t("users.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("users.username")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("users.roles")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.nickname}</TableCell>
|
||||
<TableCell className="text-xs">{user.roles.join(", ") || "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canManageDelegation ? (
|
||||
<TabsContent value="delegation">
|
||||
<p className="mb-3 text-sm text-muted-foreground">{t("delegation.hint")}</p>
|
||||
{delegationGrants.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("delegation.empty")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("delegation.permission")}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("delegation.canDelegate")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{delegationGrants.map((grant) => (
|
||||
<TableRow key={grant.menu_action_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{grant.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{grant.permission_code}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={grant.can_delegate}
|
||||
onCheckedChange={(checked) => {
|
||||
setDelegationGrants((prev) =>
|
||||
prev.map((row) =>
|
||||
row.menu_action_id === grant.menu_action_id
|
||||
? { ...row, can_delegate: checked === true }
|
||||
: row,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={delegationSaving || delegationGrants.length === 0}
|
||||
onClick={() => void saveDelegation()}
|
||||
>
|
||||
{t("delegation.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
</div>
|
||||
|
||||
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{nodeDialogMode === "create" ? t("createChild") : t("editNode")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{nodeDialogMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-code">{t("code")}</Label>
|
||||
<Input
|
||||
id="agent-code"
|
||||
value={nodeCode}
|
||||
onChange={(e) => setNodeCode(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name">{t("name")}</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={nodeName}
|
||||
onChange={(e) => setNodeName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={nodeStatus === 1} onCheckedChange={(v) => setNodeStatus(v ? 1 : 0)} />
|
||||
<Label>{t("status")}</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={nodeSaving} onClick={() => void saveNode()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roles.create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("roles.slug")}</Label>
|
||||
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("roles.permissionSubsetHint")}</p>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{assignablePermissionSlugs.map((slug) => (
|
||||
<label key={slug} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={rolePerms.includes(slug)}
|
||||
onCheckedChange={(checked) => {
|
||||
setRolePerms((prev) =>
|
||||
checked ? [...prev, slug] : prev.filter((s) => s !== slug),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-xs">{slug}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={roleSaving} onClick={() => void saveNewRole()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roles.permissions")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{assignablePermissionSlugs.map((slug) => (
|
||||
<label key={slug} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={draftPerms.includes(slug)}
|
||||
onCheckedChange={(checked) => {
|
||||
setDraftPerms((prev) =>
|
||||
checked ? [...prev, slug] : prev.filter((s) => s !== slug),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-xs">{slug}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setPermDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={permSaving} onClick={() => void saveRolePermissions()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={userDialogOpen} onOpenChange={setUserDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("users.create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.username")}</Label>
|
||||
<Input value={userUsername} onChange={(e) => setUserUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input value={userNickname} onChange={(e) => setUserNickname(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={userPassword}
|
||||
onChange={(e) => setUserPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{roles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.roles")}</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{roles.map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={userRoleIds.includes(role.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setUserRoleIds((prev) =>
|
||||
checked
|
||||
? [...prev, role.id]
|
||||
: prev.filter((id) => id !== role.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{role.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setUserDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={userSaving} onClick={() => void saveNewUser()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -26,6 +29,7 @@ import type { AdminAuditLogListData } from "@/types/api/admin-audit";
|
||||
|
||||
export function AuditLogsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["audit", "common"]);
|
||||
const tRef = useTranslationRef(["audit", "common"]);
|
||||
const exportLabels = useExportLabels("auditLogs");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminAuditLogListData | null>(null);
|
||||
@@ -69,18 +73,16 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate, t]);
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||
|
||||
const meta = data?.meta;
|
||||
|
||||
@@ -200,11 +202,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="audit-logs-table">
|
||||
@@ -219,7 +217,9 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={6} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("empty")}
|
||||
|
||||
@@ -32,11 +32,14 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||
import { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
||||
@@ -106,6 +109,7 @@ export function OddsConfigDocScreen({
|
||||
onVersionIdChange,
|
||||
}: OddsConfigDocScreenProps) {
|
||||
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
@@ -144,15 +148,16 @@ export function OddsConfigDocScreen({
|
||||
const refreshTypes = useCallback(async () => {
|
||||
setLoadingTypes(true);
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setTypes([]);
|
||||
} finally {
|
||||
setLoadingTypes(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
@@ -161,23 +166,21 @@ export function OddsConfigDocScreen({
|
||||
const d = await getAllConfigVersions(getOddsVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
useAsyncEffect(() => {
|
||||
if (workspace) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void refreshTypes();
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshTypes, refreshList, workspace]);
|
||||
void Promise.all([refreshTypes(), refreshList()]);
|
||||
}, [workspace]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
@@ -186,13 +189,15 @@ export function OddsConfigDocScreen({
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace) {
|
||||
@@ -638,9 +643,11 @@ export function OddsConfigDocScreen({
|
||||
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
|
||||
|
||||
{resolvedLoadingDetail || resolvedLoadingTypes ? (
|
||||
<p className={cn("text-center text-sm text-muted-foreground", mergedLayout ? "py-6" : "py-8")}>
|
||||
{t("odds.loadingDetails", { ns: "config" })}
|
||||
</p>
|
||||
<AdminLoadingState
|
||||
className={cn(mergedLayout ? "py-6" : "py-8")}
|
||||
minHeight="6rem"
|
||||
label={t("odds.loadingDetails", { ns: "config" })}
|
||||
/>
|
||||
) : resolvedPlayCode ? (
|
||||
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
||||
|
||||
@@ -41,11 +41,14 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_PLAY_SWITCH_MANAGE } from "@/lib/admin-prd";
|
||||
@@ -138,6 +141,7 @@ function buildPlayConfigSavePayload(
|
||||
|
||||
export function PlayConfigDocScreen() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
||||
@@ -165,19 +169,18 @@ export function PlayConfigDocScreen() {
|
||||
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshList]);
|
||||
useAsyncEffect(() => {
|
||||
void refreshList();
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
const requestSeq = detailRequestSeq.current + 1;
|
||||
@@ -196,7 +199,9 @@ export function PlayConfigDocScreen() {
|
||||
if (detailRequestSeq.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
@@ -204,7 +209,7 @@ export function PlayConfigDocScreen() {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0) {
|
||||
@@ -538,7 +543,7 @@ export function PlayConfigDocScreen() {
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -19,7 +19,14 @@ import {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
import { getAdminSettings, updateAdminSetting } from "@/api/admin-settings";
|
||||
import { updateAdminSetting } from "@/api/admin-settings";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import {
|
||||
loadApplyRebateToPayoutSetting,
|
||||
setCachedApplyRebateToPayoutSetting,
|
||||
} from "@/lib/admin-settlement-settings-cache";
|
||||
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -33,6 +40,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
@@ -58,7 +66,6 @@ import {
|
||||
} from "@/modules/config/doc/odds-rebate-rates";
|
||||
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
||||
|
||||
const SETTLEMENT_GROUP = "settlement";
|
||||
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
|
||||
|
||||
function dimensionDistinctPrimaryScopePercents(
|
||||
@@ -98,6 +105,7 @@ export function RebateConfigDocScreen({
|
||||
onVersionIdChange,
|
||||
}: RebateConfigDocScreenProps) {
|
||||
const { t } = useTranslation(["config", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
|
||||
@@ -137,54 +145,52 @@ export function RebateConfigDocScreen({
|
||||
|
||||
const refreshTypes = useCallback(async () => {
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setTypes([]);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
try {
|
||||
const d = await getAllConfigVersions(getOddsVersions);
|
||||
setListRows(d.items);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setListRows([]);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const loadWinEnjoySetting = useCallback(async () => {
|
||||
setWinEnjoyLoading(true);
|
||||
try {
|
||||
const res = await getAdminSettings(SETTLEMENT_GROUP);
|
||||
const hit = res.items.find((item) => item.key === APPLY_REBATE_TO_PAYOUT_KEY);
|
||||
setApplyRebateToPayout(Boolean(hit?.value));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setWinEnjoyLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
useAsyncEffect(() => {
|
||||
if (workspace) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(async () => {
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
await refreshTypes();
|
||||
await refreshList();
|
||||
await Promise.all([refreshTypes(), refreshList()]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [refreshTypes, refreshList, workspace]);
|
||||
})();
|
||||
}, [workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadWinEnjoySetting();
|
||||
});
|
||||
}, [loadWinEnjoySetting]);
|
||||
useAsyncEffect(() => {
|
||||
void (async () => {
|
||||
setWinEnjoyLoading(true);
|
||||
try {
|
||||
setApplyRebateToPayout(await loadApplyRebateToPayoutSetting());
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
} finally {
|
||||
setWinEnjoyLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) {
|
||||
@@ -202,6 +208,7 @@ export function RebateConfigDocScreen({
|
||||
setWinEnjoySaving(true);
|
||||
try {
|
||||
await updateAdminSetting(APPLY_REBATE_TO_PAYOUT_KEY, checked);
|
||||
setCachedApplyRebateToPayoutSetting(checked);
|
||||
setApplyRebateToPayout(checked);
|
||||
toast.success(t("rebate.winEnjoy.saveSuccess", { ns: "config" }));
|
||||
} catch (e) {
|
||||
@@ -214,8 +221,7 @@ export function RebateConfigDocScreen({
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const pt = await getAdminPlayTypes();
|
||||
const typeList = pt.items;
|
||||
const typeList = await ensureAdminPlayTypesLoaded(getAdminPlayTypes);
|
||||
setTypes(typeList);
|
||||
const d = await getOddsVersion(id);
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
@@ -225,13 +231,15 @@ export function RebateConfigDocScreen({
|
||||
setP3(inferRebatePercentFromDimension(3, rows, typeList));
|
||||
setP4(inferRebatePercentFromDimension(4, rows, typeList));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace) {
|
||||
@@ -614,7 +622,7 @@ export function RebateConfigDocScreen({
|
||||
) : null}
|
||||
|
||||
{resolvedLoading || resolvedLoadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
||||
import {
|
||||
@@ -45,7 +46,9 @@ import {
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
|
||||
@@ -86,6 +89,7 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
|
||||
|
||||
export function RiskCapDocScreen() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_RISK_CAP_MANAGE]);
|
||||
@@ -113,19 +117,18 @@ export function RiskCapDocScreen() {
|
||||
const d = await getAllConfigVersions(getRiskCapVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void refreshList();
|
||||
});
|
||||
}, [refreshList]);
|
||||
useAsyncEffect(() => {
|
||||
void refreshList();
|
||||
}, []);
|
||||
|
||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
||||
const defaultRow = rows.find(isDefaultRiskRow);
|
||||
@@ -151,14 +154,16 @@ export function RiskCapDocScreen() {
|
||||
setDraftRows(mapped);
|
||||
syncDefaultCapFromRows(mapped);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
syncDefaultCapFromRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0) {
|
||||
@@ -498,7 +503,7 @@ export function RiskCapDocScreen() {
|
||||
}
|
||||
>
|
||||
{loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.loadingDetails", { ns: "config" })}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
|
||||
) : specialRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||
) : (
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSetting,
|
||||
} from "@/api/admin-settings";
|
||||
import { getAdminSettings, updateAdminSettingsBatch } from "@/api/admin-settings";
|
||||
import { useOptionalAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
|
||||
import { WALLET_GROUP, WALLET_KEYS } from "@/modules/settings/settings-keys";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||
@@ -15,15 +14,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
const WALLET_GROUP = "wallet";
|
||||
|
||||
const KEYS = {
|
||||
IN_MIN: "wallet.transfer_in_min_minor",
|
||||
IN_MAX: "wallet.transfer_in_max_minor",
|
||||
OUT_MIN: "wallet.transfer_out_min_minor",
|
||||
OUT_MAX: "wallet.transfer_out_max_minor",
|
||||
} as const;
|
||||
|
||||
function minorUnitsToDisplay(n: unknown, decimals = 2): string {
|
||||
const num = Number(n);
|
||||
if (!Number.isFinite(num)) return "";
|
||||
@@ -43,12 +33,24 @@ interface Draft {
|
||||
outMax: string;
|
||||
}
|
||||
|
||||
function draftFromKv(kv: Record<string, unknown>): Draft {
|
||||
return {
|
||||
inMin: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MIN] ?? 100),
|
||||
inMax: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MAX] ?? 0),
|
||||
outMin: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MIN] ?? 100),
|
||||
outMax: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MAX] ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
type WalletConfigDocScreenProps = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const shared = useOptionalAdminSettingsData();
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const [draft, setDraft] = useState<Draft>({
|
||||
inMin: "",
|
||||
@@ -57,55 +59,81 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
|
||||
outMax: "",
|
||||
});
|
||||
const [saved, setSaved] = useState<Draft>({ inMin: "", inMax: "", outMin: "", outMax: "" });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [standaloneLoading, setStandaloneLoading] = useState(!embedded);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const dirty =
|
||||
draft.inMin !== saved.inMin ||
|
||||
draft.inMax !== saved.inMax ||
|
||||
draft.outMin !== saved.outMin ||
|
||||
draft.outMax !== saved.outMax;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const loading = embedded ? (shared?.loading ?? true) : standaloneLoading;
|
||||
|
||||
const loadStandalone = useCallback(async () => {
|
||||
setStandaloneLoading(true);
|
||||
try {
|
||||
const res = await getAdminSettings(WALLET_GROUP);
|
||||
const kv: Record<string, unknown> = {};
|
||||
for (const item of res.items) {
|
||||
kv[item.key] = item.value;
|
||||
}
|
||||
const d: Draft = {
|
||||
inMin: minorUnitsToDisplay(kv[KEYS.IN_MIN] ?? 100),
|
||||
inMax: minorUnitsToDisplay(kv[KEYS.IN_MAX] ?? 0),
|
||||
outMin: minorUnitsToDisplay(kv[KEYS.OUT_MIN] ?? 100),
|
||||
outMax: minorUnitsToDisplay(kv[KEYS.OUT_MAX] ?? 0),
|
||||
};
|
||||
const d = draftFromKv(kv);
|
||||
setDraft(d);
|
||||
setSaved(d);
|
||||
setDirty(false);
|
||||
} catch {
|
||||
toast.error(t("wallet.loadFailed", { ns: "config" }));
|
||||
toast.error(tRef.current("wallet.loadFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStandaloneLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
if (!embedded) {
|
||||
void loadStandalone();
|
||||
}
|
||||
}, [embedded, loadStandalone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embedded || shared?.kv === null || shared?.kv === undefined) {
|
||||
return;
|
||||
}
|
||||
const d = draftFromKv(shared.kv);
|
||||
setDraft(d);
|
||||
setSaved(d);
|
||||
}, [embedded, shared?.kv]);
|
||||
|
||||
const handleChange = (field: keyof Draft, value: string) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const items = [];
|
||||
if (draft.inMin !== saved.inMin) {
|
||||
items.push({ key: WALLET_KEYS.IN_MIN, value: displayToMinorUnits(draft.inMin) });
|
||||
}
|
||||
if (draft.inMax !== saved.inMax) {
|
||||
items.push({ key: WALLET_KEYS.IN_MAX, value: displayToMinorUnits(draft.inMax) });
|
||||
}
|
||||
if (draft.outMin !== saved.outMin) {
|
||||
items.push({ key: WALLET_KEYS.OUT_MIN, value: displayToMinorUnits(draft.outMin) });
|
||||
}
|
||||
if (draft.outMax !== saved.outMax) {
|
||||
items.push({ key: WALLET_KEYS.OUT_MAX, value: displayToMinorUnits(draft.outMax) });
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAdminSetting(KEYS.IN_MIN, displayToMinorUnits(draft.inMin));
|
||||
await updateAdminSetting(KEYS.IN_MAX, displayToMinorUnits(draft.inMax));
|
||||
await updateAdminSetting(KEYS.OUT_MIN, displayToMinorUnits(draft.outMin));
|
||||
await updateAdminSetting(KEYS.OUT_MAX, displayToMinorUnits(draft.outMax));
|
||||
await updateAdminSettingsBatch(items);
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const item of items) {
|
||||
updates[item.key] = item.value;
|
||||
}
|
||||
shared?.patchKv(updates);
|
||||
toast.success(t("wallet.saveSuccess", { ns: "config" }));
|
||||
setSaved(draft);
|
||||
setDirty(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("wallet.saveFailed", { ns: "config" }),
|
||||
@@ -186,13 +214,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
|
||||
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDraft(saved);
|
||||
setDirty(false);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setDraft(saved)} disabled={saving}>
|
||||
{t("wallet.discard", { ns: "config" })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -37,6 +40,7 @@ type PoolFilter = "all" | "sold_out" | "high_risk";
|
||||
|
||||
export function RiskCapRuntimePanel() {
|
||||
const { t } = useTranslation(["config", "risk", "draws", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const [draws, setDraws] = useState<AdminDrawListItem[]>([]);
|
||||
const [drawsLoading, setDrawsLoading] = useState(true);
|
||||
const [drawId, setDrawId] = useState<string>("");
|
||||
@@ -64,12 +68,14 @@ export function RiskCapRuntimePanel() {
|
||||
setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDraws([]);
|
||||
} finally {
|
||||
setDrawsLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const loadPools = useCallback(async () => {
|
||||
if (!drawId) {
|
||||
@@ -94,24 +100,22 @@ export function RiskCapRuntimePanel() {
|
||||
setPools(data.items);
|
||||
setCurrencyCode(data.currency_code);
|
||||
} catch (e) {
|
||||
setPoolsError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setPoolsError(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setPools([]);
|
||||
} finally {
|
||||
setPoolsLoading(false);
|
||||
}
|
||||
}, [appliedNumber, drawId, poolFilter, t]);
|
||||
}, [appliedNumber, drawId, poolFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadDraws();
|
||||
});
|
||||
}, [loadDraws]);
|
||||
useAsyncEffect(() => {
|
||||
void loadDraws();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPools();
|
||||
});
|
||||
}, [loadPools]);
|
||||
useAsyncEffect(() => {
|
||||
void loadPools();
|
||||
}, [appliedNumber, drawId, poolFilter]);
|
||||
|
||||
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;
|
||||
|
||||
@@ -226,11 +230,7 @@ export function RiskCapRuntimePanel() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{poolsLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableLoadingRow colSpan={5} />
|
||||
) : pools.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -10,6 +9,8 @@ import {
|
||||
getOddsVersion,
|
||||
getOddsVersions,
|
||||
} from "@/api/admin-config";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
|
||||
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
@@ -42,7 +43,7 @@ export function useOddsConfigWorkspace(
|
||||
selectedId: string,
|
||||
onSelectedIdChange: (id: string) => void,
|
||||
): OddsConfigWorkspace {
|
||||
const { t } = useTranslation("common");
|
||||
const tRef = useTranslationRef("common");
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||
@@ -52,6 +53,7 @@ export function useOddsConfigWorkspace(
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const detailRequestSeq = useRef(0);
|
||||
const bootstrappedRef = useRef(false);
|
||||
|
||||
const applyDetail = useCallback((next: OddsVersionDetail) => {
|
||||
setDetail(next);
|
||||
@@ -61,15 +63,14 @@ export function useOddsConfigWorkspace(
|
||||
const refreshTypes = useCallback(async () => {
|
||||
setLoadingTypes(true);
|
||||
try {
|
||||
const d = await getAdminPlayTypes();
|
||||
setTypes(d.items);
|
||||
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed"));
|
||||
setTypes([]);
|
||||
} finally {
|
||||
setLoadingTypes(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
@@ -78,13 +79,13 @@ export function useOddsConfigWorkspace(
|
||||
const d = await getAllConfigVersions(getOddsVersions);
|
||||
setList(d.items);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed");
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(
|
||||
async (id: number) => {
|
||||
@@ -100,7 +101,7 @@ export function useOddsConfigWorkspace(
|
||||
if (seq !== detailRequestSeq.current) {
|
||||
return;
|
||||
}
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed"));
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
@@ -109,7 +110,7 @@ export function useOddsConfigWorkspace(
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyDetail, t],
|
||||
[applyDetail],
|
||||
);
|
||||
|
||||
const reloadDetail = useCallback(async () => {
|
||||
@@ -124,8 +125,11 @@ export function useOddsConfigWorkspace(
|
||||
}, [loadDetail, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTypes();
|
||||
void refreshList();
|
||||
if (bootstrappedRef.current) {
|
||||
return;
|
||||
}
|
||||
bootstrappedRef.current = true;
|
||||
void Promise.all([refreshTypes(), refreshList()]);
|
||||
}, [refreshTypes, refreshList]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
|
||||
import {
|
||||
DailyTrendChart,
|
||||
PlayBreakdownChart,
|
||||
@@ -356,6 +357,112 @@ export function DashboardPlayRankingCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardAgentRankingCard({
|
||||
analytics,
|
||||
}: {
|
||||
analytics: DashboardAnalyticsState;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const {
|
||||
enabled,
|
||||
rankingMetric,
|
||||
loading,
|
||||
topAgentRows,
|
||||
currency,
|
||||
formatMoney,
|
||||
formatSignedMoney,
|
||||
} = analytics;
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metricValue = (row: (typeof topAgentRows)[number]): number => {
|
||||
if (rankingMetric === "payout") {
|
||||
return row.total_payout_minor;
|
||||
}
|
||||
if (rankingMetric === "profit") {
|
||||
return row.approx_house_gross_minor;
|
||||
}
|
||||
return row.total_bet_minor;
|
||||
};
|
||||
|
||||
const maxAbs = Math.max(1, ...topAgentRows.map((r) => Math.abs(metricValue(r))));
|
||||
|
||||
const formatRowValue = (row: (typeof topAgentRows)[number]): string => {
|
||||
const v = metricValue(row);
|
||||
if (rankingMetric === "profit") {
|
||||
return formatSignedMoney(v, currency);
|
||||
}
|
||||
return formatMoney(v, currency);
|
||||
};
|
||||
|
||||
const barColor = (row: (typeof topAgentRows)[number]): string => {
|
||||
if (rankingMetric === "bet") {
|
||||
return DASHBOARD_CHART_COLORS.primary;
|
||||
}
|
||||
if (rankingMetric === "payout") {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
}
|
||||
return row.approx_house_gross_minor >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
|
||||
<CardHeader className="space-y-2 border-b border-border/60 px-4 py-3">
|
||||
<CardTitle className="text-sm font-semibold">{t("analytics.agentRanking")}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`analytics.rankingMetrics.${rankingMetric}`)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
|
||||
{loading ? (
|
||||
<Skeleton className="h-[210px] w-full" />
|
||||
) : topAgentRows.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{topAgentRows.map((row, idx) => {
|
||||
const v = metricValue(row);
|
||||
const pct = (Math.abs(v) / maxAbs) * 100;
|
||||
const color = barColor(row);
|
||||
return (
|
||||
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="mt-0.5 w-5 shrink-0 text-center text-[11px] font-semibold text-muted-foreground">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium">{row.agent_name || "-"}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">{row.agent_code || ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs font-semibold tabular-nums">
|
||||
{formatRowValue(row)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted/30">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.max(2, pct)}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noAgentData")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单列堆叠布局(兼容旧用法) */
|
||||
export function DashboardAnalyticsPanel({
|
||||
enabled,
|
||||
|
||||
@@ -20,14 +20,12 @@ import {
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { getAdminPlayTypes } from "@/api/admin-config";
|
||||
import {
|
||||
getAdminPlayTypesLoadPromise,
|
||||
getCachedAdminPlayTypes,
|
||||
resolveAdminPlayTypeDisplayName,
|
||||
} from "@/lib/admin-play-types";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import {
|
||||
DashboardAnalyticsMain,
|
||||
DashboardAgentRankingCard,
|
||||
DashboardPlayRankingCard,
|
||||
} from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
@@ -50,7 +48,11 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorUnits,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
@@ -66,9 +68,10 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
type HotPlayTab = "4D" | "3D" | "2D" | "special";
|
||||
|
||||
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
const major = minor / 10 ** decimals;
|
||||
const major = safeMinor / 10 ** decimals;
|
||||
try {
|
||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||
style: "currency",
|
||||
@@ -77,7 +80,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(major);
|
||||
} catch {
|
||||
return formatAdminMinorUnits(minor, code, decimals);
|
||||
return formatAdminMinorUnits(safeMinor, code, decimals);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,28 +165,8 @@ export function DashboardConsole(): ReactElement {
|
||||
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
||||
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
||||
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
||||
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
const loadPlayOptions = useCallback(async () => {
|
||||
try {
|
||||
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
|
||||
setPlayOptions(
|
||||
getCachedAdminPlayTypes().map((item) => ({
|
||||
code: item.play_code,
|
||||
label:
|
||||
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setPlayOptions([]);
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPlayOptions();
|
||||
});
|
||||
}, [loadPlayOptions]);
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
@@ -230,27 +213,30 @@ export function DashboardConsole(): ReactElement {
|
||||
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const currency =
|
||||
lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
|
||||
const canFinance = capabilities?.draw_finance_risk ?? false;
|
||||
const platformLocked = platformRisk?.locked_amount ?? 0;
|
||||
const platformCap = platformRisk?.cap_amount ?? 0;
|
||||
const platformUsagePct = platformRisk?.usage_percent ?? 0;
|
||||
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
|
||||
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
|
||||
const rawPlatformUsagePct = platformRisk?.usage_percent;
|
||||
const platformUsagePct =
|
||||
typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct)
|
||||
? Math.min(100, Math.max(0, rawPlatformUsagePct))
|
||||
: platformCap > 0
|
||||
? (platformLocked / platformCap) * 100
|
||||
: 0;
|
||||
|
||||
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||
|
||||
@@ -359,10 +345,16 @@ export function DashboardConsole(): ReactElement {
|
||||
href="/admin/risk"
|
||||
title={t("riskCapUsage")}
|
||||
value={`${platformUsagePct.toFixed(1)}%`}
|
||||
subtitle={t("platformLockedAndCap", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
cap: formatMoneyMinor(platformCap, currency),
|
||||
})}
|
||||
subtitle={
|
||||
platformCap > 0
|
||||
? t("platformLockedAndCap", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
cap: formatMoneyMinor(platformCap, currency),
|
||||
})
|
||||
: t("platformCapNotConfigured", {
|
||||
locked: formatMoneyMinor(platformLocked, currency),
|
||||
})
|
||||
}
|
||||
actionLabel={t("occupancyDetails")}
|
||||
icon={<Shield className="size-5" aria-hidden />}
|
||||
accent={
|
||||
@@ -542,6 +534,7 @@ export function DashboardConsole(): ReactElement {
|
||||
{showAnalytics ? (
|
||||
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
||||
<DashboardPlayRankingCard analytics={analytics} />
|
||||
<DashboardAgentRankingCard analytics={analytics} />
|
||||
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorDecimal,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildBatchProgressConfig,
|
||||
@@ -53,6 +58,74 @@ export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
type DashboardFinanceMetricCell = {
|
||||
key: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
emphasize: boolean;
|
||||
};
|
||||
|
||||
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */
|
||||
function formatDashboardMetricAmount(
|
||||
minor: number,
|
||||
currencyCode: string | null,
|
||||
formatMoney: MoneyFormatter,
|
||||
): { display: string; title: string } {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
return {
|
||||
display: formatAdminMinorDecimal(safeMinor, code, decimals),
|
||||
title: formatMoney(safeMinor, currencyCode),
|
||||
};
|
||||
}
|
||||
|
||||
function DashboardFinanceMetricCells({
|
||||
cells,
|
||||
currency,
|
||||
formatMoney,
|
||||
}: {
|
||||
cells: readonly DashboardFinanceMetricCell[];
|
||||
currency: string | null;
|
||||
formatMoney: MoneyFormatter;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{cells.map((cell) => {
|
||||
const { display, title } = formatDashboardMetricAmount(
|
||||
cell.amount,
|
||||
currency,
|
||||
formatMoney,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg px-1 py-2 ring-1",
|
||||
cell.emphasize
|
||||
? "bg-primary/6 ring-primary/15"
|
||||
: "bg-muted/30 ring-border/50",
|
||||
)}
|
||||
>
|
||||
<p className="line-clamp-2 text-center text-[10px] leading-tight text-muted-foreground">
|
||||
{cell.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 truncate text-center text-[10px] font-bold tabular-nums leading-tight",
|
||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{display}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function usageBarFill(pct: number): string {
|
||||
if (pct >= 95) {
|
||||
return DASHBOARD_CHART_COLORS.rose;
|
||||
@@ -485,10 +558,11 @@ export function PayoutPanelSnapshot({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_payout_minor;
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const hasPayout = win + jackpot > 0;
|
||||
const bet = coerceAdminMinor(finance.total_bet_minor);
|
||||
const win = coerceAdminMinor(finance.total_win_payout_minor);
|
||||
const jackpot = coerceAdminMinor(finance.total_jackpot_win_minor);
|
||||
const payout = coerceAdminMinor(finance.total_payout_minor);
|
||||
const hasPayout = payout > 0 || win + jackpot > 0;
|
||||
|
||||
if (bet <= 0 && !hasPayout) {
|
||||
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
|
||||
@@ -502,29 +576,7 @@ export function PayoutPanelSnapshot({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={cn(
|
||||
"rounded-lg px-1.5 py-2 ring-1",
|
||||
cell.emphasize
|
||||
? "bg-primary/6 ring-primary/15"
|
||||
: "bg-muted/30 ring-border/50",
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
|
||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatMoney(cell.amount, currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
|
||||
{hasPayout ? (
|
||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
|
||||
) : (
|
||||
@@ -983,7 +1035,10 @@ export function ResultBatchQueueSummary({
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { pending_review_total, pending_draw_count, published_total, batch_total } = queue;
|
||||
const pendingReviewTotal = coerceAdminMinor(queue.pending_review_total);
|
||||
const pendingDrawCount = coerceAdminMinor(queue.pending_draw_count);
|
||||
const publishedTotal = coerceAdminMinor(queue.published_total);
|
||||
const batchTotal = coerceAdminMinor(queue.batch_total);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
@@ -994,7 +1049,7 @@ export function ResultBatchQueueSummary({
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
)}
|
||||
>
|
||||
{pending_review_total}
|
||||
{pendingReviewTotal}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
|
||||
</div>
|
||||
@@ -1005,18 +1060,16 @@ export function ResultBatchQueueSummary({
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
)}
|
||||
>
|
||||
{published_total}
|
||||
{publishedTotal}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
|
||||
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
|
||||
{batch_total}
|
||||
{pendingDrawCount > 0 ? pendingDrawCount : batchTotal}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{pending_draw_count > 0
|
||||
? t("batchPendingDrawsCount", { count: pending_draw_count })
|
||||
: t("batchTotal")}
|
||||
{pendingDrawCount > 0 ? t("batchPendingDraws") : t("batchTotal")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1032,10 +1085,14 @@ export function PlatformLifetimePayoutSnapshot({
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_minor;
|
||||
const jackpot = finance.total_jackpot_minor;
|
||||
const hasPayout = win + jackpot > 0;
|
||||
const bet = coerceAdminMinor(finance.total_bet_minor);
|
||||
const payout = coerceAdminMinor(finance.total_payout_minor);
|
||||
let win = coerceAdminMinor(finance.total_win_minor);
|
||||
let jackpot = coerceAdminMinor(finance.total_jackpot_minor);
|
||||
if (payout > 0 && win + jackpot === 0) {
|
||||
win = payout;
|
||||
}
|
||||
const hasPayout = payout > 0 || win + jackpot > 0;
|
||||
|
||||
if (bet <= 0 && !hasPayout) {
|
||||
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
|
||||
@@ -1049,29 +1106,7 @@ export function PlatformLifetimePayoutSnapshot({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.key}
|
||||
className={cn(
|
||||
"rounded-lg px-1.5 py-2 ring-1",
|
||||
cell.emphasize
|
||||
? "bg-primary/6 ring-primary/15"
|
||||
: "bg-muted/30 ring-border/50",
|
||||
)}
|
||||
>
|
||||
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
|
||||
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{formatMoney(cell.amount, currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
|
||||
{!hasPayout ? (
|
||||
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
|
||||
{t("platformNoPayoutYet")}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { format, subDays } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminMinorUnits,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminDashboardAnalyticsData,
|
||||
AdminDashboardAnalyticsAgentRow,
|
||||
DashboardAnalyticsMetric,
|
||||
DashboardAnalyticsPeriod,
|
||||
} from "@/types/api/admin-dashboard-analytics";
|
||||
@@ -27,9 +34,10 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
|
||||
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
|
||||
|
||||
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
const major = minor / 10 ** decimals;
|
||||
const major = safeMinor / 10 ** decimals;
|
||||
try {
|
||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||
style: "currency",
|
||||
@@ -38,7 +46,7 @@ export function formatDashboardMoneyMinor(minor: number, currencyCode: string |
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(major);
|
||||
} catch {
|
||||
return formatAdminMinorUnits(minor, code, decimals);
|
||||
return formatAdminMinorUnits(safeMinor, code, decimals);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +66,7 @@ export function useDashboardAnalytics({
|
||||
playOptions: { code: string; label: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
const playLabel = useAdminPlayCodeLabel();
|
||||
|
||||
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
||||
@@ -94,19 +103,18 @@ export function useDashboardAnalytics({
|
||||
const needsAuthSync =
|
||||
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
||||
setError(
|
||||
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
|
||||
needsAuthSync
|
||||
? tRef.current("warnings.apiResourceMissing")
|
||||
: raw || tRef.current("warnings.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled, period, playCode, customFrom, customTo, t]);
|
||||
}, [enabled, period, playCode, customFrom, customTo]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [enabled, period, playCode, customFrom, customTo]);
|
||||
|
||||
const currency = data?.currency_code ?? null;
|
||||
const summary = data?.summary;
|
||||
@@ -152,6 +160,28 @@ export function useDashboardAnalytics({
|
||||
return rows.slice(0, 5);
|
||||
}, [data, rankingMetric]);
|
||||
|
||||
const metricAgentValue = useCallback(
|
||||
(row: AdminDashboardAnalyticsAgentRow): number => {
|
||||
if (rankingMetric === "payout") {
|
||||
return row.total_payout_minor;
|
||||
}
|
||||
if (rankingMetric === "profit") {
|
||||
return row.approx_house_gross_minor;
|
||||
}
|
||||
return row.total_bet_minor;
|
||||
},
|
||||
[rankingMetric],
|
||||
);
|
||||
|
||||
const topAgentRows = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const rows = [...data.agent_breakdown];
|
||||
rows.sort((a, b) => metricAgentValue(b) - metricAgentValue(a));
|
||||
return rows.slice(0, 5);
|
||||
}, [data, metricAgentValue]);
|
||||
|
||||
const sparklines = useMemo(() => {
|
||||
const series = data?.daily_series ?? [];
|
||||
return {
|
||||
@@ -183,6 +213,7 @@ export function useDashboardAnalytics({
|
||||
playOptions,
|
||||
resolvePlayLabel,
|
||||
topPlayRows,
|
||||
topAgentRows,
|
||||
sparklines,
|
||||
formatMoney: formatDashboardMoneyMinor,
|
||||
formatSignedMoney: formatDashboardSignedMoneyMinor,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -17,6 +19,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -50,6 +53,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
|
||||
export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const idNum = Number(drawId);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||
@@ -67,7 +71,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(t("invalidDrawId"));
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -77,11 +81,11 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
setData(await getAdminDraw(idNum));
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
||||
if (!Number.isFinite(idNum)) return;
|
||||
@@ -97,15 +101,12 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
@@ -11,6 +13,7 @@ import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -37,6 +40,7 @@ import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
||||
|
||||
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "settlement", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "settlement", "common"]);
|
||||
useAdminCurrencyCatalog();
|
||||
const idNum = Number(drawId);
|
||||
const profile = useAdminProfile();
|
||||
@@ -54,7 +58,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum) || idNum < 1) {
|
||||
setErr(t("invalidDrawId"));
|
||||
setErr(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -63,12 +67,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
try {
|
||||
setData(await getAdminDrawFinanceSummary(idNum));
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
async function runSettlement(): Promise<void> {
|
||||
if (!Number.isFinite(idNum) || idNum < 1) return;
|
||||
@@ -84,14 +88,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (err || !data) {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -34,6 +37,7 @@ import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
|
||||
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const router = useRouter();
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
@@ -50,7 +54,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(t("invalidDrawId"));
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -60,18 +64,15 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
setData(await getAdminDrawResultBatches(idNum));
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
|
||||
if (!Number.isFinite(batchNum)) return undefined;
|
||||
@@ -115,7 +116,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDrawResultBatches } from "@/api/admin-draws";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,6 +31,7 @@ import { DrawStatusBadge } from "./draw-status-badge";
|
||||
|
||||
export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
@@ -39,7 +43,7 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(t("invalidDrawId"));
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -49,21 +53,18 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
setData(await getAdminDrawResultBatches(idNum));
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Dices, Rocket, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -56,6 +59,7 @@ function randomDrawNumber4d(): string {
|
||||
|
||||
export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
@@ -73,7 +77,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(t("invalidDrawId"));
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -83,18 +87,15 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
setData(await getAdminDrawResultBatches(idNum));
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum, t]);
|
||||
}, [idNum]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
|
||||
data,
|
||||
@@ -148,7 +149,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Ban, Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -14,10 +16,12 @@ import {
|
||||
} from "@/api/admin-draws";
|
||||
import { formatAdminInstant } from "@/lib/admin-datetime";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -86,6 +90,7 @@ function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): s
|
||||
|
||||
export function DrawsIndexConsole() {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const exportLabels = useExportLabels("drawsList");
|
||||
useAdminCurrencyCatalog();
|
||||
const defaultCurrency = "NPR";
|
||||
@@ -106,6 +111,8 @@ export function DrawsIndexConsole() {
|
||||
const [draftStatus, setDraftStatus] = useState("");
|
||||
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
||||
const [appliedStatus, setAppliedStatus] = useState("");
|
||||
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState<number>(10);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
@@ -137,17 +144,18 @@ export function DrawsIndexConsole() {
|
||||
appliedStatus.trim() === "" || appliedStatus === DRAW_FILTER_ALL
|
||||
? undefined
|
||||
: appliedStatus.trim(),
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
|
||||
async function generatePlan(): Promise<void> {
|
||||
setGenerating(true);
|
||||
@@ -168,12 +176,9 @@ export function DrawsIndexConsole() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
|
||||
const handleSelectAll = useCallback((checked: boolean) => {
|
||||
if (checked && data) {
|
||||
@@ -293,6 +298,12 @@ export function DrawsIndexConsole() {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<AdminAgentFilter
|
||||
id="draws-agent-filter"
|
||||
className="admin-list-field sm:w-[14rem]"
|
||||
value={agentNodeId}
|
||||
onChange={setAgentNodeId}
|
||||
/>
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="draw-filter-no" className="sm:w-10 sm:shrink-0">
|
||||
{t("drawNo")}
|
||||
@@ -347,6 +358,7 @@ export function DrawsIndexConsole() {
|
||||
onClick={() => {
|
||||
setAppliedDrawNo(draftDrawNo);
|
||||
setAppliedStatus(draftStatus);
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -358,8 +370,10 @@ export function DrawsIndexConsole() {
|
||||
onClick={() => {
|
||||
setDraftDrawNo("");
|
||||
setDraftStatus("");
|
||||
setAgentNodeId(undefined);
|
||||
setAppliedDrawNo("");
|
||||
setAppliedStatus("");
|
||||
setAppliedAgentNodeId(undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -410,11 +424,7 @@ export function DrawsIndexConsole() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableLoadingRow colSpan={10} />
|
||||
) : data === null || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { getAdminPageBundle } from "@/lib/admin-permission-bundles";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
@@ -138,6 +141,7 @@ function formToPayload(
|
||||
|
||||
export function IntegrationSitesConsole() {
|
||||
const { t } = useTranslation("config");
|
||||
const tRef = useTranslationRef("config");
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(
|
||||
profile?.permissions,
|
||||
@@ -174,18 +178,16 @@ export function IntegrationSitesConsole() {
|
||||
setItems(data.items);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
|
||||
error instanceof LotteryApiBizError ? error.message : tRef.current("integrationSites.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openCreate(): void {
|
||||
setMode("create");
|
||||
@@ -352,7 +354,7 @@ export function IntegrationSitesConsole() {
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.loading")}</p>
|
||||
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import {
|
||||
getAdminJackpotPoolAdjustments,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
|
||||
type Draft = {
|
||||
contribution_rate: string;
|
||||
@@ -78,6 +81,7 @@ type JackpotPoolsConsoleProps = {
|
||||
|
||||
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["jackpot", "common"]);
|
||||
const tRef = useTranslationRef(["jackpot", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
|
||||
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);
|
||||
@@ -114,17 +118,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
setAdjustmentDrafts(adjDrafts);
|
||||
setAdjustmentRows(adjRows);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const updateDraft = (id: number, patch: Partial<Draft>) => {
|
||||
setDrafts((prev) => ({
|
||||
@@ -229,7 +231,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
|
||||
const poolList = (
|
||||
<div className={embedded ? "space-y-4" : "space-y-8"}>
|
||||
{loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null}
|
||||
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -13,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -74,7 +77,7 @@ function JackpotRecordTableSection({
|
||||
/>
|
||||
</div>
|
||||
{loading && !hasData ? (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground">{t("states.loading")}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="px-4 py-6" />
|
||||
) : (
|
||||
<div className={TABLE_IN_SHELL_CLASS}>{children}</div>
|
||||
)}
|
||||
@@ -83,8 +86,12 @@ function JackpotRecordTableSection({
|
||||
);
|
||||
}
|
||||
|
||||
type RecordTab = "payout" | "contribution";
|
||||
|
||||
export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) {
|
||||
const { t } = useTranslation(["jackpot", "common"]);
|
||||
const tRef = useTranslationRef(["jackpot"]);
|
||||
const [recordTab, setRecordTab] = useState<RecordTab>("payout");
|
||||
const payoutExport = useExportLabels("jackpotPayouts");
|
||||
const contributionExport = useExportLabels("jackpotContributions");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
@@ -100,7 +107,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
const [cPer, setCPer] = useState(10);
|
||||
|
||||
const [loadingP, setLoadingP] = useState(true);
|
||||
const [loadingC, setLoadingC] = useState(true);
|
||||
const [loadingC, setLoadingC] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const loadPayouts = useCallback(async () => {
|
||||
@@ -113,11 +120,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
});
|
||||
setPayouts(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("payoutLoadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("payoutLoadFailed"));
|
||||
} finally {
|
||||
setLoadingP(false);
|
||||
}
|
||||
}, [pPage, pPer, appliedDrawNo, t]);
|
||||
}, [pPage, pPer, appliedDrawNo]);
|
||||
|
||||
const loadContribs = useCallback(async () => {
|
||||
setLoadingC(true);
|
||||
@@ -129,23 +136,22 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
});
|
||||
setContribs(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("contributionLoadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("contributionLoadFailed"));
|
||||
} finally {
|
||||
setLoadingC(false);
|
||||
}
|
||||
}, [cPage, cPer, appliedDrawNo, t]);
|
||||
}, [cPage, cPer, appliedDrawNo]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPayouts();
|
||||
});
|
||||
}, [loadPayouts]);
|
||||
useAsyncEffect(() => {
|
||||
void loadPayouts();
|
||||
}, [pPage, pPer, appliedDrawNo]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadContribs();
|
||||
});
|
||||
}, [loadContribs]);
|
||||
useAsyncEffect(() => {
|
||||
if (recordTab !== "contribution") {
|
||||
return;
|
||||
}
|
||||
void loadContribs();
|
||||
}, [recordTab, cPage, cPer, appliedDrawNo]);
|
||||
|
||||
const applyDraw = () => {
|
||||
setAppliedDrawNo(drawNo);
|
||||
@@ -328,9 +334,27 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
{filterBlock}
|
||||
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b border-border/70 pb-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={recordTab === "payout" ? "default" : "outline"}
|
||||
onClick={() => setRecordTab("payout")}
|
||||
>
|
||||
{t("payoutRecords")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={recordTab === "contribution" ? "default" : "outline"}
|
||||
onClick={() => setRecordTab("contribution")}
|
||||
>
|
||||
{t("contributionRecords")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{payoutTable}
|
||||
{contributionTable}
|
||||
{recordTab === "payout" ? payoutTable : contributionTable}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -16,6 +18,8 @@ import {
|
||||
postAdminPlayerUnfreeze,
|
||||
putAdminPlayer,
|
||||
} from "@/api/admin-player";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
@@ -81,6 +86,7 @@ const PLAYER_STATUS_OPTIONS = [
|
||||
|
||||
export function PlayersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["players", "common"]);
|
||||
const tRef = useTranslationRef(["players", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const exportLabels = useExportLabels("players");
|
||||
@@ -95,6 +101,8 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [query, setQuery] = useState("");
|
||||
const [siteCode, setSiteCode] = useState("");
|
||||
const [appliedSiteCode, setAppliedSiteCode] = useState("");
|
||||
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
|
||||
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -131,12 +139,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
per_page: perPage,
|
||||
keyword: query.trim() || undefined,
|
||||
site_code: appliedSiteCode.trim() || undefined,
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
setLastPage(Math.max(1, data.meta.last_page));
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed");
|
||||
setErr(msg);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
@@ -144,13 +153,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, appliedSiteCode, t]);
|
||||
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
|
||||
|
||||
function openCreateAccount(): void {
|
||||
setAccountMode("create");
|
||||
@@ -334,6 +341,12 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminAgentFilter
|
||||
id="players-agent-filter"
|
||||
className="admin-list-field sm:w-[14rem]"
|
||||
value={agentNodeId}
|
||||
onChange={setAgentNodeId}
|
||||
/>
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("search")}
|
||||
@@ -349,6 +362,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -365,6 +379,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
@@ -377,15 +392,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell">
|
||||
<Table id="players-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("site")}</TableHead>
|
||||
<AdminAgentHead />
|
||||
<TableHead>{t("sitePlayerId")}</TableHead>
|
||||
<TableHead>{t("username")}</TableHead>
|
||||
<TableHead>{t("nickname")}</TableHead>
|
||||
@@ -398,9 +411,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 && !loading ? (
|
||||
{loading && items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={12} />
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground">
|
||||
<TableCell colSpan={12} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -413,6 +428,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{row.site_code}</span>
|
||||
</TableCell>
|
||||
<AdminAgentCell row={row} />
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{row.site_player_id}</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Eye } from "lucide-react";
|
||||
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -20,6 +22,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -36,6 +39,7 @@ import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||
import type {
|
||||
AdminReconcileJobRow,
|
||||
AdminReconcileItemsData,
|
||||
AdminReconcileJobListData,
|
||||
} from "@/types/api/admin-reconcile";
|
||||
@@ -80,8 +84,23 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
||||
const raw = summary?.[key];
|
||||
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
|
||||
function renderPeriodRange(
|
||||
row: Pick<AdminReconcileJobRow, "period_start" | "period_end">,
|
||||
formatTs: (value: string | null | undefined) => string,
|
||||
): string {
|
||||
const from = row.period_start ? formatTs(row.period_start) : "—";
|
||||
const to = row.period_end ? formatTs(row.period_end) : "—";
|
||||
return `${from} ~ ${to}`;
|
||||
}
|
||||
|
||||
export function ReconcileConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["reconcile", "common"]);
|
||||
const tRef = useTranslationRef(["reconcile", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
||||
@@ -115,18 +134,16 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const d = await getAdminReconcileJobs({ page, per_page: perPage });
|
||||
setJobs(d);
|
||||
} catch (e) {
|
||||
setJobsErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setJobsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setJobs(null);
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
}
|
||||
}, [page, perPage, t]);
|
||||
}, [page, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadJobs();
|
||||
});
|
||||
}, [loadJobs]);
|
||||
useAsyncEffect(() => {
|
||||
void loadJobs();
|
||||
}, [page, perPage]);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
if (selectedId == null) {
|
||||
@@ -141,18 +158,16 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
});
|
||||
setItems(d);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadItemsFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadItemsFailed"));
|
||||
setItems(null);
|
||||
} finally {
|
||||
setItemsLoading(false);
|
||||
}
|
||||
}, [selectedId, itemsPage, itemsPerPage, t]);
|
||||
}, [selectedId, itemsPage, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadItems();
|
||||
});
|
||||
}, [loadItems]);
|
||||
useAsyncEffect(() => {
|
||||
void loadItems();
|
||||
}, [selectedId, itemsPage, itemsPerPage]);
|
||||
|
||||
const loadPlayers = useCallback(async (keyword: string) => {
|
||||
const q = keyword.trim();
|
||||
@@ -218,6 +233,9 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const jm = jobs?.meta;
|
||||
const im = items?.meta;
|
||||
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
|
||||
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
|
||||
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
|
||||
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
@@ -225,28 +243,157 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
||||
<CardDescription>{t("createDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,0.9fr)_minmax(220px,0.95fr)_auto] lg:items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border bg-muted/15 p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="rounded-lg bg-background p-2 text-muted-foreground">
|
||||
<CalendarRange className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("scopeTitle")}</div>
|
||||
<p className="text-sm text-muted-foreground">{t("scopeDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
||||
<p className="text-xs text-muted-foreground">{t("reconcileTypeHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<AdminDateRangeField
|
||||
id="rc-date-range"
|
||||
label={t("dateRange")}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("dateRangeHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<AdminDateRangeField
|
||||
id="rc-date-range"
|
||||
label={t("dateRange")}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border bg-background p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="rounded-lg bg-muted/20 p-2 text-muted-foreground">
|
||||
<UserRound className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("playerScopeTitle")}</div>
|
||||
<p className="text-sm text-muted-foreground">{t("playerSearchHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
||||
<Input
|
||||
id="rc-player-search"
|
||||
value={playerSearch}
|
||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||
placeholder={t("playerSearchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPlayer ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{selectedPlayer.site_player_id}
|
||||
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
||||
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{t("playerSelected")} · {selectedPlayer.site_code}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(null);
|
||||
setPlayerSearch("");
|
||||
setPlayerResults([]);
|
||||
}}
|
||||
>
|
||||
{t("playerClear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
||||
<div className="mt-4 rounded-lg border bg-background">
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{playerLoading ? (
|
||||
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
|
||||
) : playerResults.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{playerResults.map((player) => {
|
||||
const active = selectedPlayer?.id === player.id;
|
||||
return (
|
||||
<button
|
||||
key={player.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
||||
active && "bg-muted/30",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedPlayer(player);
|
||||
setPlayerSearch(player.site_player_id);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{player.site_player_id}
|
||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{player.username ?? "—"} · {player.site_code}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{active ? t("playerSelectedShort") : t("playerChoose")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-lg border border-dashed bg-muted/10 px-3 py-3 text-sm text-muted-foreground">
|
||||
{t("playerAllPlayersHint")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3">
|
||||
<div className="min-w-0 text-sm text-muted-foreground">
|
||||
{selectedPlayer
|
||||
? t("createSummaryPlayer", {
|
||||
player: selectedPlayer.site_player_id,
|
||||
from: dateFrom || "—",
|
||||
to: dateTo || "—",
|
||||
})
|
||||
: t("createSummaryAll", {
|
||||
from: dateFrom || "—",
|
||||
to: dateTo || "—",
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full lg:w-auto"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
@@ -263,82 +410,6 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{submitting ? t("submitting") : t("createTask")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-1.5 pt-4">
|
||||
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
||||
<Input
|
||||
id="rc-player-search"
|
||||
value={playerSearch}
|
||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||
placeholder={t("playerSearchPlaceholder")}
|
||||
/>
|
||||
{selectedPlayer ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{selectedPlayer.site_player_id}
|
||||
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
||||
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(null);
|
||||
setPlayerSearch("");
|
||||
setPlayerResults([]);
|
||||
}}
|
||||
>
|
||||
{t("playerClear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
||||
<div className="rounded-lg border bg-background">
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{playerLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("loadingPlayers")}</div>
|
||||
) : playerResults.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{playerResults.map((player) => {
|
||||
const active = selectedPlayer?.id === player.id;
|
||||
return (
|
||||
<button
|
||||
key={player.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
||||
active && "bg-muted/30",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedPlayer(player);
|
||||
setPlayerSearch(player.site_player_id);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{player.site_player_id}
|
||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{player.username ?? "—"} · {player.site_code}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{active ? t("playerSelectedShort") : t("playerChoose")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -349,6 +420,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
||||
<CardDescription>{t("jobsDesc")}</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
||||
{t("refresh")}
|
||||
@@ -356,9 +428,6 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
||||
{jobsLoading && !jobs ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
{jobs ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
@@ -373,7 +442,10 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-center">{t("itemCount")}</TableHead>
|
||||
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
|
||||
<TableHead>{t("period")}</TableHead>
|
||||
<TableHead>{t("finishedAt")}</TableHead>
|
||||
<TableHead>{t("createdAt")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted/20 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("operate")}
|
||||
@@ -381,9 +453,11 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.items.length === 0 ? (
|
||||
{jobsLoading && !jobs ? (
|
||||
<AdminTableLoadingRow colSpan={10} />
|
||||
) : jobs.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -402,12 +476,28 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{jobStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{getJobSummaryValue(row.summary_json, "item_count")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
getJobSummaryValue(row.summary_json, "mismatch_count") > 0
|
||||
? "font-medium text-amber-700"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{getJobSummaryValue(row.summary_json, "mismatch_count")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
|
||||
<span className="line-clamp-2">
|
||||
{row.period_start ? formatTs(row.period_start) : "—"} ~{" "}
|
||||
{row.period_end ? formatTs(row.period_end) : "—"}
|
||||
{renderPeriodRange(row, formatTs)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(row.finished_at)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
@@ -475,10 +565,27 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
||||
{itemsLoading && !items ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||
) : null}
|
||||
{items ? (
|
||||
<>
|
||||
<div className="mb-4 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("itemCount")}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobItemCount}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("mismatchCount")}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xl font-semibold tabular-nums text-amber-700">
|
||||
<ShieldAlert className="size-4" />
|
||||
{selectedJobMismatchCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("matchedCount")}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobMatchedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("jobNo")} {items.job_no}</span>
|
||||
<span>·</span>
|
||||
@@ -493,7 +600,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{t("period")} {selectedJob ? `${selectedJob.period_start ? formatTs(selectedJob.period_start) : "—"} ~ ${selectedJob.period_end ? formatTs(selectedJob.period_end) : "—"}` : "—"}</span>
|
||||
<span>{t("period")} {selectedJob ? renderPeriodRange(selectedJob, formatTs) : "—"}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
||||
@@ -502,29 +609,47 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("sideARef")}</TableHead>
|
||||
<TableHead>{t("sideBRef")}</TableHead>
|
||||
<TableHead>{t("differenceAmount")}</TableHead>
|
||||
<TableHead className="text-right">{t("differenceAmount")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("detectedAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("noDetails")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.items.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableRow
|
||||
key={r.id}
|
||||
className={cn(
|
||||
r.status === "mismatch" && "bg-amber-500/5",
|
||||
r.status === "matched" && "bg-emerald-500/5",
|
||||
)}
|
||||
>
|
||||
<TableCell>{r.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
|
||||
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.difference_amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={r.status}>
|
||||
{itemStatusLabel(r.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(r.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
import { Download, RefreshCw } from "lucide-react";
|
||||
|
||||
@@ -10,6 +12,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -40,6 +43,7 @@ type ReportJobsPanelProps = {
|
||||
|
||||
export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) {
|
||||
const { t } = useTranslation(["reports", "common"]);
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [jobs, setJobs] = useState<AdminReportJobRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -51,18 +55,16 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
const data = await getAdminReportJobs({ page: 1, per_page: 10 });
|
||||
setJobs(data.items);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("tasks.loadFailed"));
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("tasks.loadFailed"));
|
||||
setJobs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadJobs();
|
||||
});
|
||||
}, [loadJobs, refreshToken]);
|
||||
useAsyncEffect(() => {
|
||||
void loadJobs();
|
||||
}, [refreshToken]);
|
||||
|
||||
async function handleDownload(job: AdminReportJobRow): Promise<void> {
|
||||
if (!canExport || job.status !== "completed") {
|
||||
@@ -111,11 +113,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableLoadingRow colSpan={6} />
|
||||
) : jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
|
||||
@@ -20,13 +20,10 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { getAdminPlayTypes } from "@/api/admin-config";
|
||||
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import {
|
||||
getAdminPlayTypesLoadPromise,
|
||||
getCachedAdminPlayTypes,
|
||||
resolveAdminPlayTypeDisplayName,
|
||||
} from "@/lib/admin-play-types";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs";
|
||||
@@ -49,6 +46,8 @@ import { getAdminTransferOrders } from "@/api/admin-wallet";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -56,6 +55,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -121,12 +121,24 @@ type ReportDefinition = {
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
type PreviewColumns = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
metricA: string;
|
||||
metricB: string;
|
||||
metricC: string;
|
||||
status: string;
|
||||
extra: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type ReportFilters = {
|
||||
drawNo: string;
|
||||
drawId: number | null;
|
||||
number: string;
|
||||
player: string;
|
||||
playerId: number | null;
|
||||
agentNodeId: number | undefined;
|
||||
play: string;
|
||||
operator: string;
|
||||
operatorId: number | null;
|
||||
@@ -190,6 +202,7 @@ const emptyFilters: ReportFilters = {
|
||||
number: "",
|
||||
player: "",
|
||||
playerId: null,
|
||||
agentNodeId: undefined,
|
||||
play: "",
|
||||
operator: "",
|
||||
operatorId: null,
|
||||
@@ -302,6 +315,10 @@ function formatPlainMoney(value: number, currencyCode: string | null | undefined
|
||||
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
||||
}
|
||||
|
||||
function formatUsagePercent(ratio: number | null | undefined): string {
|
||||
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
|
||||
}
|
||||
|
||||
function optionText(...parts: Array<string | number | null | undefined>): string {
|
||||
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
||||
}
|
||||
@@ -314,6 +331,7 @@ function reportListParams(filters: ReportFilters, page: number, perPage: number)
|
||||
date_to: filters.dateTo || undefined,
|
||||
player_id: filters.playerId ?? undefined,
|
||||
play_code: filters.play.trim() || undefined,
|
||||
agent_node_id: filters.agentNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -328,23 +346,22 @@ function parsePositiveInteger(value: string): number | null {
|
||||
|
||||
async function resolveDraw(
|
||||
filters: ReportFilters,
|
||||
t: (key: string, options?: { ns?: string; drawNo?: string }) => string,
|
||||
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
|
||||
): Promise<{ id: number; draw_no: string }> {
|
||||
if (filters.drawId && filters.drawNo.trim()) {
|
||||
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
|
||||
if (filters.drawId != null && filters.drawId > 0) {
|
||||
const drawNo = filters.drawNo.trim();
|
||||
return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) };
|
||||
}
|
||||
|
||||
const drawNo = filters.drawNo.trim();
|
||||
if (!drawNo) {
|
||||
throw new LotteryApiBizError(t("validation.drawNoRequired", { ns: "reports" }), -1, null);
|
||||
throw new LotteryApiBizError(messages.drawNoRequired, -1, null);
|
||||
}
|
||||
|
||||
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
||||
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
||||
if (!matched) {
|
||||
throw new LotteryApiBizError(t("validation.drawNoNotFound", { ns: "reports", drawNo }), -1, {
|
||||
drawNo,
|
||||
});
|
||||
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
|
||||
}
|
||||
return { id: matched.id, draw_no: matched.draw_no };
|
||||
}
|
||||
@@ -403,10 +420,131 @@ export function ReportsConsole() {
|
||||
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
||||
const [jobRefreshToken, setJobRefreshToken] = useState(0);
|
||||
const [search, setSearch] = useState<SearchState>(emptySearch);
|
||||
const [playOptions, setPlayOptions] = useState<PlayOption[]>([]);
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
|
||||
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
|
||||
|
||||
const pageScopedLabel = useCallback(
|
||||
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
|
||||
[t],
|
||||
);
|
||||
|
||||
const previewColumns = useMemo<PreviewColumns>(() => {
|
||||
switch (selectedReport.key) {
|
||||
case "draw_profit":
|
||||
return {
|
||||
primary: t("preview.columns.drawProfit.primary"),
|
||||
secondary: t("preview.columns.drawProfit.secondary"),
|
||||
metricA: t("preview.columns.drawProfit.metricA"),
|
||||
metricB: t("preview.columns.drawProfit.metricB"),
|
||||
metricC: t("preview.columns.drawProfit.metricC"),
|
||||
status: t("preview.columns.drawProfit.status"),
|
||||
extra: t("preview.columns.drawProfit.extra"),
|
||||
time: t("preview.columns.drawProfit.time"),
|
||||
};
|
||||
case "daily_profit":
|
||||
return {
|
||||
primary: t("preview.columns.dailyProfit.primary"),
|
||||
secondary: t("preview.columns.dailyProfit.secondary"),
|
||||
metricA: t("preview.columns.dailyProfit.metricA"),
|
||||
metricB: t("preview.columns.dailyProfit.metricB"),
|
||||
metricC: t("preview.columns.dailyProfit.metricC"),
|
||||
status: t("preview.columns.dailyProfit.status"),
|
||||
extra: t("preview.columns.dailyProfit.extra"),
|
||||
time: t("preview.columns.dailyProfit.time"),
|
||||
};
|
||||
case "player_win_loss":
|
||||
return {
|
||||
primary: t("preview.columns.playerWinLoss.primary"),
|
||||
secondary: t("agentColumns.agent", { ns: "common" }),
|
||||
metricA: t("preview.columns.playerWinLoss.metricA"),
|
||||
metricB: t("preview.columns.playerWinLoss.metricB"),
|
||||
metricC: t("preview.columns.playerWinLoss.metricC"),
|
||||
status: t("preview.columns.playerWinLoss.status"),
|
||||
extra: t("preview.columns.playerWinLoss.extra"),
|
||||
time: t("preview.columns.playerWinLoss.time"),
|
||||
};
|
||||
case "player_transfer":
|
||||
return {
|
||||
primary: t("preview.columns.playerTransfer.primary"),
|
||||
secondary: t("preview.columns.playerTransfer.secondary"),
|
||||
metricA: t("preview.columns.playerTransfer.metricA"),
|
||||
metricB: t("preview.columns.playerTransfer.metricB"),
|
||||
metricC: t("preview.columns.playerTransfer.metricC"),
|
||||
status: t("preview.columns.playerTransfer.status"),
|
||||
extra: t("preview.columns.playerTransfer.extra"),
|
||||
time: t("preview.columns.playerTransfer.time"),
|
||||
};
|
||||
case "hot_number_risk":
|
||||
return {
|
||||
primary: t("preview.columns.hotNumberRisk.primary"),
|
||||
secondary: t("preview.columns.hotNumberRisk.secondary"),
|
||||
metricA: t("preview.columns.hotNumberRisk.metricA"),
|
||||
metricB: t("preview.columns.hotNumberRisk.metricB"),
|
||||
metricC: t("preview.columns.hotNumberRisk.metricC"),
|
||||
status: t("preview.columns.hotNumberRisk.status"),
|
||||
extra: t("preview.columns.hotNumberRisk.extra"),
|
||||
time: t("preview.columns.hotNumberRisk.time"),
|
||||
};
|
||||
case "play_dimension":
|
||||
return {
|
||||
primary: t("preview.columns.playDimension.primary"),
|
||||
secondary: t("preview.columns.playDimension.secondary"),
|
||||
metricA: t("preview.columns.playDimension.metricA"),
|
||||
metricB: t("preview.columns.playDimension.metricB"),
|
||||
metricC: t("preview.columns.playDimension.metricC"),
|
||||
status: t("preview.columns.playDimension.status"),
|
||||
extra: t("preview.columns.playDimension.extra"),
|
||||
time: t("preview.columns.playDimension.time"),
|
||||
};
|
||||
case "sold_out_number":
|
||||
return {
|
||||
primary: t("preview.columns.soldOut.primary"),
|
||||
secondary: t("preview.columns.soldOut.secondary"),
|
||||
metricA: t("preview.columns.soldOut.metricA"),
|
||||
metricB: t("preview.columns.soldOut.metricB"),
|
||||
metricC: t("preview.columns.soldOut.metricC"),
|
||||
status: t("preview.columns.soldOut.status"),
|
||||
extra: t("preview.columns.soldOut.extra"),
|
||||
time: t("preview.columns.soldOut.time"),
|
||||
};
|
||||
case "rebate_commission":
|
||||
return {
|
||||
primary: t("preview.columns.rebateCommission.primary"),
|
||||
secondary: t("preview.columns.rebateCommission.secondary"),
|
||||
metricA: t("preview.columns.rebateCommission.metricA"),
|
||||
metricB: t("preview.columns.rebateCommission.metricB"),
|
||||
metricC: t("preview.columns.rebateCommission.metricC"),
|
||||
status: t("preview.columns.rebateCommission.status"),
|
||||
extra: t("preview.columns.rebateCommission.extra"),
|
||||
time: t("preview.columns.rebateCommission.time"),
|
||||
};
|
||||
case "admin_audit":
|
||||
return {
|
||||
primary: t("preview.columns.adminAudit.primary"),
|
||||
secondary: t("preview.columns.adminAudit.secondary"),
|
||||
metricA: t("preview.columns.adminAudit.metricA"),
|
||||
metricB: t("preview.columns.adminAudit.metricB"),
|
||||
metricC: t("preview.columns.adminAudit.metricC"),
|
||||
status: t("preview.columns.adminAudit.status"),
|
||||
extra: t("preview.columns.adminAudit.extra"),
|
||||
time: t("preview.columns.adminAudit.time"),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
primary: t("preview.columns.primary"),
|
||||
secondary: t("preview.columns.secondary"),
|
||||
metricA: t("preview.columns.metricA"),
|
||||
metricB: t("preview.columns.metricB"),
|
||||
metricC: t("preview.columns.metricC"),
|
||||
status: t("preview.columns.status"),
|
||||
extra: t("preview.columns.extra"),
|
||||
time: t("preview.columns.time"),
|
||||
};
|
||||
}
|
||||
}, [selectedReport.key, t]);
|
||||
|
||||
const exportFileBase = useMemo(() => {
|
||||
const segments: string[] = [selectedReport.key];
|
||||
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
||||
@@ -419,29 +557,6 @@ export function ReportsConsole() {
|
||||
return normalizeFilenamePart(segments.join("-")) || selectedReport.key;
|
||||
}, [selectedReport.key, filters]);
|
||||
|
||||
const loadPlayOptions = useCallback(async () => {
|
||||
try {
|
||||
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
|
||||
setPlayOptions(
|
||||
getCachedAdminPlayTypes().map((item) => ({
|
||||
code: item.play_code,
|
||||
label: optionText(
|
||||
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item),
|
||||
item.play_code,
|
||||
),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setPlayOptions([]);
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadPlayOptions();
|
||||
});
|
||||
}, [loadPlayOptions]);
|
||||
|
||||
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
|
||||
setSearch((prev) => ({ ...prev, loading: true }));
|
||||
try {
|
||||
@@ -481,7 +596,11 @@ export function ReportsConsole() {
|
||||
try {
|
||||
switch (selectedReport.key) {
|
||||
case "draw_profit": {
|
||||
const draw = await resolveDraw(filters, t);
|
||||
const draw = await resolveDraw(filters, {
|
||||
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||
drawNoNotFound: (drawNo) =>
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const summary = await getAdminDrawFinanceSummary(draw.id);
|
||||
setResult({
|
||||
key: "draw_profit",
|
||||
@@ -519,10 +638,10 @@ export function ReportsConsole() {
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||
{
|
||||
label: t("preview.stats.houseGross"),
|
||||
label: pageScopedLabel("houseGross"),
|
||||
value: formatPlainMoney(totalGross, "NPR"),
|
||||
tone: totalGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
@@ -548,7 +667,7 @@ export function ReportsConsole() {
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{
|
||||
label: t("preview.stats.houseGross"),
|
||||
label: pageScopedLabel("houseGross"),
|
||||
value: formatPlainMoney(
|
||||
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
||||
"NPR",
|
||||
@@ -592,17 +711,21 @@ export function ReportsConsole() {
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
|
||||
{ label: t("preview.stats.transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
|
||||
{ label: pageScopedLabel("transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
|
||||
{ label: pageScopedLabel("transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "hot_number_risk": {
|
||||
if (!filters.number.trim()) {
|
||||
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
|
||||
throw new LotteryApiBizError(tRef.current("validation.drawNoNumberRequired"), -1, null);
|
||||
}
|
||||
const draw = await resolveDraw(filters, t);
|
||||
const draw = await resolveDraw(filters, {
|
||||
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||
drawNoNotFound: (drawNo) =>
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
||||
const rows: ExportRow[] = [
|
||||
{
|
||||
@@ -642,14 +765,18 @@ export function ReportsConsole() {
|
||||
summary: [
|
||||
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
|
||||
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
|
||||
{ label: t("preview.stats.usage"), value: detail.pool.usage_ratio == null ? "-" : `${detail.pool.usage_ratio}%`, tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
||||
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
||||
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "sold_out_number": {
|
||||
const draw = await resolveDraw(filters, t);
|
||||
const draw = await resolveDraw(filters, {
|
||||
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
||||
drawNoNotFound: (drawNo) =>
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
||||
const rows = payload.items.map((item) => ({
|
||||
draw_id: payload.draw_id,
|
||||
@@ -695,8 +822,8 @@ export function ReportsConsole() {
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
|
||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
|
||||
],
|
||||
});
|
||||
break;
|
||||
@@ -717,8 +844,8 @@ export function ReportsConsole() {
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
|
||||
{ label: t("preview.stats.orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
|
||||
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
|
||||
{ label: pageScopedLabel("orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
@@ -761,15 +888,15 @@ export function ReportsConsole() {
|
||||
}
|
||||
default:
|
||||
setResult(null);
|
||||
setError(t("loadFailed"));
|
||||
setError(tRef.current("loadFailed"));
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(null);
|
||||
setError(err instanceof LotteryApiBizError ? err.message : t("loadFailed"));
|
||||
setError(err instanceof LotteryApiBizError ? err.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewReports, filters, page, perPage, selectedReport, t]);
|
||||
}, [canViewReports, filters, page, perPage, selectedReport]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -928,7 +1055,7 @@ export function ReportsConsole() {
|
||||
/>
|
||||
<div className="mt-2 max-h-64 overflow-auto">
|
||||
{search.loading ? (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
<AdminLoadingInline className="py-2" />
|
||||
) : null}
|
||||
{!search.loading && kind === "draw" ? (
|
||||
search.draws.map((item) => (
|
||||
@@ -1067,11 +1194,7 @@ export function ReportsConsole() {
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
@@ -1148,7 +1271,7 @@ export function ReportsConsole() {
|
||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
|
||||
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
|
||||
<TableCell>v{result.raw.pool.version}</TableCell>
|
||||
</TableRow>
|
||||
{result.raw.logs.items.map((item) => (
|
||||
@@ -1176,7 +1299,7 @@ export function ReportsConsole() {
|
||||
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
|
||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
|
||||
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
|
||||
<TableCell>v{item.version}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
@@ -1201,7 +1324,10 @@ export function ReportsConsole() {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.player_id}>
|
||||
<TableCell className="font-medium">{item.username}</TableCell>
|
||||
<TableCell>ID {item.player_id}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{adminAgentDisplayLabel(item)}
|
||||
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
|
||||
@@ -1305,6 +1431,13 @@ export function ReportsConsole() {
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
{selectedReport.category === "profit" || selectedReport.category === "wallet" ? (
|
||||
<AdminAgentFilter
|
||||
id="report-agent-filter"
|
||||
value={filters.agentNodeId}
|
||||
onChange={(id) => setFilters((prev) => ({ ...prev, agentNodeId: id }))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div className="flex shrink-0 gap-2">
|
||||
@@ -1395,17 +1528,20 @@ export function ReportsConsole() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950">
|
||||
{t("preview.summaryScopeHint")}
|
||||
</div>
|
||||
<Table id="reports-preview-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.secondary")}</TableHead>
|
||||
<TableHead className="text-center">{t("preview.columns.metricA")}</TableHead>
|
||||
<TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
|
||||
<TableHead className="text-center">{t("preview.columns.metricC")}</TableHead>
|
||||
<TableHead>{t("preview.columns.status")}</TableHead>
|
||||
<TableHead>{t("preview.columns.extra")}</TableHead>
|
||||
<TableHead>{t("preview.columns.time")}</TableHead>
|
||||
<TableHead>{previewColumns.primary}</TableHead>
|
||||
<TableHead>{previewColumns.secondary}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
||||
<TableHead>{previewColumns.status}</TableHead>
|
||||
<TableHead>{previewColumns.extra}</TableHead>
|
||||
<TableHead>{previewColumns.time}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderTable()}</TableBody>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDraw } from "@/api/admin-draws";
|
||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display";
|
||||
@@ -11,6 +14,7 @@ import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||
|
||||
export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "draws"]);
|
||||
const tRef = useTranslationRef(["risk", "draws"]);
|
||||
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -21,24 +25,22 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {
|
||||
setDraw(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("drawInfoLoadFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("drawInfoLoadFailed");
|
||||
setError(msg);
|
||||
setDraw(null);
|
||||
}
|
||||
}, [drawId, t]);
|
||||
}, [drawId]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId]);
|
||||
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
|
||||
if (!draw) {
|
||||
return <p className="text-sm text-muted-foreground">{t("loadingDraw")}</p>;
|
||||
return <AdminLoadingInline className="py-4" label={t("loadingDraw")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Shield } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -13,6 +15,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -48,6 +51,7 @@ const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
|
||||
export function RiskIndexConsole() {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskIndex");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||
@@ -81,19 +85,17 @@ export function RiskIndexConsole() {
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadDrawListFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDrawListFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, drawNoQuery, statusFilter, t]);
|
||||
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, drawNoQuery, statusFilter]);
|
||||
|
||||
function applySearch(): void {
|
||||
setDrawNoQuery(drawNoInput.trim());
|
||||
@@ -174,10 +176,7 @@ export function RiskIndexConsole() {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-shell">
|
||||
<Table id="risk-index-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -188,7 +187,9 @@ export function RiskIndexConsole() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={4} />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
@@ -222,7 +223,6 @@ export function RiskIndexConsole() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<AdminListPaginationFooter
|
||||
selectId="risk-index-draws-per-page"
|
||||
total={total}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -11,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -48,6 +51,7 @@ function riskActionFilterLabel(
|
||||
|
||||
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskLockLogs");
|
||||
useAdminCurrencyCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
@@ -79,19 +83,17 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadLogsFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadLogsFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber, t]);
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber]);
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card">
|
||||
@@ -157,10 +159,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-lock-logs-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
@@ -175,6 +174,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminRiskLockLogRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
@@ -214,7 +214,6 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminRiskPoolDetail } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -35,6 +38,7 @@ export function RiskPoolDetailConsole({
|
||||
number4d: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskPoolDetail", { number: number4d });
|
||||
useAdminCurrencyCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
@@ -53,19 +57,17 @@ export function RiskPoolDetailConsole({
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadDetailFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadDetailFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawId, number4d, page, perPage, t]);
|
||||
}, [drawId, number4d, page, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId, number4d, page, perPage]);
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
@@ -87,7 +89,7 @@ export function RiskPoolDetailConsole({
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Lock, Unlock } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -17,6 +19,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -81,6 +84,7 @@ export function RiskPoolsConsole({
|
||||
allowSortChange = false,
|
||||
}: RiskPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [
|
||||
@@ -115,19 +119,17 @@ export function RiskPoolsConsole({
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : t("loadPoolsFailed");
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("loadPoolsFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawId, filter, number, page, perPage, sort, t]);
|
||||
}, [drawId, filter, number, page, perPage, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId, filter, number, page, perPage, sort]);
|
||||
|
||||
const handleManualStatus = useCallback(
|
||||
async (row: AdminRiskPoolRow) => {
|
||||
@@ -240,10 +242,7 @@ export function RiskPoolsConsole({
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-pools-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
@@ -258,6 +257,7 @@ export function RiskPoolsConsole({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
|
||||
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
|
||||
const acting = actingNumber === row.normalized_number;
|
||||
@@ -359,7 +359,6 @@ export function RiskPoolsConsole({
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ConfirmDialog />
|
||||
|
||||
92
src/modules/settings/admin-settings-data-context.tsx
Normal file
92
src/modules/settings/admin-settings-data-context.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAdminSettings } from "@/api/admin-settings";
|
||||
|
||||
/** 系统设置页一次拉取的分组(避免各卡片重复 GET) */
|
||||
export const SYSTEM_SETTINGS_GROUPS = ["draw", "settlement", "frontend", "wallet"] as const;
|
||||
|
||||
function mergeItemsToKv(
|
||||
items: { key: string; value: unknown }[],
|
||||
into: Record<string, unknown>,
|
||||
): void {
|
||||
for (const item of items) {
|
||||
into[item.key] = item.value;
|
||||
}
|
||||
}
|
||||
|
||||
type AdminSettingsDataContextValue = {
|
||||
kv: Record<string, unknown> | null;
|
||||
loading: boolean;
|
||||
reload: () => Promise<void>;
|
||||
patchKv: (updates: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
const AdminSettingsDataContext = createContext<AdminSettingsDataContextValue | null>(null);
|
||||
|
||||
export function AdminSettingsDataProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation(["config"]);
|
||||
const [kv, setKv] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
SYSTEM_SETTINGS_GROUPS.map((group) => getAdminSettings(group)),
|
||||
);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const res of responses) {
|
||||
mergeItemsToKv(res.items, merged);
|
||||
}
|
||||
setKv(merged);
|
||||
} catch {
|
||||
toast.error(tRef.current("system.loadFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const patchKv = useCallback((updates: Record<string, unknown>) => {
|
||||
setKv((prev) => (prev === null ? { ...updates } : { ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ kv, loading, reload, patchKv }),
|
||||
[kv, loading, reload, patchKv],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminSettingsDataContext.Provider value={value}>{children}</AdminSettingsDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAdminSettingsData(): AdminSettingsDataContextValue {
|
||||
const ctx = useContext(AdminSettingsDataContext);
|
||||
if (ctx === null) {
|
||||
throw new Error("useAdminSettingsData must be used within AdminSettingsDataProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalAdminSettingsData(): AdminSettingsDataContextValue | null {
|
||||
return useContext(AdminSettingsDataContext);
|
||||
}
|
||||
36
src/modules/settings/components/settings-section-actions.tsx
Normal file
36
src/modules/settings/components/settings-section-actions.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SettingsSectionActions({
|
||||
dirty,
|
||||
loading,
|
||||
saving,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveLabel,
|
||||
savingLabel,
|
||||
discardLabel,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
saveLabel: string;
|
||||
savingLabel: string;
|
||||
discardLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
|
||||
{saving ? savingLabel : saveLabel}
|
||||
</Button>
|
||||
{dirty ? (
|
||||
<Button type="button" variant="outline" onClick={onDiscard} disabled={saving}>
|
||||
{discardLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -68,6 +70,7 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
|
||||
|
||||
export function CurrencySettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers"]);
|
||||
const tRef = useTranslationRef(["config", "adminUsers"]);
|
||||
const exportLabels = useExportLabels("currencies");
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
|
||||
@@ -96,18 +99,16 @@ export function CurrencySettingsPanel() {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError
|
||||
? error.message
|
||||
: t("currencies.loadFailed", { ns: "config" }),
|
||||
: tRef.current("currencies.loadFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canManage, t]);
|
||||
}, [canManage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [canManage]);
|
||||
|
||||
function openCreate(): void {
|
||||
setMode("create");
|
||||
|
||||
99
src/modules/settings/hooks/use-settings-section.ts
Normal file
99
src/modules/settings/hooks/use-settings-section.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { updateAdminSettingsBatch, type AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { setCachedApplyRebateToPayoutSetting } from "@/lib/admin-settlement-settings-cache";
|
||||
import { useAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
|
||||
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function useSettingsSection<TDraft>(options: {
|
||||
initialDraft: TDraft;
|
||||
fromKv: (kv: Record<string, unknown>) => TDraft;
|
||||
buildDirtyItems: (draft: TDraft, saved: TDraft) => AdminSettingBatchItem[];
|
||||
saveSuccessKey: string;
|
||||
saveFailedKey: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["config"]);
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const { kv, loading, patchKv } = useAdminSettingsData();
|
||||
const [draft, setDraft] = useState(options.initialDraft);
|
||||
const [saved, setSaved] = useState(options.initialDraft);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const hydratedRef = useRef(false);
|
||||
|
||||
const { fromKv, buildDirtyItems, saveSuccessKey, saveFailedKey } = options;
|
||||
|
||||
const dirty = useMemo(
|
||||
() => buildDirtyItems(draft, saved).length > 0,
|
||||
[draft, saved, buildDirtyItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (kv === null) {
|
||||
return;
|
||||
}
|
||||
const next = fromKv(kv);
|
||||
setDraft(next);
|
||||
setSaved(next);
|
||||
hydratedRef.current = true;
|
||||
}, [kv, fromKv]);
|
||||
|
||||
const updateField = <K extends keyof TDraft>(field: K, value: TDraft[K]) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setDraft(saved);
|
||||
};
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
const items = buildDirtyItems(draft, saved);
|
||||
if (items.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAdminSettingsBatch(items);
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const item of items) {
|
||||
updates[item.key] = item.value;
|
||||
if (item.key === SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT) {
|
||||
setCachedApplyRebateToPayoutSetting(Boolean(item.value));
|
||||
}
|
||||
}
|
||||
patchKv(updates);
|
||||
setSaved(draft);
|
||||
toast.success(tRef.current(saveSuccessKey, { ns: "config" }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError
|
||||
? error.message
|
||||
: tRef.current(saveFailedKey, { ns: "config" }),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sectionLoading = loading || (kv !== null && !hydratedRef.current);
|
||||
|
||||
return {
|
||||
draft,
|
||||
saved,
|
||||
loading: sectionLoading,
|
||||
saving,
|
||||
dirty,
|
||||
updateField,
|
||||
discard,
|
||||
save,
|
||||
};
|
||||
}
|
||||
148
src/modules/settings/panels/currency-format-settings-panel.tsx
Normal file
148
src/modules/settings/panels/currency-format-settings-panel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface CurrencyFormatDraft {
|
||||
currencyDisplayDecimals: string;
|
||||
currencyDecimalSeparator: string;
|
||||
currencyThousandsSeparator: string;
|
||||
}
|
||||
|
||||
const INITIAL: CurrencyFormatDraft = {
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): CurrencyFormatDraft {
|
||||
return {
|
||||
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
||||
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
||||
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: CurrencyFormatDraft, saved: CurrencyFormatDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
if (draft.currencyDisplayDecimals !== saved.currencyDisplayDecimals) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
||||
value: Math.max(
|
||||
0,
|
||||
Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (draft.currencyDecimalSeparator !== saved.currencyDecimalSeparator) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
||||
value: (draft.currencyDecimalSeparator || ".").slice(0, 1),
|
||||
});
|
||||
}
|
||||
if (draft.currencyThousandsSeparator !== saved.currencyThousandsSeparator) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
||||
value: (draft.currencyThousandsSeparator || ",").slice(0, 1),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function CurrencyFormatSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveCurrencyFormatSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("system.sections.currencyFormat", { ns: "config" })}
|
||||
description={t("system.sections.currencyFormatDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-display-decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={draft.currencyDisplayDecimals}
|
||||
onChange={(e) => updateField("currencyDisplayDecimals", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-decimal-separator"
|
||||
value={draft.currencyDecimalSeparator}
|
||||
onChange={(e) => updateField("currencyDecimalSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-thousands-separator"
|
||||
value={draft.currencyThousandsSeparator}
|
||||
onChange={(e) => updateField("currencyThousandsSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveCurrencyFormatTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveCurrencyFormatDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
234
src/modules/settings/panels/draw-settings-panel.tsx
Normal file
234
src/modules/settings/panels/draw-settings-panel.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface DrawDraft {
|
||||
defaultCurrency: string;
|
||||
drawIntervalMinutes: string;
|
||||
drawBettingWindowSeconds: string;
|
||||
drawCloseBeforeDrawSeconds: string;
|
||||
drawBufferDrawsAhead: string;
|
||||
requireManualReview: boolean;
|
||||
cooldownMinutes: string;
|
||||
}
|
||||
|
||||
const INITIAL: DrawDraft = {
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): DrawDraft {
|
||||
return {
|
||||
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
||||
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
||||
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
||||
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
||||
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
const push = (key: string, value: unknown, changed: boolean) => {
|
||||
if (changed) {
|
||||
items.push({ key, value });
|
||||
}
|
||||
};
|
||||
|
||||
push(
|
||||
DRAW_KEYS.DEFAULT_CURRENCY,
|
||||
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
||||
draft.defaultCurrency !== saved.defaultCurrency,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
||||
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
||||
draft.drawIntervalMinutes !== saved.drawIntervalMinutes,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
||||
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
||||
draft.drawBettingWindowSeconds !== saved.drawBettingWindowSeconds,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
||||
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
||||
draft.drawCloseBeforeDrawSeconds !== saved.drawCloseBeforeDrawSeconds,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
||||
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
||||
draft.drawBufferDrawsAhead !== saved.drawBufferDrawsAhead,
|
||||
);
|
||||
push(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview, draft.requireManualReview !== saved.requireManualReview);
|
||||
push(
|
||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||
draft.cooldownMinutes !== saved.cooldownMinutes,
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function DrawSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveDrawSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("system.sections.draw", { ns: "config" })}
|
||||
description={t("system.sections.drawDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.requireManualReview}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("requireManualReview", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="default-currency" className="text-sm font-medium">
|
||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="default-currency"
|
||||
value={draft.defaultCurrency}
|
||||
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
|
||||
disabled={loading || saving}
|
||||
maxLength={16}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-interval-minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
step="1"
|
||||
value={draft.drawIntervalMinutes}
|
||||
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-betting-window-seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
step="1"
|
||||
value={draft.drawBettingWindowSeconds}
|
||||
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-close-before-seconds"
|
||||
type="number"
|
||||
min="5"
|
||||
step="1"
|
||||
value={draft.drawCloseBeforeDrawSeconds}
|
||||
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
|
||||
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-buffer-ahead"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={draft.drawBufferDrawsAhead}
|
||||
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="cooldown-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveDrawTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveDrawDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
src/modules/settings/panels/frontend-settings-panel.tsx
Normal file
138
src/modules/settings/panels/frontend-settings-panel.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { FRONTEND_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface FrontendDraft {
|
||||
playRulesHtmlZh: string;
|
||||
playRulesHtmlEn: string;
|
||||
playRulesHtmlNe: string;
|
||||
}
|
||||
|
||||
const INITIAL: FrontendDraft = {
|
||||
playRulesHtmlZh: "",
|
||||
playRulesHtmlEn: "",
|
||||
playRulesHtmlNe: "",
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): FrontendDraft {
|
||||
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
|
||||
return {
|
||||
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
||||
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
||||
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: FrontendDraft, saved: FrontendDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
if (draft.playRulesHtmlZh !== saved.playRulesHtmlZh) {
|
||||
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_ZH, value: draft.playRulesHtmlZh });
|
||||
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML, value: draft.playRulesHtmlZh });
|
||||
}
|
||||
if (draft.playRulesHtmlEn !== saved.playRulesHtmlEn) {
|
||||
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_EN, value: draft.playRulesHtmlEn });
|
||||
}
|
||||
if (draft.playRulesHtmlNe !== saved.playRulesHtmlNe) {
|
||||
items.push({ key: FRONTEND_KEYS.PLAY_RULES_HTML_NE, value: draft.playRulesHtmlNe });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function FrontendSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveFrontendSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("system.fields.playRulesHtml", { ns: "config" })}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
||||
</p>
|
||||
<Tabs defaultValue="zh" className="w-full">
|
||||
<TabsList className="w-full max-w-md">
|
||||
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="zh" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-zh"
|
||||
value={draft.playRulesHtmlZh}
|
||||
onChange={(e) => updateField("playRulesHtmlZh", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="en" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-en"
|
||||
value={draft.playRulesHtmlEn}
|
||||
onChange={(e) => updateField("playRulesHtmlEn", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="ne" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-ne"
|
||||
value={draft.playRulesHtmlNe}
|
||||
onChange={(e) => updateField("playRulesHtmlNe", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
149
src/modules/settings/panels/settlement-settings-panel.tsx
Normal file
149
src/modules/settings/panels/settlement-settings-panel.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface SettlementDraft {
|
||||
autoSettlement: boolean;
|
||||
autoApprove: boolean;
|
||||
autoPayout: boolean;
|
||||
applyRebateToPayout: boolean;
|
||||
}
|
||||
|
||||
const INITIAL: SettlementDraft = {
|
||||
autoSettlement: true,
|
||||
autoApprove: true,
|
||||
autoPayout: true,
|
||||
applyRebateToPayout: false,
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): SettlementDraft {
|
||||
return {
|
||||
autoSettlement: Boolean(kv[SETTLEMENT_KEYS.AUTO_SETTLEMENT] ?? true),
|
||||
autoApprove: Boolean(kv[SETTLEMENT_KEYS.AUTO_APPROVE] ?? true),
|
||||
autoPayout: Boolean(kv[SETTLEMENT_KEYS.AUTO_PAYOUT] ?? true),
|
||||
applyRebateToPayout: Boolean(kv[SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirtyItems(draft: SettlementDraft, saved: SettlementDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
if (draft.autoSettlement !== saved.autoSettlement) {
|
||||
items.push({ key: SETTLEMENT_KEYS.AUTO_SETTLEMENT, value: draft.autoSettlement });
|
||||
}
|
||||
if (draft.autoApprove !== saved.autoApprove) {
|
||||
items.push({ key: SETTLEMENT_KEYS.AUTO_APPROVE, value: draft.autoApprove });
|
||||
}
|
||||
if (draft.autoPayout !== saved.autoPayout) {
|
||||
items.push({ key: SETTLEMENT_KEYS.AUTO_PAYOUT, value: draft.autoPayout });
|
||||
}
|
||||
if (draft.applyRebateToPayout !== saved.applyRebateToPayout) {
|
||||
items.push({ key: SETTLEMENT_KEYS.APPLY_REBATE_TO_PAYOUT, value: draft.applyRebateToPayout });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function SettlementSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
initialDraft: INITIAL,
|
||||
fromKv,
|
||||
buildDirtyItems: buildItems,
|
||||
saveSuccessKey: "system.saveSettlementSuccess",
|
||||
saveFailedKey: "system.saveFailed",
|
||||
});
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("system.sections.settlement", { ns: "config" })}
|
||||
description={t("system.sections.settlementDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoSettlement}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoSettlement", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoApprove}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoApprove", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1 pr-4">
|
||||
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.applyRebateToPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
saving={saving}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveSettlementTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveSettlementDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
saveLabel={t("actions.save", { ns: "adminUsers" })}
|
||||
savingLabel={t("saving", { ns: "adminUsers" })}
|
||||
discardLabel={t("system.discard", { ns: "config" })}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/modules/settings/settings-keys.ts
Normal file
38
src/modules/settings/settings-keys.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const DRAW_GROUP = "draw";
|
||||
export const SETTLEMENT_GROUP = "settlement";
|
||||
export const FRONTEND_GROUP = "frontend";
|
||||
export const WALLET_GROUP = "wallet";
|
||||
|
||||
export const DRAW_KEYS = {
|
||||
DEFAULT_CURRENCY: "currency.default_code",
|
||||
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
||||
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
||||
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
||||
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
||||
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
||||
} as const;
|
||||
|
||||
export const SETTLEMENT_KEYS = {
|
||||
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
||||
AUTO_APPROVE: "settlement.auto_approve_on_tick",
|
||||
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
|
||||
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
|
||||
} as const;
|
||||
|
||||
export const FRONTEND_KEYS = {
|
||||
PLAY_RULES_HTML: "frontend.play_rules_html",
|
||||
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
|
||||
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
|
||||
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
|
||||
} as const;
|
||||
|
||||
export const WALLET_KEYS = {
|
||||
IN_MIN: "wallet.transfer_in_min_minor",
|
||||
IN_MAX: "wallet.transfer_in_max_minor",
|
||||
OUT_MIN: "wallet.transfer_out_min_minor",
|
||||
OUT_MAX: "wallet.transfer_out_max_minor",
|
||||
} as const;
|
||||
@@ -1,568 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSetting,
|
||||
} from "@/api/admin-settings";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminSettingsDataProvider } from "@/modules/settings/admin-settings-data-context";
|
||||
import { CurrencyFormatSettingsPanel } from "@/modules/settings/panels/currency-format-settings-panel";
|
||||
import { DrawSettingsPanel } from "@/modules/settings/panels/draw-settings-panel";
|
||||
import { FrontendSettingsPanel } from "@/modules/settings/panels/frontend-settings-panel";
|
||||
import { SettlementSettingsPanel } from "@/modules/settings/panels/settlement-settings-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const DRAW_GROUP = "draw";
|
||||
const SETTLEMENT_GROUP = "settlement";
|
||||
|
||||
const DRAW_KEYS = {
|
||||
DEFAULT_CURRENCY: "currency.default_code",
|
||||
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
||||
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
||||
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
||||
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
||||
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
||||
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
||||
AUTO_APPROVE: "settlement.auto_approve_on_tick",
|
||||
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
|
||||
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
|
||||
} as const;
|
||||
|
||||
const FRONTEND_GROUP = "frontend";
|
||||
const FRONTEND_KEYS = {
|
||||
PLAY_RULES_HTML: "frontend.play_rules_html",
|
||||
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
|
||||
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
|
||||
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
|
||||
} as const;
|
||||
|
||||
interface RuntimeDraft {
|
||||
defaultCurrency: string;
|
||||
drawIntervalMinutes: string;
|
||||
drawBettingWindowSeconds: string;
|
||||
drawCloseBeforeDrawSeconds: string;
|
||||
drawBufferDrawsAhead: string;
|
||||
requireManualReview: boolean;
|
||||
cooldownMinutes: string;
|
||||
currencyDisplayDecimals: string;
|
||||
currencyDecimalSeparator: string;
|
||||
currencyThousandsSeparator: string;
|
||||
autoSettlement: boolean;
|
||||
autoApprove: boolean;
|
||||
autoPayout: boolean;
|
||||
applyRebateToPayout: boolean;
|
||||
playRulesHtmlZh: string;
|
||||
playRulesHtmlEn: string;
|
||||
playRulesHtmlNe: string;
|
||||
}
|
||||
|
||||
const RUNTIME_DRAFT_KEYS = [
|
||||
"defaultCurrency",
|
||||
"drawIntervalMinutes",
|
||||
"drawBettingWindowSeconds",
|
||||
"drawCloseBeforeDrawSeconds",
|
||||
"drawBufferDrawsAhead",
|
||||
"requireManualReview",
|
||||
"cooldownMinutes",
|
||||
"currencyDisplayDecimals",
|
||||
"currencyDecimalSeparator",
|
||||
"currencyThousandsSeparator",
|
||||
"autoSettlement",
|
||||
"autoApprove",
|
||||
"autoPayout",
|
||||
"applyRebateToPayout",
|
||||
] as const satisfies readonly (keyof RuntimeDraft)[];
|
||||
|
||||
const FRONTEND_DRAFT_KEYS = [
|
||||
"playRulesHtmlZh",
|
||||
"playRulesHtmlEn",
|
||||
"playRulesHtmlNe",
|
||||
] as const satisfies readonly (keyof RuntimeDraft)[];
|
||||
|
||||
function isSectionDirty<const K extends keyof RuntimeDraft>(
|
||||
draft: RuntimeDraft,
|
||||
saved: RuntimeDraft,
|
||||
keys: readonly K[],
|
||||
): boolean {
|
||||
return keys.some((key) => draft[key] !== saved[key]);
|
||||
}
|
||||
|
||||
function applyDraftFields<const K extends keyof RuntimeDraft>(
|
||||
base: RuntimeDraft,
|
||||
source: RuntimeDraft,
|
||||
keys: readonly K[],
|
||||
): RuntimeDraft {
|
||||
const next = { ...base };
|
||||
for (const key of keys) {
|
||||
next[key] = source[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function SaveActions({
|
||||
dirty,
|
||||
loading,
|
||||
saving,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveLabel,
|
||||
savingLabel,
|
||||
discardLabel,
|
||||
}: {
|
||||
dirty: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
saveLabel: string;
|
||||
savingLabel: string;
|
||||
discardLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
|
||||
{saving ? savingLabel : saveLabel}
|
||||
</Button>
|
||||
{dirty ? (
|
||||
<Button type="button" variant="outline" onClick={onDiscard}>
|
||||
{discardLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemSettingsScreen() {
|
||||
const { t } = useTranslation(["common", "config", "adminUsers"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const [draft, setDraft] = useState<RuntimeDraft>({
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
autoSettlement: true,
|
||||
autoApprove: true,
|
||||
autoPayout: true,
|
||||
applyRebateToPayout: false,
|
||||
playRulesHtmlZh: "",
|
||||
playRulesHtmlEn: "",
|
||||
playRulesHtmlNe: "",
|
||||
});
|
||||
const [saved, setSaved] = useState<RuntimeDraft>({
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
currencyDisplayDecimals: "2",
|
||||
currencyDecimalSeparator: ".",
|
||||
currencyThousandsSeparator: ",",
|
||||
autoSettlement: true,
|
||||
autoApprove: true,
|
||||
autoPayout: true,
|
||||
applyRebateToPayout: false,
|
||||
playRulesHtmlZh: "",
|
||||
playRulesHtmlEn: "",
|
||||
playRulesHtmlNe: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingRuntime, setSavingRuntime] = useState(false);
|
||||
const [savingFrontend, setSavingFrontend] = useState(false);
|
||||
|
||||
const runtimeDirty = useMemo(
|
||||
() => isSectionDirty(draft, saved, RUNTIME_DRAFT_KEYS),
|
||||
[draft, saved],
|
||||
);
|
||||
const frontendDirty = useMemo(
|
||||
() => isSectionDirty(draft, saved, FRONTEND_DRAFT_KEYS),
|
||||
[draft, saved],
|
||||
);
|
||||
const anyDirty = runtimeDirty || frontendDirty;
|
||||
const saving = savingRuntime || savingFrontend;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [drawRes, settlementRes, frontendRes] = await Promise.all([
|
||||
getAdminSettings(DRAW_GROUP),
|
||||
getAdminSettings(SETTLEMENT_GROUP),
|
||||
getAdminSettings(FRONTEND_GROUP),
|
||||
]);
|
||||
|
||||
const kv: Record<string, unknown> = {};
|
||||
for (const item of [...drawRes.items, ...settlementRes.items, ...frontendRes.items]) {
|
||||
kv[item.key] = item.value;
|
||||
}
|
||||
|
||||
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
|
||||
const nextDraft: RuntimeDraft = {
|
||||
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
||||
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
||||
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
||||
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
||||
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
||||
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
||||
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
||||
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
|
||||
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
|
||||
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
|
||||
applyRebateToPayout: Boolean(kv[DRAW_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
|
||||
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
||||
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
||||
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSaved(nextDraft);
|
||||
} catch {
|
||||
toast.error(t("system.loadFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const discardSection = <const K extends keyof RuntimeDraft>(keys: readonly K[]) => {
|
||||
setDraft((prev) => applyDraftFields(prev, saved, keys));
|
||||
};
|
||||
|
||||
const handleSaveRuntime = async () => {
|
||||
setSavingRuntime(true);
|
||||
try {
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DEFAULT_CURRENCY,
|
||||
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
||||
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
||||
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
||||
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
||||
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
||||
);
|
||||
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
||||
Math.max(0, Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2)),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
||||
(draft.currencyDecimalSeparator || ".").slice(0, 1),
|
||||
);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
||||
(draft.currencyThousandsSeparator || ",").slice(0, 1),
|
||||
);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
|
||||
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
|
||||
toast.success(t("system.saveRuntimeSuccess", { ns: "config" }));
|
||||
setSaved((prev) => applyDraftFields(prev, draft, RUNTIME_DRAFT_KEYS));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setSavingRuntime(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFrontend = async () => {
|
||||
setSavingFrontend(true);
|
||||
try {
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
|
||||
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
|
||||
toast.success(t("system.saveFrontendSuccess", { ns: "config" }));
|
||||
setSaved((prev) => applyDraftFields(prev, draft, FRONTEND_DRAFT_KEYS));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setSavingFrontend(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveLabel = t("actions.save", { ns: "adminUsers" });
|
||||
const savingLabel = t("saving", { ns: "adminUsers" });
|
||||
const discardLabel = t("system.discard", { ns: "config" });
|
||||
function SystemSettingsContent() {
|
||||
const { t } = useTranslation(["config"]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
{anyDirty ? (
|
||||
<div className="sticky top-0 z-20 -mx-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 shadow-sm backdrop-blur-sm">
|
||||
<p className="text-sm font-medium text-amber-950 dark:text-amber-100">
|
||||
{t("system.unsavedChanges", { ns: "config" })}
|
||||
{runtimeDirty && frontendDirty
|
||||
? ` · ${t("system.title", { ns: "config" })} / ${t("system.frontendConfig", { ns: "config" })}`
|
||||
: runtimeDirty
|
||||
? ` · ${t("system.title", { ns: "config" })}`
|
||||
: ` · ${t("system.frontendConfig", { ns: "config" })}`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AdminPageCard
|
||||
title={t("system.title", { ns: "config" })}
|
||||
description={t("system.description", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.requireManualReview}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("requireManualReview", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="default-currency" className="text-sm font-medium">
|
||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="default-currency"
|
||||
value={draft.defaultCurrency}
|
||||
onChange={(e) => updateDraft("defaultCurrency", e.target.value.toUpperCase())}
|
||||
disabled={loading || saving}
|
||||
maxLength={16}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-interval-minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
step="1"
|
||||
value={draft.drawIntervalMinutes}
|
||||
onChange={(e) => updateDraft("drawIntervalMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-betting-window-seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
step="1"
|
||||
value={draft.drawBettingWindowSeconds}
|
||||
onChange={(e) => updateDraft("drawBettingWindowSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
|
||||
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-close-before-seconds"
|
||||
type="number"
|
||||
min="5"
|
||||
step="1"
|
||||
value={draft.drawCloseBeforeDrawSeconds}
|
||||
onChange={(e) => updateDraft("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
|
||||
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-buffer-ahead"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={draft.drawBufferDrawsAhead}
|
||||
onChange={(e) => updateDraft("drawBufferDrawsAhead", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-display-decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={draft.currencyDisplayDecimals}
|
||||
onChange={(e) => updateDraft("currencyDisplayDecimals", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-decimal-separator"
|
||||
value={draft.currencyDecimalSeparator}
|
||||
onChange={(e) => updateDraft("currencyDecimalSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
||||
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="currency-thousands-separator"
|
||||
value={draft.currencyThousandsSeparator}
|
||||
onChange={(e) => updateDraft("currencyThousandsSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoSettlement}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoSettlement", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoApprove}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoApprove", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("autoPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1 pr-4">
|
||||
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.applyRebateToPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateDraft("applyRebateToPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="cooldown-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SaveActions
|
||||
dirty={runtimeDirty}
|
||||
loading={loading}
|
||||
saving={savingRuntime}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveRuntimeTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveRuntimeDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => handleSaveRuntime(),
|
||||
})
|
||||
}
|
||||
onDiscard={() => discardSection(RUNTIME_DRAFT_KEYS)}
|
||||
saveLabel={saveLabel}
|
||||
savingLabel={savingLabel}
|
||||
discardLabel={discardLabel}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
<DrawSettingsPanel />
|
||||
<CurrencyFormatSettingsPanel />
|
||||
<SettlementSettingsPanel />
|
||||
|
||||
<AdminPageCard
|
||||
title={t("wallet.title", { ns: "config" })}
|
||||
@@ -571,73 +25,15 @@ export function SystemSettingsScreen() {
|
||||
<WalletConfigDocScreen embedded />
|
||||
</AdminPageCard>
|
||||
|
||||
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("system.fields.playRulesHtml", { ns: "config" })}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
||||
</p>
|
||||
<Tabs defaultValue="zh" className="w-full">
|
||||
<TabsList className="w-full max-w-md">
|
||||
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
||||
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="zh" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-zh"
|
||||
value={draft.playRulesHtmlZh}
|
||||
onChange={(e) => updateDraft("playRulesHtmlZh", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="en" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-en"
|
||||
value={draft.playRulesHtmlEn}
|
||||
onChange={(e) => updateDraft("playRulesHtmlEn", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="ne" className="mt-3">
|
||||
<Textarea
|
||||
id="play-rules-html-ne"
|
||||
value={draft.playRulesHtmlNe}
|
||||
onChange={(e) => updateDraft("playRulesHtmlNe", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
placeholder="<div>...</div>"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SaveActions
|
||||
dirty={frontendDirty}
|
||||
loading={loading}
|
||||
saving={savingFrontend}
|
||||
onSave={() =>
|
||||
requestConfirm({
|
||||
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
|
||||
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => handleSaveFrontend(),
|
||||
})
|
||||
}
|
||||
onDiscard={() => discardSection(FRONTEND_DRAFT_KEYS)}
|
||||
saveLabel={saveLabel}
|
||||
savingLabel={savingLabel}
|
||||
discardLabel={discardLabel}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
|
||||
<ConfirmDialog />
|
||||
<FrontendSettingsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemSettingsScreen() {
|
||||
return (
|
||||
<AdminSettingsDataProvider>
|
||||
<SystemSettingsContent />
|
||||
</AdminSettingsDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -14,6 +16,8 @@ import {
|
||||
postAdminRejectSettlementBatch,
|
||||
} from "@/api/admin-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
@@ -37,6 +41,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
@@ -72,6 +77,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
|
||||
|
||||
export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const tRef = useTranslationRef(["settlement", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
useAdminCurrencyCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
@@ -84,6 +90,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
@@ -95,18 +103,22 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
try {
|
||||
const [s, d] = await Promise.all([
|
||||
getAdminSettlementBatch(batchId),
|
||||
getAdminSettlementBatchDetails(batchId, { page, per_page: perPage }),
|
||||
getAdminSettlementBatchDetails(batchId, {
|
||||
page,
|
||||
per_page: perPage,
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
}),
|
||||
]);
|
||||
setSummary(s);
|
||||
setDetails(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setSummary(null);
|
||||
setDetails(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [batchId, page, perPage, t]);
|
||||
}, [batchId, page, perPage, appliedAgentNodeId]);
|
||||
|
||||
async function runAction(label: string, action: () => Promise<unknown>): Promise<void> {
|
||||
setActing(label);
|
||||
@@ -173,10 +185,9 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => void load(), 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [batchId, page, perPage, appliedAgentNodeId]);
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
@@ -322,7 +333,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : loading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("loadingSummary")}</p>
|
||||
<AdminLoadingState minHeight="6rem" className="py-4" label={t("loadingSummary")} />
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
@@ -332,11 +343,30 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<CardContent>
|
||||
{details ? (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<AdminAgentFilter
|
||||
id="settlement-details-agent-filter"
|
||||
className="w-[14rem]"
|
||||
value={agentNodeId}
|
||||
onChange={setAgentNodeId}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("search", { ns: "common", defaultValue: "Search" })}
|
||||
</Button>
|
||||
</div>
|
||||
<Table id={`settlement-details-table-${batchId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead>{t("matchedTier")}</TableHead>
|
||||
<TableHead className="text-center">{t("regularPayout")}</TableHead>
|
||||
@@ -348,6 +378,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(r.play_code)}</TableCell>
|
||||
<AdminAgentIdentityCells row={r} />
|
||||
<AdminPlayerIdentityCells row={r} />
|
||||
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||
@@ -379,7 +410,11 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{loading ? t("loadingDetails") : t("states.noData", { ns: "common" })}
|
||||
{loading ? (
|
||||
<AdminLoadingInline label={t("loadingDetails")} />
|
||||
) : (
|
||||
t("states.noData", { ns: "common" })
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Eye, HandCoins, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
postAdminPayoutSettlementBatch,
|
||||
postAdminRejectSettlementBatch,
|
||||
} from "@/api/admin-settlement";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
@@ -45,6 +48,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
@@ -87,6 +91,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
|
||||
|
||||
export function SettlementBatchesConsole() {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const tRef = useTranslationRef(["settlement", "common"]);
|
||||
const exportLabels = useExportLabels("settlementBatches");
|
||||
const profile = useAdminProfile();
|
||||
useAdminCurrencyCatalog();
|
||||
@@ -99,6 +104,8 @@ export function SettlementBatchesConsole() {
|
||||
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
||||
const [draftStatus, setDraftStatus] = useState(STATUS_ALL);
|
||||
const [appliedStatus, setAppliedStatus] = useState(STATUS_ALL);
|
||||
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [actingId, setActingId] = useState<number | null>(null);
|
||||
@@ -117,24 +124,25 @@ export function SettlementBatchesConsole() {
|
||||
appliedStatus === STATUS_ALL || appliedStatus.trim() === ""
|
||||
? undefined
|
||||
: appliedStatus.trim(),
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setError(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => void load(), 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
|
||||
const applyFilters = () => {
|
||||
setAppliedDrawNo(draftDrawNo);
|
||||
setAppliedStatus(draftStatus);
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@@ -193,6 +201,12 @@ export function SettlementBatchesConsole() {
|
||||
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
<AdminAgentFilter
|
||||
id="settlement-batches-agent-filter"
|
||||
className="admin-list-field sm:w-[14rem]"
|
||||
value={agentNodeId}
|
||||
onChange={setAgentNodeId}
|
||||
/>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
|
||||
{t("drawNo")}
|
||||
@@ -234,10 +248,7 @@ export function SettlementBatchesConsole() {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-0">
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-shell">
|
||||
<Table id="settlement-batches-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -253,6 +264,7 @@ export function SettlementBatchesConsole() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={9} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||
@@ -333,7 +345,6 @@ export function SettlementBatchesConsole() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="settlement-batches-per-page"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -58,6 +63,7 @@ const TICKET_STATUS_OPTIONS = [
|
||||
|
||||
type TicketFilters = {
|
||||
siteCode: string;
|
||||
agentNodeId: number | undefined;
|
||||
playerQuery: string;
|
||||
drawNo: string;
|
||||
numberKeyword: string;
|
||||
@@ -68,6 +74,7 @@ type TicketFilters = {
|
||||
|
||||
const emptyTicketFilters: TicketFilters = {
|
||||
siteCode: "",
|
||||
agentNodeId: undefined,
|
||||
playerQuery: "",
|
||||
drawNo: "",
|
||||
numberKeyword: "",
|
||||
@@ -101,6 +108,7 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const tRef = useTranslationRef(["tickets", "common"]);
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const exportLabels = useExportLabels("tickets");
|
||||
@@ -131,6 +139,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
per_page: perPage,
|
||||
...query,
|
||||
site_code: applied.siteCode.trim() || undefined,
|
||||
agent_node_id: applied.agentNodeId,
|
||||
draw_no: applied.drawNo.trim() || undefined,
|
||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||
number: applied.numberKeyword.trim() || undefined,
|
||||
@@ -139,24 +148,23 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applied, page, perPage, t]);
|
||||
}, [applied, page, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [applied, page, perPage]);
|
||||
|
||||
const runSearch = () => {
|
||||
setErr(null);
|
||||
setApplied({
|
||||
...draft,
|
||||
siteCode: draft.siteCode.trim(),
|
||||
agentNodeId: draft.agentNodeId,
|
||||
playerQuery: draft.playerQuery.trim(),
|
||||
drawNo: draft.drawNo.trim(),
|
||||
numberKeyword: draft.numberKeyword.trim(),
|
||||
@@ -222,6 +230,12 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminAgentFilter
|
||||
id="tickets-agent-filter"
|
||||
className="admin-list-field sm:w-[14rem]"
|
||||
value={draft.agentNodeId}
|
||||
onChange={(id) => setDraft((current) => ({ ...current, agentNodeId: id }))}
|
||||
/>
|
||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||
<Label htmlFor="pt-player" className="sm:shrink-0">
|
||||
{t("playerId")}
|
||||
@@ -344,17 +358,14 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
) : null}
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
{loading || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="tickets-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead>{t("orderNo")}</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
@@ -370,9 +381,11 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={16} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={15} className="text-muted-foreground">
|
||||
<TableCell colSpan={16} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -384,6 +397,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
return (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
@@ -413,19 +427,21 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-tickets-per-page"
|
||||
total={data.total}
|
||||
page={data.page}
|
||||
lastPage={Math.max(1, data.last_page)}
|
||||
perPage={data.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-tickets-per-page"
|
||||
total={data.total}
|
||||
page={data.page}
|
||||
lastPage={Math.max(1, data.last_page)}
|
||||
perPage={data.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Copy, RotateCcw, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
} from "@/api/admin-wallet";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
|
||||
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -24,6 +28,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -125,6 +130,7 @@ function statusLabelT(status: string, t: (key: string) => string): string {
|
||||
}
|
||||
|
||||
type TransferFilters = {
|
||||
agentNodeId: number | undefined;
|
||||
playerId: string;
|
||||
playerAccount: string;
|
||||
transferNo: string;
|
||||
@@ -136,6 +142,7 @@ type TransferFilters = {
|
||||
};
|
||||
|
||||
const emptyTransferFilters: TransferFilters = {
|
||||
agentNodeId: undefined,
|
||||
playerId: "",
|
||||
playerAccount: "",
|
||||
transferNo: "",
|
||||
@@ -147,6 +154,7 @@ const emptyTransferFilters: TransferFilters = {
|
||||
};
|
||||
|
||||
type TxnFilters = {
|
||||
agentNodeId: number | undefined;
|
||||
playerId: string;
|
||||
playerAccount: string;
|
||||
txnNo: string;
|
||||
@@ -159,6 +167,7 @@ type TxnFilters = {
|
||||
};
|
||||
|
||||
const emptyTxnFilters: TxnFilters = {
|
||||
agentNodeId: undefined,
|
||||
playerId: "",
|
||||
playerAccount: "",
|
||||
txnNo: "",
|
||||
@@ -306,6 +315,7 @@ function TransferOrderRowActions({
|
||||
|
||||
export function TransferOrdersPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
|
||||
@@ -386,21 +396,20 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
created_from: applied.createdFrom.trim() || undefined,
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
status: applied.statusCsv.trim() || undefined,
|
||||
agent_node_id: applied.agentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, applied, t]);
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
@@ -421,6 +430,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<AdminAgentFilter
|
||||
id="transfer-agent-filter"
|
||||
value={draft.agentNodeId}
|
||||
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
|
||||
/>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
|
||||
<Input
|
||||
@@ -531,11 +545,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table id="wallet-transfer-orders-table" className="table-fixed">
|
||||
@@ -543,6 +553,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<TableRow>
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
|
||||
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead className="w-14">{t("direction")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
||||
@@ -554,9 +565,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={13} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="text-muted-foreground">
|
||||
<TableCell colSpan={13} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -569,6 +582,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell>{row.direction}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
@@ -605,19 +619,21 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transfer-orders-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transfer-orders-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -629,6 +645,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
|
||||
export function WalletTxnsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("walletTransactions");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
||||
@@ -660,21 +677,20 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
biz_type: applied.bizType.trim() || undefined,
|
||||
status: applied.statusCsv.trim() || undefined,
|
||||
agent_node_id: applied.agentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, applied, t]);
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
@@ -694,6 +710,11 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<AdminAgentFilter
|
||||
id="wallet-txn-agent-filter"
|
||||
value={draft.agentNodeId}
|
||||
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
|
||||
/>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-no">{t("txnNo")}</Label>
|
||||
<Input
|
||||
@@ -835,11 +856,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table id="wallet-transactions-table" className="table-fixed">
|
||||
@@ -847,6 +864,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
<TableRow>
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
|
||||
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
||||
@@ -856,9 +874,11 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={11} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
<TableCell colSpan={11} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -871,6 +891,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
|
||||
<TableCell className="tabular-nums text-xs">
|
||||
@@ -891,19 +912,21 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transactions-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transactions-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -913,6 +936,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
|
||||
export function PlayerWalletPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("playerWallets");
|
||||
useAdminCurrencyCatalog();
|
||||
const [playerId, setPlayerId] = useState("");
|
||||
@@ -923,7 +947,7 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
const query = useCallback(async () => {
|
||||
const id = Number(playerId.trim());
|
||||
if (Number.isNaN(id) || id < 1) {
|
||||
setErr(t("invalidPlayerId"));
|
||||
setErr(tRef.current("invalidPlayerId"));
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
@@ -933,12 +957,12 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
const d = await getAdminPlayerWallets(id);
|
||||
setResult(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : t("queryFailed"));
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("queryFailed"));
|
||||
setResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [playerId, t]);
|
||||
}, [playerId]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user