feat(admin, i18n): enhance admin dashboard and user management with new features and translations
Added the ability to filter admin dashboard data by site code and agent node ID, improving data retrieval capabilities. Introduced new functions for fetching dashboard data based on these parameters. Updated the admin users and roles management components to reflect these changes. Enhanced multi-language support by adding new translations for agent management and permission levels in English, Nepali, and Chinese, ensuring a consistent user experience across the admin interface.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { 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";
|
||||
@@ -19,13 +19,13 @@ import {
|
||||
} from "@/api/admin-users";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -55,9 +55,9 @@ function permissionGroupLabel(key: string, fallback: string, t: (key: string) =>
|
||||
return translated === `permissionGroups.${key}` ? fallback : translated;
|
||||
}
|
||||
|
||||
function permissionLabel(slug: string, fallback: string, t: (key: string) => string): string {
|
||||
const translated = t(`permissionNames.${slug}`);
|
||||
return translated === `permissionNames.${slug}` ? fallback : translated;
|
||||
function permissionPackageLabel(key: string, fallback: string, t: (key: string) => string): string {
|
||||
const translated = t(`permissionLevels.${key}`);
|
||||
return translated === `permissionLevels.${key}` ? fallback : translated;
|
||||
}
|
||||
|
||||
export function AdminRolesConsole(): React.ReactElement {
|
||||
@@ -71,7 +71,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [rolePermissionOpen, setRolePermissionOpen] = useState(false);
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
||||
@@ -94,22 +93,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
() => roles.find((role) => role.id === selectedRoleId) ?? null,
|
||||
[roles, selectedRoleId],
|
||||
);
|
||||
const selectedPermissionSet = useMemo(
|
||||
() => new Set(draftRolePermissions),
|
||||
[draftRolePermissions],
|
||||
);
|
||||
|
||||
const directPermissionGroups = useMemo(() => {
|
||||
const groups = catalog?.permission_menu_groups;
|
||||
if (groups && groups.length > 0) {
|
||||
return groups;
|
||||
}
|
||||
const flatPermissions = catalog?.permissions ?? [];
|
||||
if (flatPermissions.length > 0) {
|
||||
return [{ key: "all", label: t("allPermissions"), permissions: flatPermissions }];
|
||||
}
|
||||
return [];
|
||||
}, [catalog, t]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -134,36 +117,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function isDirectGroupOpen(key: string): boolean {
|
||||
return directMenuExpanded[key] === true;
|
||||
}
|
||||
|
||||
function toggleDirectGroup(key: string): void {
|
||||
setDirectMenuExpanded((prev) => {
|
||||
const wasOpen = prev[key] === true;
|
||||
return { ...prev, [key]: !wasOpen };
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRolePermission(slug: string, checked: boolean): void {
|
||||
setDraftRolePermissions((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((value) => value !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleGroupPermissions(slugs: string[], checked: boolean): void {
|
||||
setDraftRolePermissions((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, ...slugs])).sort();
|
||||
}
|
||||
const remove = new Set(slugs);
|
||||
return prev.filter((value) => !remove.has(value));
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateRole(): void {
|
||||
setRoleMode("create");
|
||||
setEditingRoleId(null);
|
||||
@@ -187,7 +140,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
function openRolePermissionEditor(role: AdminRoleRow): void {
|
||||
setSelectedRoleId(role.id);
|
||||
setDraftRolePermissions([...role.permission_slugs].sort());
|
||||
setDirectMenuExpanded({});
|
||||
setRolePermissionOpen(true);
|
||||
}
|
||||
|
||||
@@ -205,20 +157,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
function getGroupSelectionState(slugs: string[]): boolean | "indeterminate" {
|
||||
if (slugs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const selectedCount = slugs.filter((slug) => selectedPermissionSet.has(slug)).length;
|
||||
if (selectedCount === 0) {
|
||||
return false;
|
||||
}
|
||||
if (selectedCount === slugs.length) {
|
||||
return true;
|
||||
}
|
||||
return "indeterminate";
|
||||
}
|
||||
|
||||
async function saveRolePermissions(): Promise<void> {
|
||||
if (!selectedRole) {
|
||||
return;
|
||||
@@ -342,7 +280,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<TableHead>{t("roleTable.status")}</TableHead>
|
||||
<TableHead>{t("roleTable.users")}</TableHead>
|
||||
<TableHead>{t("roleTable.permissions")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("roleTable.actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("roleTable.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -379,7 +317,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
||||
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageRoles ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
@@ -432,86 +370,15 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
||||
{directPermissionGroups.map((group) => {
|
||||
const isOpen = isDirectGroupOpen(group.key);
|
||||
const groupSlugs = group.permissions.map((permission) => permission.slug);
|
||||
const selectedCount = group.permissions.filter((permission) =>
|
||||
selectedPermissionSet.has(permission.slug),
|
||||
).length;
|
||||
const checkedState = getGroupSelectionState(groupSlugs);
|
||||
|
||||
return (
|
||||
<div key={group.key} className={cn("border-b border-border/60 last:border-b-0", isOpen && "bg-muted/10")}>
|
||||
<div className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => toggleDirectGroup(group.key)}
|
||||
aria-label={isOpen ? t("aria.collapse", { ns: "common" }) : t("aria.expand", { ns: "common" })}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn("size-4 transition-transform", isOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={checkedState === true}
|
||||
indeterminate={checkedState === "indeterminate"}
|
||||
onCheckedChange={(value) => toggleGroupPermissions(groupSlugs, value === true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => toggleDirectGroup(group.key)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[15px] font-medium leading-6 text-foreground">
|
||||
{permissionGroupLabel(group.key, group.label, t)}
|
||||
</span>
|
||||
{group.permissions.length > 0 ? (
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{group.permissions.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||
{selectedCount}/{group.permissions.length}
|
||||
</span>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<div className="pb-2">
|
||||
{group.permissions.map((permission, index) => (
|
||||
<label
|
||||
key={permission.slug}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-muted/20",
|
||||
index === 0 && "border-t border-border/50",
|
||||
selectedPermissionSet.has(permission.slug) && "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<span className="mt-1 flex h-4 w-8 shrink-0 items-center">
|
||||
<span className="h-px w-full bg-border/70" />
|
||||
</span>
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={selectedPermissionSet.has(permission.slug)}
|
||||
onCheckedChange={(value) =>
|
||||
toggleRolePermission(permission.slug, value === true)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 whitespace-normal break-words leading-6 text-foreground">
|
||||
{permissionLabel(permission.slug, permission.name, t)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<AdminPermissionPackageSelector
|
||||
catalog={catalog}
|
||||
selectedSlugs={draftRolePermissions}
|
||||
onChange={setDraftRolePermissions}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
emptyText={t("states.noData", { ns: "common" })}
|
||||
heightClassName="h-[min(56vh,520px)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t bg-background px-5 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => handleRolePermissionDialogOpenChange(false)}>
|
||||
|
||||
@@ -320,6 +320,12 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("modelGuide", {
|
||||
defaultValue:
|
||||
"账号层只绑定角色,不直接分配功能权限;具体权限请到“角色管理”维护。",
|
||||
})}
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">
|
||||
@@ -372,7 +378,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
|
||||
<TableHead>{t("table.roles")}</TableHead>
|
||||
<TableHead>{t("table.effective")}</TableHead>
|
||||
<TableHead className="w-14 whitespace-nowrap text-center">{t("table.actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -414,7 +420,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageUsers ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, KeyRound, Pencil, Plus, Search, Shield, Trash2, Users } from "lucide-react";
|
||||
import { ChevronRight, KeyRound, Pencil, Plus, Search, Trash2, Users } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
@@ -9,7 +9,6 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteAgentNode,
|
||||
deleteAgentRole,
|
||||
getAgentNodeAdminUsers,
|
||||
getAgentNodeRoles,
|
||||
@@ -17,9 +16,7 @@ import {
|
||||
postAgentAdminUser,
|
||||
postAgentNode,
|
||||
postAgentRole,
|
||||
putAgentAdminUserRoles,
|
||||
putAgentNode,
|
||||
putAgentRole,
|
||||
putAgentRolePermissions,
|
||||
getAgentDelegationGrants,
|
||||
putAgentDelegationGrants,
|
||||
@@ -27,6 +24,7 @@ import {
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { getAdminUserPermissionCatalog } from "@/api/admin-users";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
|
||||
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";
|
||||
@@ -75,6 +73,20 @@ import type { AgentDelegationGrantRow, AgentNodeRow } from "@/types/api/admin-ag
|
||||
import type { AdminPermissionCatalogData, AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function permissionGroupLabel(key: string, fallback: string, t: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
const translated = t(`adminUsers:permissionGroups.${key}`);
|
||||
return translated === `adminUsers:permissionGroups.${key}` ? fallback : translated;
|
||||
}
|
||||
|
||||
function permissionPackageLabel(
|
||||
key: string,
|
||||
fallback: string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const translated = t(`adminUsers:permissionLevels.${key}`);
|
||||
return translated === `adminUsers:permissionLevels.${key}` ? fallback : translated;
|
||||
}
|
||||
|
||||
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
const out: AgentNodeRow[] = [];
|
||||
const walk = (list: AgentNodeRow[]) => {
|
||||
@@ -257,6 +269,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
selected !== null &&
|
||||
!selected.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === selected.parent_id);
|
||||
const defaultDetailTab = canViewRoles ? "roles" : canViewUsers ? "users" : canManageDelegation ? "delegation" : "roles";
|
||||
|
||||
const assignablePermissionSlugs = useMemo(() => {
|
||||
const mine = new Set(profile?.permissions ?? []);
|
||||
@@ -279,6 +292,24 @@ export function AgentsConsole(): React.ReactElement {
|
||||
return slugs;
|
||||
}, [catalog, profile?.permissions]);
|
||||
|
||||
const selectedRoleCountText = useMemo(
|
||||
() => t("roles.selectedCount", {
|
||||
defaultValue: "已选 {{selected}} / {{total}} 项",
|
||||
selected: rolePerms.length,
|
||||
total: assignablePermissionSlugs.length,
|
||||
}),
|
||||
[assignablePermissionSlugs.length, rolePerms.length, t],
|
||||
);
|
||||
|
||||
const selectedDraftCountText = useMemo(
|
||||
() => t("roles.selectedCount", {
|
||||
defaultValue: "已选 {{selected}} / {{total}} 项",
|
||||
selected: draftPerms.length,
|
||||
total: assignablePermissionSlugs.length,
|
||||
}),
|
||||
[assignablePermissionSlugs.length, draftPerms.length, t],
|
||||
);
|
||||
|
||||
const loadTree = useCallback(async (siteId?: number | null) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
@@ -514,7 +545,11 @@ export function AgentsConsole(): React.ReactElement {
|
||||
onValueChange={(v) => setAdminSiteId(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder={t("siteLabel")} />
|
||||
<SelectValue placeholder={t("siteLabel")}>
|
||||
{adminSiteId !== null
|
||||
? siteOptions.find((opt) => opt.id === adminSiteId)?.label ?? adminSiteId
|
||||
: undefined}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((opt) => (
|
||||
@@ -526,6 +561,12 @@ export function AgentsConsole(): React.ReactElement {
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("modelGuide", {
|
||||
defaultValue:
|
||||
"代理层负责数据范围(Scope)与授权上限(Ceiling),账号权限请通过角色分配。",
|
||||
})}
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
@@ -588,7 +629,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
{!selected ? (
|
||||
<p className="text-sm text-muted-foreground">{t("selectNode")}</p>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs defaultValue={defaultDetailTab}>
|
||||
<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>
|
||||
@@ -619,7 +660,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
|
||||
<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 ? (
|
||||
@@ -640,97 +680,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
) : 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">
|
||||
@@ -750,68 +699,70 @@ export function AgentsConsole(): React.ReactElement {
|
||||
</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>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("roles.slug")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("roles.userCount")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-[80px] shadow-[-1px_0_0_rgba(203,213,225,0.7)]" />
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{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>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
@@ -835,24 +786,26 @@ export function AgentsConsole(): React.ReactElement {
|
||||
</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>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("users.username")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("users.roles")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
@@ -862,40 +815,42 @@ export function AgentsConsole(): React.ReactElement {
|
||||
{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>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("delegation.permission")}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("delegation.canDelegate")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
@@ -956,33 +911,38 @@ export function AgentsConsole(): React.ReactElement {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
||||
<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 className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<div className="rounded-xl border bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
<p>{t("roles.permissionSubsetHint")}</p>
|
||||
<p className="mt-2 font-medium text-foreground">{selectedRoleCountText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<AdminPermissionPackageSelector
|
||||
catalog={catalog}
|
||||
selectedSlugs={rolePerms}
|
||||
onChange={setRolePerms}
|
||||
selectableSlugs={assignablePermissionSlugs}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
helperText={t("roles.permissionSubsetHint")}
|
||||
summaryText={selectedRoleCountText}
|
||||
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
@@ -996,25 +956,24 @@ export function AgentsConsole(): React.ReactElement {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
||||
<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 className="rounded-xl border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
{selectedDraftCountText}
|
||||
</div>
|
||||
<AdminPermissionPackageSelector
|
||||
catalog={catalog}
|
||||
selectedSlugs={draftPerms}
|
||||
onChange={setDraftPerms}
|
||||
selectableSlugs={assignablePermissionSlugs}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
helperText={t("roles.permissionSubsetHint")}
|
||||
summaryText={selectedDraftCountText}
|
||||
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setPermDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
|
||||
@@ -230,13 +230,15 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.id}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{row.operator_type}:{row.operator_id}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.module_code}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.action_code}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.target_type ?? "—"} {row.target_id ?? ""}
|
||||
{t(`operatorTypes.${row.operator_type}`, {
|
||||
ns: "audit",
|
||||
defaultValue: row.operator_type,
|
||||
})}
|
||||
:{row.operator_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{row.module_label}</TableCell>
|
||||
<TableCell className="text-sm">{row.action_label}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{row.target_label}</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
|
||||
@@ -512,7 +512,7 @@ export function RiskCapDocScreen() {
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -556,7 +556,7 @@ export function RiskCapDocScreen() {
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canEditDraft ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={saving}
|
||||
|
||||
@@ -467,11 +467,13 @@ export function DashboardAgentRankingCard({
|
||||
export function DashboardAnalyticsPanel({
|
||||
enabled,
|
||||
playOptions,
|
||||
scope,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
playOptions: { code: string; label: string }[];
|
||||
scope: { siteCode: string; agentNodeId: number | undefined };
|
||||
}): ReactNode {
|
||||
const analytics = useDashboardAnalytics({ enabled, playOptions });
|
||||
const analytics = useDashboardAnalytics({ enabled, playOptions, scope });
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<DashboardAnalyticsMain analytics={analytics} />
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Scale,
|
||||
} from "lucide-react";
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { getAdminDashboardByScope } from "@/api/admin-dashboard";
|
||||
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
@@ -188,7 +188,7 @@ export function DashboardConsole(): ReactElement {
|
||||
setAbnormalTransferTotal(null);
|
||||
|
||||
try {
|
||||
const d = await getAdminDashboard();
|
||||
const d = await getAdminDashboardByScope({});
|
||||
setHall(d.hall);
|
||||
|
||||
if (d.resolved_draw != null) {
|
||||
@@ -242,7 +242,11 @@ export function DashboardConsole(): ReactElement {
|
||||
|
||||
const pendingReviewTotal = resultBatchQueue?.pending_review_total ?? 0;
|
||||
|
||||
const analytics = useDashboardAnalytics({ enabled: canFinance, playOptions });
|
||||
const analytics = useDashboardAnalytics({
|
||||
enabled: canFinance,
|
||||
playOptions,
|
||||
scope: { siteCode: "", agentNodeId: undefined },
|
||||
});
|
||||
const showAnalytics = canFinance;
|
||||
|
||||
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
|
||||
|
||||
@@ -1088,7 +1088,7 @@ export function PlatformLifetimePayoutSnapshot({
|
||||
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);
|
||||
const jackpot = coerceAdminMinor(finance.total_jackpot_minor);
|
||||
if (payout > 0 && win + jackpot === 0) {
|
||||
win = payout;
|
||||
}
|
||||
|
||||
@@ -61,9 +61,11 @@ export function formatDashboardSignedMoneyMinor(minor: number, currencyCode: str
|
||||
export function useDashboardAnalytics({
|
||||
enabled,
|
||||
playOptions,
|
||||
scope,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
playOptions: { code: string; label: string }[];
|
||||
scope: { siteCode: string; agentNodeId: number | undefined };
|
||||
}) {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
@@ -94,6 +96,8 @@ export function useDashboardAnalytics({
|
||||
period,
|
||||
metric: "overview",
|
||||
play_code: playCode !== "" ? playCode : undefined,
|
||||
site_code: scope.siteCode || undefined,
|
||||
agent_node_id: scope.agentNodeId,
|
||||
...(period === "custom" ? { date_from: customFrom, date_to: customTo } : {}),
|
||||
});
|
||||
setData(payload);
|
||||
@@ -110,11 +114,11 @@ export function useDashboardAnalytics({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled, period, playCode, customFrom, customTo]);
|
||||
}, [enabled, period, playCode, customFrom, customTo, scope.agentNodeId, scope.siteCode]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [enabled, period, playCode, customFrom, customTo]);
|
||||
}, [enabled, period, playCode, customFrom, customTo, scope.agentNodeId, scope.siteCode]);
|
||||
|
||||
const currency = data?.currency_code ?? null;
|
||||
const summary = data?.summary;
|
||||
|
||||
@@ -243,7 +243,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
<TableHead>{t("batchId")}</TableHead>
|
||||
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
||||
<TableHead>{t("numberCount")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -252,7 +252,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
<TableCell className="font-mono text-xs">{b.id}</TableCell>
|
||||
<TableCell>v{b.result_version}</TableCell>
|
||||
<TableCell>{b.items.length}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageDraw ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={discardingBatchId === b.id}
|
||||
|
||||
@@ -21,7 +21,6 @@ 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";
|
||||
@@ -111,7 +110,6 @@ 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);
|
||||
@@ -298,12 +296,6 @@ 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")}
|
||||
@@ -358,7 +350,7 @@ export function DrawsIndexConsole() {
|
||||
onClick={() => {
|
||||
setAppliedDrawNo(draftDrawNo);
|
||||
setAppliedStatus(draftStatus);
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
setAppliedAgentNodeId(undefined);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -370,7 +362,6 @@ export function DrawsIndexConsole() {
|
||||
onClick={() => {
|
||||
setDraftDrawNo("");
|
||||
setDraftStatus("");
|
||||
setAgentNodeId(undefined);
|
||||
setAppliedDrawNo("");
|
||||
setAppliedStatus("");
|
||||
setAppliedAgentNodeId(undefined);
|
||||
@@ -382,18 +373,6 @@ export function DrawsIndexConsole() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data?.schedule ? (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>
|
||||
{t("scheduleTimezoneHint", {
|
||||
tz: "Local",
|
||||
interval: data.schedule.interval_minutes,
|
||||
})}
|
||||
</p>
|
||||
{canManageDraw ? <p>{t("listActionsHint")}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
@@ -419,7 +398,7 @@ export function DrawsIndexConsole() {
|
||||
<TableHead className="text-center">{t("betTotal")}</TableHead>
|
||||
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
||||
<TableHead className="text-center">{t("profitLoss")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -472,7 +451,7 @@ export function DrawsIndexConsole() {
|
||||
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
|
||||
@@ -365,7 +365,7 @@ export function IntegrationSitesConsole() {
|
||||
<TableHead>{t("integrationSites.columns.name")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.status")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("integrationSites.columns.actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("integrationSites.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -385,7 +385,7 @@ export function IntegrationSitesConsole() {
|
||||
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
|
||||
{row.wallet_api_url ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
busy={exportBusyId === row.id}
|
||||
actions={[
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
@@ -18,7 +19,6 @@ 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";
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
@@ -91,18 +90,17 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const exportLabels = useExportLabels("players");
|
||||
const profile = useAdminProfile();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const keywordFromUrl = (searchParams.get("keyword") ?? "").trim();
|
||||
useAdminCurrencyCatalog();
|
||||
const canManagePlayers = adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
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 [keyword, setKeyword] = useState(keywordFromUrl);
|
||||
const [query, setQuery] = useState(keywordFromUrl);
|
||||
|
||||
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -138,8 +136,6 @@ export function PlayersConsole(): React.ReactElement {
|
||||
page,
|
||||
per_page: perPage,
|
||||
keyword: query.trim() || undefined,
|
||||
site_code: appliedSiteCode.trim() || undefined,
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
@@ -153,11 +149,17 @@ export function PlayersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
|
||||
}, [page, perPage, query]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
|
||||
}, [page, perPage, query]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
setKeyword(keywordFromUrl);
|
||||
setQuery(keywordFromUrl);
|
||||
setPage(1);
|
||||
}, [keywordFromUrl]);
|
||||
|
||||
function openCreateAccount(): void {
|
||||
setAccountMode("create");
|
||||
@@ -311,42 +313,6 @@ export function PlayersConsole(): React.ReactElement {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
{canChooseSite ? (
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-20 sm:shrink-0">{t("filterSite")}</Label>
|
||||
<Select
|
||||
value={siteCode || "__all__"}
|
||||
onValueChange={(v) => setSiteCode(v === "__all__" ? "" : v ?? "")}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[12rem]">
|
||||
<SelectValue>
|
||||
{(v) => {
|
||||
const value = String(v ?? "__all__");
|
||||
if (value === "__all__") {
|
||||
return t("filterAllSites");
|
||||
}
|
||||
const site = siteOptions.find((item) => item.code === value);
|
||||
return site ? `${site.code} — ${site.name}` : value;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.code} value={site.code}>
|
||||
{site.code} — {site.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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")}
|
||||
@@ -361,8 +327,14 @@ export function PlayersConsole(): React.ReactElement {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
if (keyword.trim()) {
|
||||
nextParams.set("keyword", keyword.trim());
|
||||
} else {
|
||||
nextParams.delete("keyword");
|
||||
}
|
||||
const queryString = nextParams.toString();
|
||||
router.replace(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -378,8 +350,14 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
if (keyword.trim()) {
|
||||
nextParams.set("keyword", keyword.trim());
|
||||
} else {
|
||||
nextParams.delete("keyword");
|
||||
}
|
||||
const queryString = nextParams.toString();
|
||||
router.replace(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false });
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
@@ -407,7 +385,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -489,7 +467,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManagePlayers ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
@@ -499,6 +477,16 @@ export function PlayersConsole(): React.ReactElement {
|
||||
icon: Pencil,
|
||||
onClick: () => openEditAccount(row),
|
||||
},
|
||||
{
|
||||
key: "tickets",
|
||||
label: t("viewTickets", { defaultValue: "查看注单" }),
|
||||
href: `/admin/tickets?player_id=${row.id}`,
|
||||
},
|
||||
{
|
||||
key: "wallet",
|
||||
label: t("viewWallet", { defaultValue: "查看钱包流水" }),
|
||||
href: `/admin/wallet/transactions?player_id=${row.id}`,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("delete"),
|
||||
|
||||
@@ -434,10 +434,10 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<Table id="reconcile-jobs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="sticky left-0 z-20 w-24 bg-muted/20 shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<TableHead className="sticky left-0 z-20 w-24 bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("table.id", { ns: "common" })}
|
||||
</TableHead>
|
||||
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted/20 shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("jobNo")}
|
||||
</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
@@ -447,7 +447,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<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)]">
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("operate")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
<TableHead>{t("tasks.columns.format")}</TableHead>
|
||||
<TableHead>{t("tasks.columns.status")}</TableHead>
|
||||
<TableHead>{t("tasks.columns.createdAt")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("tasks.columns.actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("tasks.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -134,7 +134,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatTs(job.created_at ?? job.finished_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
busy={downloadingId === job.id}
|
||||
actions={[
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import * as XLSX from "xlsx";
|
||||
@@ -46,7 +47,6 @@ 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";
|
||||
@@ -92,7 +92,7 @@ import type {
|
||||
AdminReportRebateCommissionRow,
|
||||
} from "@/types/api/admin-reports";
|
||||
|
||||
type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
||||
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
|
||||
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
||||
type ExportFormat = "csv" | "excel";
|
||||
@@ -138,7 +138,6 @@ type ReportFilters = {
|
||||
number: string;
|
||||
player: string;
|
||||
playerId: number | null;
|
||||
agentNodeId: number | undefined;
|
||||
play: string;
|
||||
operator: string;
|
||||
operatorId: number | null;
|
||||
@@ -202,7 +201,6 @@ const emptyFilters: ReportFilters = {
|
||||
number: "",
|
||||
player: "",
|
||||
playerId: null,
|
||||
agentNodeId: undefined,
|
||||
play: "",
|
||||
operator: "",
|
||||
operatorId: null,
|
||||
@@ -323,7 +321,11 @@ function optionText(...parts: Array<string | number | null | undefined>): string
|
||||
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
||||
}
|
||||
|
||||
function reportListParams(filters: ReportFilters, page: number, perPage: number) {
|
||||
function reportListParams(
|
||||
filters: ReportFilters,
|
||||
page: number,
|
||||
perPage: number,
|
||||
) {
|
||||
return {
|
||||
page,
|
||||
per_page: perPage,
|
||||
@@ -331,7 +333,6 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,7 +402,7 @@ function resultRowCount(result: ReportResult | null): number {
|
||||
return result?.rows.length ?? 0;
|
||||
}
|
||||
|
||||
export function ReportsConsole() {
|
||||
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
|
||||
const { t, i18n } = useTranslation(["reports", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
|
||||
@@ -410,7 +411,13 @@ export function ReportsConsole() {
|
||||
useAdminPlayTypeCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(REPORTS[0].key);
|
||||
const filteredReports = useMemo(
|
||||
() => (initialCategory ? REPORTS.filter((report) => report.category === initialCategory) : REPORTS),
|
||||
[initialCategory],
|
||||
);
|
||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(
|
||||
filteredReports[0]?.key ?? REPORTS[0].key,
|
||||
);
|
||||
const [filters, setFilters] = useState<ReportFilters>(emptyFilters);
|
||||
const [result, setResult] = useState<ReportResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -422,8 +429,16 @@ export function ReportsConsole() {
|
||||
const [search, setSearch] = useState<SearchState>(emptySearch);
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
const searchParams = useSearchParams();
|
||||
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
||||
|
||||
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
|
||||
const selectedReport = filteredReports.find((report) => report.key === selectedKey) ?? filteredReports[0] ?? REPORTS[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredReports.some((report) => report.key === selectedKey)) {
|
||||
setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key);
|
||||
}
|
||||
}, [filteredReports, selectedKey]);
|
||||
|
||||
const pageScopedLabel = useCallback(
|
||||
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
|
||||
@@ -621,7 +636,9 @@ export function ReportsConsole() {
|
||||
break;
|
||||
}
|
||||
case "daily_profit": {
|
||||
const payload = await getAdminReportDailyProfit(reportListParams(filters, page, perPage));
|
||||
const payload = await getAdminReportDailyProfit(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const rows = payload.items.map((item) => ({
|
||||
business_date: item.business_date,
|
||||
total_bet_minor: item.total_bet_minor,
|
||||
@@ -650,7 +667,9 @@ export function ReportsConsole() {
|
||||
break;
|
||||
}
|
||||
case "player_win_loss": {
|
||||
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
|
||||
const payload = await getAdminReportPlayerWinLoss(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const rows = payload.items.map((item) => ({
|
||||
player_id: item.player_id,
|
||||
username: item.username,
|
||||
@@ -806,7 +825,9 @@ export function ReportsConsole() {
|
||||
break;
|
||||
}
|
||||
case "play_dimension": {
|
||||
const payload = await getAdminReportPlayDimension(reportListParams(filters, page, perPage));
|
||||
const payload = await getAdminReportPlayDimension(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const rows = payload.items.map((item) => ({
|
||||
play_code: item.play_code,
|
||||
dimension: item.dimension,
|
||||
@@ -829,7 +850,9 @@ export function ReportsConsole() {
|
||||
break;
|
||||
}
|
||||
case "rebate_commission": {
|
||||
const payload = await getAdminReportRebateCommission(reportListParams(filters, page, perPage));
|
||||
const payload = await getAdminReportRebateCommission(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const rows = payload.items.map((item) => ({
|
||||
play_code: item.play_code,
|
||||
total_rebate_minor: item.total_rebate_minor,
|
||||
@@ -906,13 +929,30 @@ export function ReportsConsole() {
|
||||
});
|
||||
}, [selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
drawNo: drawNoFromUrl || prev.drawNo,
|
||||
}));
|
||||
if (drawNoFromUrl) {
|
||||
setSelectedKey("draw_profit");
|
||||
}
|
||||
}, [drawNoFromUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (result && result.key === selectedReport.key && selectedReport.connected) {
|
||||
queueMicrotask(() => {
|
||||
void queryReport();
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, perPage]);
|
||||
|
||||
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
|
||||
@@ -1394,7 +1434,7 @@ export function ReportsConsole() {
|
||||
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5 pt-3">
|
||||
{REPORTS.map((report) => {
|
||||
{filteredReports.map((report) => {
|
||||
const Icon = report.icon;
|
||||
const active = report.key === selectedReport.key;
|
||||
return (
|
||||
@@ -1431,13 +1471,6 @@ 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">
|
||||
|
||||
40
src/modules/reports/reports-subnav.tsx
Normal file
40
src/modules/reports/reports-subnav.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tabs = [
|
||||
{ category: "profit", href: "/admin/reports/profit" },
|
||||
{ category: "wallet", href: "/admin/reports/wallet" },
|
||||
{ category: "risk", href: "/admin/reports/risk" },
|
||||
{ category: "audit", href: "/admin/reports/audit" },
|
||||
] as const;
|
||||
|
||||
export function ReportsSubnav(): React.ReactElement {
|
||||
const { t } = useTranslation("reports");
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav aria-label={t("title")} className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1">
|
||||
{tabs.map((tab) => {
|
||||
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"border-b-2 px-4 py-3 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`categories.${tab.category}`)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export function RiskIndexConsole() {
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("closeTime")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -205,7 +205,7 @@ export function RiskIndexConsole() {
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{row.close_time ? formatDt(row.close_time) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
|
||||
@@ -253,7 +253,7 @@ export function RiskPoolsConsole({
|
||||
<TableHead className="text-center">{t("remainingAmount")}</TableHead>
|
||||
<TableHead className="text-center">{t("usageRatio")}</TableHead>
|
||||
<TableHead>{t("poolStatus")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -301,7 +301,7 @@ export function RiskPoolsConsole({
|
||||
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
busy={acting}
|
||||
actions={[
|
||||
|
||||
@@ -231,7 +231,7 @@ export function CurrencySettingsPanel() {
|
||||
<TableHead className="whitespace-nowrap">{t("currencies.table.decimals", { ns: "config" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-14 whitespace-nowrap text-center">{t("currencies.table.actions", { ns: "config" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("currencies.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -269,7 +269,7 @@ export function CurrencySettingsPanel() {
|
||||
: t("system.states.disabled", { ns: "config" })}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
|
||||
@@ -11,6 +11,8 @@ 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";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
interface SettlementDraft {
|
||||
autoSettlement: boolean;
|
||||
@@ -54,6 +56,8 @@ function buildDirtyItems(draft: SettlementDraft, saved: SettlementDraft): AdminS
|
||||
|
||||
export function SettlementSettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.payout.manage"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const buildItems = useCallback(buildDirtyItems, []);
|
||||
const section = useSettingsSection({
|
||||
@@ -66,6 +70,10 @@ export function SettlementSettingsPanel() {
|
||||
|
||||
const { draft, loading, saving, dirty, updateField, discard, save } = section;
|
||||
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
|
||||
@@ -16,7 +16,6 @@ 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";
|
||||
@@ -90,8 +89,7 @@ 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 [appliedAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
@@ -343,24 +341,6 @@ 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>
|
||||
|
||||
@@ -14,7 +14,6 @@ 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";
|
||||
@@ -104,8 +103,6 @@ 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);
|
||||
@@ -124,7 +121,6 @@ export function SettlementBatchesConsole() {
|
||||
appliedStatus === STATUS_ALL || appliedStatus.trim() === ""
|
||||
? undefined
|
||||
: appliedStatus.trim(),
|
||||
agent_node_id: appliedAgentNodeId,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
@@ -133,16 +129,15 @@ export function SettlementBatchesConsole() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
|
||||
}, [page, perPage, appliedDrawNo, appliedStatus]);
|
||||
|
||||
const applyFilters = () => {
|
||||
setAppliedDrawNo(draftDrawNo);
|
||||
setAppliedStatus(draftStatus);
|
||||
setAppliedAgentNodeId(agentNodeId);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@@ -201,12 +196,6 @@ 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")}
|
||||
@@ -260,7 +249,7 @@ export function SettlementBatchesConsole() {
|
||||
<TableHead className="text-center">{t("platformProfit")}</TableHead>
|
||||
<TableHead>{t("reviewStatus")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead />
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -300,7 +289,7 @@ export function SettlementBatchesConsole() {
|
||||
{settlementStatusText(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
busy={actingId === row.id}
|
||||
actions={[
|
||||
@@ -310,6 +299,11 @@ export function SettlementBatchesConsole() {
|
||||
icon: Eye,
|
||||
href: `/admin/settlement-batches/${row.id}/details`,
|
||||
},
|
||||
{
|
||||
key: "report",
|
||||
label: t("viewReport", { defaultValue: "查看报表" }),
|
||||
href: `/admin/reports?draw_no=${encodeURIComponent(row.draw_no ?? "")}`,
|
||||
},
|
||||
{
|
||||
key: "approve",
|
||||
label: t("pass"),
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
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 { 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";
|
||||
@@ -26,13 +26,6 @@ import {
|
||||
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,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -62,8 +55,6 @@ const TICKET_STATUS_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
type TicketFilters = {
|
||||
siteCode: string;
|
||||
agentNodeId: number | undefined;
|
||||
playerQuery: string;
|
||||
drawNo: string;
|
||||
numberKeyword: string;
|
||||
@@ -73,8 +64,6 @@ type TicketFilters = {
|
||||
};
|
||||
|
||||
const emptyTicketFilters: TicketFilters = {
|
||||
siteCode: "",
|
||||
agentNodeId: undefined,
|
||||
playerQuery: "",
|
||||
drawNo: "",
|
||||
numberKeyword: "",
|
||||
@@ -109,12 +98,17 @@ 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");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [draft, setDraft] = useState<TicketFilters>(emptyTicketFilters);
|
||||
const [applied, setApplied] = useState<TicketFilters>(emptyTicketFilters);
|
||||
const searchParams = useSearchParams();
|
||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||
const initialFilters: TicketFilters = {
|
||||
...emptyTicketFilters,
|
||||
playerQuery: playerIdFromUrl,
|
||||
};
|
||||
const [draft, setDraft] = useState<TicketFilters>(initialFilters);
|
||||
const [applied, setApplied] = useState<TicketFilters>(initialFilters);
|
||||
const [data, setData] = useState<AdminTicketItemsData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
@@ -138,8 +132,6 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
page,
|
||||
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,
|
||||
@@ -163,8 +155,6 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
setErr(null);
|
||||
setApplied({
|
||||
...draft,
|
||||
siteCode: draft.siteCode.trim(),
|
||||
agentNodeId: draft.agentNodeId,
|
||||
playerQuery: draft.playerQuery.trim(),
|
||||
drawNo: draft.drawNo.trim(),
|
||||
numberKeyword: draft.numberKeyword.trim(),
|
||||
@@ -195,47 +185,6 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
{canChooseSite ? (
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:shrink-0">{t("filterSite")}</Label>
|
||||
<Select
|
||||
value={draft.siteCode || "__all__"}
|
||||
onValueChange={(v) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
siteCode: v === "__all__" ? "" : (v ?? ""),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[12rem]">
|
||||
<SelectValue>
|
||||
{(v) => {
|
||||
const value = String(v ?? "__all__");
|
||||
if (value === "__all__") {
|
||||
return t("filterAllSites");
|
||||
}
|
||||
const site = siteOptions.find((item) => item.code === value);
|
||||
return site ? `${site.code} — ${site.name}` : value;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.code} value={site.code}>
|
||||
{site.code} — {site.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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")}
|
||||
@@ -378,14 +327,15 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableHead className="text-center">{t("winAmount")}</TableHead>
|
||||
<TableHead>{t("placedAt")}</TableHead>
|
||||
<TableHead>{t("updatedAt")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={16} />
|
||||
<AdminTableLoadingRow colSpan={17} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={16} className="text-muted-foreground">
|
||||
<TableCell colSpan={17} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -420,6 +370,17 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableCell className="text-center tabular-nums text-xs">{winLabel}</TableCell>
|
||||
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
|
||||
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view-player",
|
||||
label: t("viewPlayer", { defaultValue: "查看玩家" }),
|
||||
href: `/admin/players?keyword=${encodeURIComponent(String(row.player_id ?? ""))}&site_code=${encodeURIComponent(String(row.site_code ?? ""))}${row.agent_node_id ? `&agent_node_id=${row.agent_node_id}` : ""}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
|
||||
67
src/modules/update_sticky_actions.js
Normal file
67
src/modules/update_sticky_actions.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const files = [
|
||||
"reports/report-jobs-panel.tsx",
|
||||
"risk/risk-pools-console.tsx",
|
||||
"risk/risk-index-console.tsx",
|
||||
"admin-roles/admin-roles-console.tsx",
|
||||
"admin-users/admin-users-console.tsx",
|
||||
"wallet/wallet-console.tsx",
|
||||
"integration/integration-sites-console.tsx",
|
||||
"players/players-console.tsx",
|
||||
"config/doc/risk-cap-doc-screen.tsx",
|
||||
"draws/draw-review-console.tsx",
|
||||
"draws/draws-index-console.tsx",
|
||||
"tickets/player-tickets-console.tsx",
|
||||
"settings/currency-settings-panel.tsx",
|
||||
"agents/agents-console.tsx",
|
||||
"settlement/settlement-batches-console.tsx"
|
||||
];
|
||||
|
||||
const STICKY_HEAD_CLASSES = "sticky right-0 z-20 bg-muted shadow-[-1px_0_0_rgba(203,213,225,0.7)] ";
|
||||
const STICKY_CELL_CLASSES = "sticky right-0 z-10 bg-card shadow-[-1px_0_0_rgba(203,213,225,0.7)] ";
|
||||
|
||||
files.forEach(file => {
|
||||
const fullPath = path.join("/Users/kang/Work/lotterySystem/lotteryadmin/src/modules", file);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.log("Not found:", file);
|
||||
return;
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(fullPath, 'utf8');
|
||||
let changed = false;
|
||||
|
||||
// Replace TableHead
|
||||
const newContent1 = content.replace(
|
||||
/<TableHead className="([^"]*text-center[^"]*)"([^>]*)>(.*?t\([^)]*(?:action|操作)[^)]*\).*?)<\/TableHead>/g,
|
||||
(match, p1, p2, p3) => {
|
||||
if (p1.includes("sticky")) return match;
|
||||
changed = true;
|
||||
return `<TableHead className="${STICKY_HEAD_CLASSES}${p1}"${p2}>${p3}</TableHead>`;
|
||||
}
|
||||
);
|
||||
content = newContent1;
|
||||
|
||||
// Replace TableCell wrapping AdminRowActionsMenu
|
||||
const newContent2 = content.replace(
|
||||
/<TableCell([^>]*)>\s*<AdminRowActionsMenu/g,
|
||||
(match, p1) => {
|
||||
if (match.includes("sticky")) return match;
|
||||
changed = true;
|
||||
if (p1.includes('className="')) {
|
||||
return match.replace(/className="([^"]*)"/, `className="${STICKY_CELL_CLASSES}$1"`);
|
||||
} else {
|
||||
return `<TableCell className="${STICKY_CELL_CLASSES}"${p1}>\n<AdminRowActionsMenu`;
|
||||
}
|
||||
}
|
||||
);
|
||||
content = newContent2;
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
console.log("Updated", file);
|
||||
} else {
|
||||
console.log("Skipped", file);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Copy, RotateCcw, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
@@ -17,7 +18,6 @@ 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";
|
||||
@@ -121,7 +121,7 @@ function statusLabelT(status: string, t: (key: string) => string): string {
|
||||
case "reversed":
|
||||
return t("statusReversed");
|
||||
case "manually_processed":
|
||||
return t("statusManuallyProcessed");
|
||||
return t("statusCaseClosed");
|
||||
case "posted":
|
||||
return t("statusPosted");
|
||||
default:
|
||||
@@ -130,7 +130,6 @@ function statusLabelT(status: string, t: (key: string) => string): string {
|
||||
}
|
||||
|
||||
type TransferFilters = {
|
||||
agentNodeId: number | undefined;
|
||||
playerId: string;
|
||||
playerAccount: string;
|
||||
transferNo: string;
|
||||
@@ -142,7 +141,6 @@ type TransferFilters = {
|
||||
};
|
||||
|
||||
const emptyTransferFilters: TransferFilters = {
|
||||
agentNodeId: undefined,
|
||||
playerId: "",
|
||||
playerAccount: "",
|
||||
transferNo: "",
|
||||
@@ -154,7 +152,6 @@ const emptyTransferFilters: TransferFilters = {
|
||||
};
|
||||
|
||||
type TxnFilters = {
|
||||
agentNodeId: number | undefined;
|
||||
playerId: string;
|
||||
playerAccount: string;
|
||||
txnNo: string;
|
||||
@@ -167,7 +164,6 @@ type TxnFilters = {
|
||||
};
|
||||
|
||||
const emptyTxnFilters: TxnFilters = {
|
||||
agentNodeId: undefined,
|
||||
playerId: "",
|
||||
playerAccount: "",
|
||||
txnNo: "",
|
||||
@@ -203,7 +199,7 @@ const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "failed", label: "statusFailed" },
|
||||
{ value: "pending_reconcile", label: "statusPendingReconcile" },
|
||||
{ value: "reversed", label: "statusReversed" },
|
||||
{ value: "manually_processed", label: "statusManuallyProcessed" },
|
||||
{ value: "manually_processed", label: "statusCaseClosed" },
|
||||
];
|
||||
|
||||
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
|
||||
@@ -251,6 +247,7 @@ function canManuallyProcessTransferOrder(
|
||||
row: {
|
||||
direction?: string;
|
||||
status: string;
|
||||
fail_reason?: string | null;
|
||||
can_manually_process?: boolean;
|
||||
},
|
||||
canWriteWallet: boolean,
|
||||
@@ -259,7 +256,8 @@ function canManuallyProcessTransferOrder(
|
||||
canWriteWallet &&
|
||||
(row.can_manually_process ??
|
||||
(["processing", "failed", "pending_reconcile"].includes(row.status) &&
|
||||
!(row.direction === "out" && row.status === "pending_reconcile")))
|
||||
!(row.direction === "out" && row.status === "pending_reconcile") &&
|
||||
row.fail_reason !== "lottery_credit_failed"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,7 +293,7 @@ function TransferOrderRowActions({
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
label: t("manualProcess"),
|
||||
label: t("markCaseClosed"),
|
||||
icon: Wrench,
|
||||
hidden: !canManuallyProcessTransferOrder(row, canWriteWallet),
|
||||
onClick: () => onManualProcess(row.transfer_no),
|
||||
@@ -362,10 +360,10 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
|
||||
const handleManuallyProcess = (transferNo: string) =>
|
||||
requestConfirm({
|
||||
title: t("confirm.manualProcessTitle"),
|
||||
description: t("confirm.manualProcessDescription", { transferNo }),
|
||||
title: t("confirm.markCaseClosedTitle"),
|
||||
description: t("confirm.markCaseClosedDescription", { transferNo }),
|
||||
onConfirm: () =>
|
||||
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess")),
|
||||
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("markCaseClosedSuccess")),
|
||||
});
|
||||
|
||||
const handleCompleteCredit = (transferNo: string) =>
|
||||
@@ -396,7 +394,6 @@ 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) {
|
||||
@@ -430,11 +427,6 @@ 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
|
||||
@@ -561,7 +553,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("failReason")}</TableHead>
|
||||
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
|
||||
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
|
||||
<TableHead className="w-12 text-center">{t("actions")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -600,7 +592,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{formatTs(row.finished_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center align-middle">
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center align-middle shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<div className="flex justify-center">
|
||||
<TransferOrderRowActions
|
||||
row={row}
|
||||
@@ -655,6 +647,8 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
|
||||
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
|
||||
const searchParams = useSearchParams();
|
||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -677,7 +671,6 @@ 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) {
|
||||
@@ -692,6 +685,15 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
void load();
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!playerIdFromUrl) {
|
||||
return;
|
||||
}
|
||||
setDraft((d) => ({ ...d, playerId: playerIdFromUrl }));
|
||||
setApplied((d) => ({ ...d, playerId: playerIdFromUrl }));
|
||||
setPage(1);
|
||||
}, [playerIdFromUrl]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
setPage(1);
|
||||
@@ -710,11 +712,6 @@ 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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_WALLET_PLAYER_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
@@ -17,6 +18,7 @@ const RECONCILE_PERMS = [
|
||||
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
|
||||
{ href: "/admin/wallet/transactions", label: "subnavTransactions", requiredAny: RECONCILE_PERMS },
|
||||
{ href: "/admin/wallet/transfer-orders", label: "subnavTransferOrders", requiredAny: RECONCILE_PERMS },
|
||||
{ href: "/admin/wallet/player", label: "subnavPlayerWallet", requiredAny: PRD_WALLET_PLAYER_ACCESS_ANY },
|
||||
];
|
||||
|
||||
export function WalletSubnav(): React.ReactElement {
|
||||
|
||||
Reference in New Issue
Block a user