feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates
Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
Network,
|
||||
Scale,
|
||||
ScrollText,
|
||||
Receipt,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
@@ -37,6 +38,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
reports: FileSpreadsheet,
|
||||
risk: ShieldAlert,
|
||||
settlement: Landmark,
|
||||
settlement_center: Receipt,
|
||||
reconcile: Scale,
|
||||
audit: ScrollText,
|
||||
admin_users: ShieldCheck,
|
||||
|
||||
@@ -23,6 +23,7 @@ export type AdminNavSegment =
|
||||
| "risk"
|
||||
| "settings"
|
||||
| "settlement"
|
||||
| "settlement_center"
|
||||
| "reconcile"
|
||||
| "audit"
|
||||
| "admin_users"
|
||||
|
||||
@@ -13,11 +13,12 @@ import {
|
||||
deleteAdminRole,
|
||||
getAdminRoles,
|
||||
getAdminUserPermissionCatalog,
|
||||
postAdminRole,
|
||||
putAdminRole,
|
||||
putAdminRolePermissions,
|
||||
} from "@/api/admin-users";
|
||||
import { isPlatformFixedRole, isPlatformSuperAdminRole } from "@/lib/platform-system-roles";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -77,7 +78,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
const [roleSaving, setRoleSaving] = useState(false);
|
||||
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
||||
const [roleMode, setRoleMode] = useState<"create" | "edit">("create");
|
||||
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||
const [roleSlug, setRoleSlug] = useState("");
|
||||
const [roleName, setRoleName] = useState("");
|
||||
@@ -116,18 +116,10 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
function openCreateRole(): void {
|
||||
setRoleMode("create");
|
||||
setEditingRoleId(null);
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRoleDescription("");
|
||||
setRoleStatus(1);
|
||||
setRoleDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditRole(role: AdminRoleRow): void {
|
||||
setRoleMode("edit");
|
||||
if (isPlatformSuperAdminRole(role)) {
|
||||
return;
|
||||
}
|
||||
setEditingRoleId(role.id);
|
||||
setRoleSlug(role.slug);
|
||||
setRoleName(role.name);
|
||||
@@ -180,39 +172,24 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
async function submitRole(): Promise<void> {
|
||||
const name = roleName.trim();
|
||||
const slug = roleSlug.trim().toLowerCase();
|
||||
if (name === "" || slug === "") {
|
||||
if (name === "" || slug === "" || editingRoleId === null) {
|
||||
toast.error(t("roleFormRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setRoleFormSaving(true);
|
||||
try {
|
||||
if (roleMode === "create") {
|
||||
const created = await postAdminRole({
|
||||
slug,
|
||||
name,
|
||||
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
|
||||
status: roleStatus,
|
||||
});
|
||||
setRoles((prev) => [...prev, created]);
|
||||
setCatalog((prev) => (prev ? { ...prev, roles: [...prev.roles, created] } : prev));
|
||||
toast.success(t("roleCreateSuccess", { name: created.name }));
|
||||
} else {
|
||||
if (editingRoleId === null) {
|
||||
return;
|
||||
}
|
||||
const updated = await putAdminRole(editingRoleId, {
|
||||
slug,
|
||||
name,
|
||||
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
|
||||
status: roleStatus,
|
||||
});
|
||||
setRoles((prev) => prev.map((role) => (role.id === updated.id ? updated : role)));
|
||||
setCatalog((prev) =>
|
||||
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === updated.id ? updated : role)) } : prev,
|
||||
);
|
||||
toast.success(t("roleUpdateSuccess", { name: updated.name }));
|
||||
}
|
||||
const updated = await putAdminRole(editingRoleId, {
|
||||
slug,
|
||||
name,
|
||||
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
|
||||
status: roleStatus,
|
||||
});
|
||||
setRoles((prev) => prev.map((role) => (role.id === updated.id ? updated : role)));
|
||||
setCatalog((prev) =>
|
||||
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === updated.id ? updated : role)) } : prev,
|
||||
);
|
||||
toast.success(t("roleUpdateSuccess", { name: updated.name }));
|
||||
handleRoleDialogOpenChange(false);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("roleSaveFailed");
|
||||
@@ -249,11 +226,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>{t("roleListTitle", { defaultValue: "平台角色管理" })}</CardTitle>
|
||||
{canManageRoles ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateRole()}>
|
||||
{t("createRole", { defaultValue: "新增平台角色" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
@@ -267,6 +239,11 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("roleListHint", {
|
||||
defaultValue: "平台仅保留「超级管理员」与「代理」两个内置角色;超级管理员自动拥有全部权限。",
|
||||
})}
|
||||
</p>
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
<div className="rounded-md border">
|
||||
<Table id="admin-roles-table">
|
||||
@@ -286,13 +263,13 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
{loading && roles.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={8} className="text-muted-foreground" />
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
roles.map((role) => {
|
||||
const fixedRole = isPlatformFixedRole(role);
|
||||
const superAdminRole = isPlatformSuperAdminRole(role);
|
||||
|
||||
return (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell>{role.id}</TableCell>
|
||||
<TableCell>
|
||||
@@ -324,12 +301,14 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
key: "permissions",
|
||||
label: t("roleActions.permissions"),
|
||||
icon: KeyRound,
|
||||
disabled: superAdminRole,
|
||||
onClick: () => openRolePermissionEditor(role),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: t("actions.edit"),
|
||||
icon: Pencil,
|
||||
disabled: superAdminRole,
|
||||
onClick: () => openEditRole(role),
|
||||
},
|
||||
{
|
||||
@@ -337,7 +316,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
label: t("actions.delete"),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: role.is_system || role.user_count > 0,
|
||||
disabled: fixedRole || role.user_count > 0,
|
||||
onClick: () => setRoleDeleteTarget(role),
|
||||
},
|
||||
]}
|
||||
@@ -347,7 +326,8 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -375,6 +355,10 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
onChange={setDraftRolePermissions}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
helperText={t("rolePermissionDialog.packageHint", {
|
||||
defaultValue:
|
||||
"勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。",
|
||||
})}
|
||||
emptyText={t("states.noData", { ns: "common" })}
|
||||
heightClassName="h-[min(56vh,520px)]"
|
||||
/>
|
||||
@@ -406,9 +390,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<Dialog open={roleDialogOpen} onOpenChange={handleRoleDialogOpenChange}>
|
||||
<DialogContent showCloseButton className="max-w-lg gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{roleMode === "create" ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{t("roleDialog.editTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
@@ -418,7 +400,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
value={roleSlug}
|
||||
placeholder={t("roleDialog.slugPlaceholder")}
|
||||
onChange={(e) => setRoleSlug(e.target.value)}
|
||||
disabled={roleMode === "edit"}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -462,10 +444,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmSaveRoleTitle"),
|
||||
description:
|
||||
roleMode === "create"
|
||||
? t("confirmSaveRoleCreateDescription", { name: roleName || roleSlug || "—" })
|
||||
: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
|
||||
description: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => submitRole(),
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
putAdminUserRoles,
|
||||
} from "@/api/admin-users";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
@@ -36,6 +38,13 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -49,7 +58,11 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_ADMIN_USER_MANAGE } from "@/lib/admin-prd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
|
||||
import type {
|
||||
AdminPermissionCatalogData,
|
||||
AdminUserPermissionRow,
|
||||
AdminUserSiteBinding,
|
||||
} from "@/types/api/index";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AdminUsersConsole(): React.ReactElement {
|
||||
@@ -58,6 +71,17 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const exportLabels = useExportLabels("adminUsers");
|
||||
const profile = useAdminProfile();
|
||||
const { sites: hookSiteOptions } = useAdminSiteCodeOptions();
|
||||
const siteOptions = useMemo(() => {
|
||||
if (hookSiteOptions.length > 0) {
|
||||
return hookSiteOptions;
|
||||
}
|
||||
return (profile?.accessible_sites ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
code: site.code,
|
||||
name: site.name,
|
||||
}));
|
||||
}, [hookSiteOptions, profile?.accessible_sites]);
|
||||
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_USER_MANAGE]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
@@ -86,6 +110,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
const [formPassword, setFormPassword] = useState("");
|
||||
const [formStatus, setFormStatus] = useState(0);
|
||||
const [formCreateRoles, setFormCreateRoles] = useState<string[]>([]);
|
||||
const [formAdminSiteId, setFormAdminSiteId] = useState<number | null>(null);
|
||||
const [roleEditSiteId, setRoleEditSiteId] = useState<number | null>(null);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUserPermissionRow | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
@@ -99,6 +125,39 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
[catalog],
|
||||
);
|
||||
|
||||
const defaultSiteId = useMemo(() => siteOptions[0]?.id ?? null, [siteOptions]);
|
||||
const roleEditSiteLabel = useMemo(() => {
|
||||
const site = siteOptions.find((item) => item.id === roleEditSiteId);
|
||||
return site ? `${site.name} (${site.code})` : null;
|
||||
}, [roleEditSiteId, siteOptions]);
|
||||
const formAdminSiteLabel = useMemo(() => {
|
||||
const site = siteOptions.find((item) => item.id === formAdminSiteId);
|
||||
return site ? `${site.name} (${site.code})` : null;
|
||||
}, [formAdminSiteId, siteOptions]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (formAdminSiteId === null && defaultSiteId !== null && accountOpen && accountMode === "create") {
|
||||
setFormAdminSiteId(defaultSiteId);
|
||||
}
|
||||
}, [accountOpen, accountMode, defaultSiteId, formAdminSiteId]);
|
||||
|
||||
function formatSiteBindings(bindings: AdminUserSiteBinding[] | undefined): string {
|
||||
if (!bindings || bindings.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return bindings
|
||||
.map((b) => `${b.site_code}${b.role_slugs.length > 0 ? ` (${b.role_slugs.length})` : ""}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function rolesForSite(bindings: AdminUserSiteBinding[] | undefined, siteId: number | null): string[] {
|
||||
if (siteId === null) {
|
||||
return [];
|
||||
}
|
||||
const match = bindings?.find((b) => b.site_id === siteId);
|
||||
return match ? [...match.role_slugs].sort() : [];
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
@@ -149,8 +208,16 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
}
|
||||
|
||||
function openPermissionEditor(row: AdminUserPermissionRow): void {
|
||||
const bindings = row.site_bindings ?? [];
|
||||
const initialSiteId =
|
||||
bindings[0]?.site_id ?? defaultSiteId ?? siteOptions[0]?.id ?? null;
|
||||
setSelectedId(row.id);
|
||||
setDraftRoles([...row.roles].sort());
|
||||
setRoleEditSiteId(initialSiteId);
|
||||
setDraftRoles(
|
||||
rolesForSite(bindings, initialSiteId).length > 0
|
||||
? rolesForSite(bindings, initialSiteId)
|
||||
: [...row.roles].sort(),
|
||||
);
|
||||
setPermissionOpen(true);
|
||||
}
|
||||
|
||||
@@ -158,6 +225,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
setPermissionOpen(open);
|
||||
if (!open) {
|
||||
setSelectedId(null);
|
||||
setRoleEditSiteId(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +238,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
setFormPassword("");
|
||||
setFormStatus(0);
|
||||
setFormCreateRoles([]);
|
||||
setFormAdminSiteId(defaultSiteId);
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
@@ -205,6 +274,10 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
toast.error(t("roleRequired"));
|
||||
return;
|
||||
}
|
||||
if (accountMode === "create" && (formAdminSiteId === null || formAdminSiteId <= 0)) {
|
||||
toast.error(t("siteRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setAccountSaving(true);
|
||||
try {
|
||||
@@ -224,6 +297,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
||||
password: formPassword,
|
||||
status: formStatus,
|
||||
admin_site_id: formAdminSiteId as number,
|
||||
role_slugs: formCreateRoles,
|
||||
});
|
||||
setItems((prev) => [created, ...prev]);
|
||||
@@ -265,9 +339,16 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
if (!selectedUser) {
|
||||
return;
|
||||
}
|
||||
if (roleEditSiteId === null || roleEditSiteId <= 0) {
|
||||
toast.error(t("siteRequired"));
|
||||
return;
|
||||
}
|
||||
setSavingRoles(true);
|
||||
try {
|
||||
const result = await putAdminUserRoles(selectedUser.id, draftRoles);
|
||||
const result = await putAdminUserRoles(selectedUser.id, {
|
||||
admin_site_id: roleEditSiteId,
|
||||
role_slugs: draftRoles,
|
||||
});
|
||||
setDraftRoles([...result.roles].sort());
|
||||
setItems((prev) =>
|
||||
prev.map((row) =>
|
||||
@@ -275,6 +356,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
? {
|
||||
...row,
|
||||
roles: result.roles,
|
||||
site_bindings: result.site_bindings ?? row.site_bindings,
|
||||
effective_permissions: result.effective_permissions,
|
||||
}
|
||||
: row,
|
||||
@@ -378,6 +460,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<TableHead>{t("table.account")}</TableHead>
|
||||
<TableHead>{t("table.nickname")}</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
|
||||
<TableHead>{t("table.sites")}</TableHead>
|
||||
<TableHead>{t("table.roles")}</TableHead>
|
||||
<TableHead>{t("table.effective")}</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>
|
||||
@@ -385,13 +468,9 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={7} />
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={8} className="text-muted-foreground" />
|
||||
) : (
|
||||
items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -408,6 +487,9 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
{row.status === 0 ? t("status.enabled") : t("status.disabled")}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatSiteBindings(row.site_bindings) || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.roles.length === 0 ? (
|
||||
@@ -494,6 +576,33 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("permissionDialog.rolesDescription")}</p>
|
||||
{siteOptions.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="role-edit-site">{t("permissionDialog.site")}</Label>
|
||||
<Select
|
||||
value={roleEditSiteId !== null ? String(roleEditSiteId) : undefined}
|
||||
disabled={siteOptions.length <= 1}
|
||||
onValueChange={(value) => {
|
||||
const siteId = Number(value);
|
||||
setRoleEditSiteId(siteId);
|
||||
setDraftRoles(rolesForSite(selectedUser?.site_bindings, siteId));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="role-edit-site" className="h-9 w-full max-w-md">
|
||||
<SelectValue placeholder={t("accountDialog.sitePlaceholder")}>
|
||||
{roleEditSiteLabel ?? t("accountDialog.sitePlaceholder")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.name} ({site.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
{(catalog?.roles ?? []).map((role) => {
|
||||
const checked = draftRoles.includes(role.slug);
|
||||
@@ -606,6 +715,29 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{accountMode === "create" && siteOptions.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("accountDialog.site")}</div>
|
||||
<Select
|
||||
value={formAdminSiteId !== null ? String(formAdminSiteId) : undefined}
|
||||
disabled={siteOptions.length <= 1}
|
||||
onValueChange={(value) => setFormAdminSiteId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder={t("accountDialog.sitePlaceholder")}>
|
||||
{formAdminSiteLabel ?? t("accountDialog.sitePlaceholder")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.name} ({site.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
{accountMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>
|
||||
|
||||
699
src/modules/agents/agent-line-detail-panel.tsx
Normal file
699
src/modules/agents/agent-line-detail-panel.tsx
Normal file
@@ -0,0 +1,699 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
|
||||
import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields";
|
||||
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentNodeProfileSummary, AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
|
||||
|
||||
function settlementCycleLabel(
|
||||
cycle: AgentNodeProfileSummary["settlement_cycle"] | undefined,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
if (cycle === "daily") {
|
||||
return t("profile.cycleDaily", { defaultValue: "日结" });
|
||||
}
|
||||
if (cycle === "monthly") {
|
||||
return t("profile.cycleMonthly", { defaultValue: "月结" });
|
||||
}
|
||||
return t("profile.cycleWeekly", { defaultValue: "周结" });
|
||||
}
|
||||
|
||||
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
|
||||
|
||||
export type AgentLineDetailPanelProps = {
|
||||
node: AgentNodeRow | null;
|
||||
profile: AgentProfileRow | null;
|
||||
profileLoading: boolean;
|
||||
childAgents: AgentNodeRow[];
|
||||
childCountById: Map<number, number>;
|
||||
siteCode: string;
|
||||
siteLabel: string | null;
|
||||
parentName: string | null;
|
||||
detailTab: AgentDetailTab;
|
||||
onDetailTabChange: (tab: AgentDetailTab) => void;
|
||||
canViewProfileTab: boolean;
|
||||
canEditProfileTab: boolean;
|
||||
profileReadOnly: boolean;
|
||||
canViewDownlineTab: boolean;
|
||||
canViewPlayersTab: boolean;
|
||||
canManageNode: boolean;
|
||||
canCreateChild: boolean;
|
||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||
onEditChild: (node: AgentNodeRow) => void;
|
||||
onDeleteChild: (node: AgentNodeRow) => void;
|
||||
onAddChild: () => void;
|
||||
onEditCurrent: () => void;
|
||||
onSelectChild: (node: AgentNodeRow) => void;
|
||||
profileFields: AgentProfileFieldsProps | null;
|
||||
profileSaving: boolean;
|
||||
onSaveProfile: () => void;
|
||||
};
|
||||
|
||||
export function AgentLineDetailPanel({
|
||||
node,
|
||||
profile,
|
||||
profileLoading,
|
||||
childAgents,
|
||||
childCountById,
|
||||
siteCode,
|
||||
siteLabel,
|
||||
parentName,
|
||||
detailTab,
|
||||
onDetailTabChange,
|
||||
canViewProfileTab,
|
||||
canEditProfileTab,
|
||||
profileReadOnly,
|
||||
canViewDownlineTab,
|
||||
canViewPlayersTab,
|
||||
canManageNode,
|
||||
canCreateChild,
|
||||
canDeleteChild,
|
||||
onEditChild,
|
||||
onDeleteChild,
|
||||
onAddChild,
|
||||
onEditCurrent,
|
||||
onSelectChild,
|
||||
profileFields,
|
||||
profileSaving,
|
||||
onSaveProfile,
|
||||
}: AgentLineDetailPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
|
||||
if (node === null) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-muted/20 px-6 py-20 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80 bg-background">
|
||||
<Network className="size-6 text-muted-foreground/70" aria-hidden />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-foreground">
|
||||
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理查看占成与授信" })}
|
||||
</p>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
{t("lineUi.selectAgentHint", {
|
||||
defaultValue: "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cycleLabel =
|
||||
profile?.settlement_cycle === "daily"
|
||||
? t("profile.cycleDaily", { defaultValue: "日结" })
|
||||
: profile?.settlement_cycle === "monthly"
|
||||
? t("profile.cycleMonthly", { defaultValue: "月结" })
|
||||
: t("profile.cycleWeekly", { defaultValue: "周结" });
|
||||
|
||||
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
|
||||
{
|
||||
key: "overview",
|
||||
label: t("lineUi.tabOverview", { defaultValue: "概览" }),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: "profile",
|
||||
label: profileReadOnly
|
||||
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
|
||||
: t("lineUi.tabProfile", { defaultValue: "占成与授信" }),
|
||||
visible: canViewProfileTab,
|
||||
},
|
||||
{
|
||||
key: "downline",
|
||||
label: t("lineUi.tabDownline", { defaultValue: "直属下级" }),
|
||||
count: childAgents.length,
|
||||
visible: canViewDownlineTab,
|
||||
},
|
||||
{
|
||||
key: "players",
|
||||
label: t("lineUi.tabPlayers", { defaultValue: "直属玩家" }),
|
||||
visible: canViewPlayersTab,
|
||||
},
|
||||
];
|
||||
|
||||
const siteDisplay =
|
||||
siteLabel && siteCode.trim() !== ""
|
||||
? `${siteLabel} (${siteCode})`
|
||||
: siteLabel ?? siteCode;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background">
|
||||
<header className="border-b border-border/60 bg-card px-5 py-5 sm:px-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground">
|
||||
{node.name}
|
||||
</h2>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
|
||||
{node.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "启用" })
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
<span className="font-mono text-xs text-foreground/80">{node.code}</span>
|
||||
{node.username ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-border">·</span>
|
||||
{node.username}
|
||||
</>
|
||||
) : null}
|
||||
{parentName ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-border">·</span>
|
||||
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||
{siteDisplay ? (
|
||||
<div
|
||||
className="rounded-lg border border-border/70 bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground"
|
||||
title={siteDisplay}
|
||||
>
|
||||
<span className="font-medium text-foreground/90">
|
||||
{t("lineUi.currentSite", { defaultValue: "当前站点" })}
|
||||
</span>
|
||||
<span className="mx-1.5 text-border">|</span>
|
||||
<span className="truncate">{siteDisplay}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{canManageNode ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
{t("lineUi.editAccount", { defaultValue: "账号与状态" })}
|
||||
</Button>
|
||||
{canCreateChild ? (
|
||||
<Button type="button" size="sm" onClick={onAddChild}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-0 overflow-x-auto border-b border-border/60 bg-card px-5 sm:px-6">
|
||||
{tabs
|
||||
.filter((tab) => tab.visible)
|
||||
.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.key}
|
||||
active={detailTab === tab.key}
|
||||
onClick={() => onDetailTabChange(tab.key)}
|
||||
label={tab.label}
|
||||
count={tab.count}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
|
||||
{detailTab === "overview" ? (
|
||||
<OverviewTab
|
||||
profile={profile}
|
||||
profileLoading={profileLoading}
|
||||
cycleLabel={cycleLabel}
|
||||
profileReadOnly={profileReadOnly}
|
||||
canViewDownlineTab={canViewDownlineTab}
|
||||
canViewPlayersTab={canViewPlayersTab}
|
||||
childCount={childAgents.length}
|
||||
onGoToDownline={() => onDetailTabChange("downline")}
|
||||
onGoToPlayers={() => onDetailTabChange("players")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detailTab === "profile" && canViewProfileTab && profileFields ? (
|
||||
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm">
|
||||
<CardHeader className="border-b border-border/60 pb-4">
|
||||
<CardTitle className="text-base">
|
||||
{profileReadOnly
|
||||
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
|
||||
: t("lineUi.tabProfile", { defaultValue: "占成与授信" })}
|
||||
</CardTitle>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
{profileReadOnly
|
||||
? t("lineUi.profileReadOnlyHint", {
|
||||
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
||||
})
|
||||
: t("lineUi.profileTabHint", {
|
||||
defaultValue:
|
||||
"占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
|
||||
})}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
|
||||
{canManageNode && canEditProfileTab ? (
|
||||
<div className="mt-6 flex justify-end border-t border-border/60 pt-5">
|
||||
<Button
|
||||
type="button"
|
||||
className="min-w-[10rem]"
|
||||
disabled={profileSaving || profileFields.loading}
|
||||
onClick={onSaveProfile}
|
||||
>
|
||||
{profileSaving
|
||||
? t("common:actions.saving", { defaultValue: "保存中…" })
|
||||
: t("lineUi.saveProfile", { defaultValue: "保存占成与授信" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{detailTab === "downline" && canViewDownlineTab ? (
|
||||
<DownlineTable
|
||||
childAgents={childAgents}
|
||||
childCountById={childCountById}
|
||||
canManageNode={canManageNode}
|
||||
canCreateChild={canCreateChild}
|
||||
canDeleteChild={canDeleteChild}
|
||||
onEditChild={onEditChild}
|
||||
onDeleteChild={onDeleteChild}
|
||||
onSelectChild={onSelectChild}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detailTab === "players" && canViewPlayersTab ? (
|
||||
<AgentsPlayersPanel
|
||||
siteCode={siteCode}
|
||||
agentNodeId={node.id}
|
||||
allowCreatePlayer={profile?.can_create_player === true}
|
||||
embedded
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
profile,
|
||||
profileLoading,
|
||||
cycleLabel,
|
||||
profileReadOnly,
|
||||
canViewDownlineTab,
|
||||
canViewPlayersTab,
|
||||
childCount,
|
||||
onGoToDownline,
|
||||
onGoToPlayers,
|
||||
}: {
|
||||
profile: AgentProfileRow | null;
|
||||
profileLoading: boolean;
|
||||
cycleLabel: string;
|
||||
profileReadOnly: boolean;
|
||||
canViewDownlineTab: boolean;
|
||||
canViewPlayersTab: boolean;
|
||||
childCount: number;
|
||||
onGoToDownline: () => void;
|
||||
onGoToPlayers: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
|
||||
const rebateCap =
|
||||
profile && !profileLoading ? ratioToPercentUi(profile.rebate_limit ?? 0) : null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
{profileReadOnly ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<MetricCard
|
||||
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
|
||||
highlight
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
|
||||
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
|
||||
subtitle={
|
||||
rebateCap !== null
|
||||
? t("lineUi.shareRebateCap", {
|
||||
defaultValue: "回水上限 {{rate}}%",
|
||||
rate: rebateCap,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
accent
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
|
||||
highlight
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!profileReadOnly && !profileLoading && profile ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("lineUi.profileFootnote", {
|
||||
defaultValue: "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
|
||||
rebate: ratioToPercentUi(profile.rebate_limit ?? 0),
|
||||
defaultRebate: ratioToPercentUi(profile.default_player_rebate ?? 0),
|
||||
cycle: cycleLabel,
|
||||
})}
|
||||
{(profile.risk_tags?.length ?? 0) > 0
|
||||
? ` · ${t("profile.riskTags", { defaultValue: "风控" })}: ${profile.risk_tags?.join(", ")}`
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{profileReadOnly ? (
|
||||
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
|
||||
{t("lineUi.selfAgentOverviewHint", {
|
||||
defaultValue:
|
||||
"以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canViewDownlineTab || canViewPlayersTab ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{canViewDownlineTab ? (
|
||||
<OverviewLinkCard
|
||||
icon={Network}
|
||||
title={t("lineUi.tabDownline", { defaultValue: "直属下级" })}
|
||||
description={t("lineUi.overviewDownlineCard", {
|
||||
defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。",
|
||||
count: childCount,
|
||||
})}
|
||||
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
|
||||
onAction={onGoToDownline}
|
||||
/>
|
||||
) : null}
|
||||
{canViewPlayersTab ? (
|
||||
<OverviewLinkCard
|
||||
icon={Users}
|
||||
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
|
||||
description={t("lineUi.overviewPlayersHint", {
|
||||
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
|
||||
})}
|
||||
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
|
||||
onAction={onGoToPlayers}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewLinkCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Card className="border-border/70 shadow-sm">
|
||||
<CardContent className="flex items-start justify-between gap-4 pt-5">
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/8 text-primary">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">{title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 text-primary hover:text-primary"
|
||||
onClick={onAction}
|
||||
>
|
||||
{actionLabel}
|
||||
<ChevronRight className="ml-0.5 size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DownlineTable({
|
||||
childAgents,
|
||||
childCountById,
|
||||
canManageNode,
|
||||
canCreateChild,
|
||||
canDeleteChild,
|
||||
onEditChild,
|
||||
onDeleteChild,
|
||||
onSelectChild,
|
||||
onAddChild,
|
||||
}: {
|
||||
childAgents: AgentNodeRow[];
|
||||
childCountById: Map<number, number>;
|
||||
canManageNode: boolean;
|
||||
canCreateChild: boolean;
|
||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||
onEditChild: (node: AgentNodeRow) => void;
|
||||
onDeleteChild: (node: AgentNodeRow) => void;
|
||||
onSelectChild: (node: AgentNodeRow) => void;
|
||||
onAddChild: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
|
||||
if (childAgents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border/70 bg-card px-6 py-16 text-center shadow-sm">
|
||||
<AdminNoResourceState className="py-4">
|
||||
{canManageNode && canCreateChild ? (
|
||||
<Button type="button" className="mt-2" onClick={onAddChild}>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</AdminNoResourceState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead>
|
||||
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
|
||||
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
|
||||
<TableHead>{t("lineUi.downlineColumns.email", { defaultValue: "邮箱" })}</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("profile.totalShareRate", { defaultValue: "占成 (%)" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级数" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">{t("common:status.label", { defaultValue: "状态" })}</TableHead>
|
||||
{canManageNode ? (
|
||||
<TableHead className="sticky right-0 z-10 w-14 bg-muted/40 text-center shadow-[-1px_0_0_var(--border)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{childAgents.map((child) => {
|
||||
const summary = child.profile_summary;
|
||||
return (
|
||||
<TableRow
|
||||
key={child.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectChild(child)}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">{child.code}</TableCell>
|
||||
<TableCell className="font-medium">{child.name}</TableCell>
|
||||
<TableCell className="text-xs">{child.username ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-[10rem] truncate text-xs text-muted-foreground">
|
||||
{child.email ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{summary ? `${ratioToPercentUi(summary.total_share_rate)}%` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{summary ? formatCredit(summary.credit_limit) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{summary ? formatCredit(summary.allocated_credit) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{summary ? settlementCycleLabel(summary.settlement_cycle, t) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums text-xs">
|
||||
{childCountById.get(child.id) ?? 0}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
|
||||
{child.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "启用" })
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
{canManageNode ? (
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: t("editNode", { defaultValue: "编辑代理" }),
|
||||
icon: Pencil,
|
||||
onClick: () => onEditChild(child),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("deleteNode", { defaultValue: "删除代理" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: !canDeleteChild(child),
|
||||
onClick: () => onDeleteChild(child),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
accent = false,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle?: string;
|
||||
accent?: boolean;
|
||||
highlight?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border bg-card px-4 py-4 shadow-sm transition-colors",
|
||||
highlight && "border-primary/25 bg-primary/[0.04]",
|
||||
accent && !highlight && "border-border/70",
|
||||
!accent && !highlight && "border-border/70",
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-2xl font-semibold tabular-nums tracking-tight",
|
||||
highlight ? "text-primary" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count?: number;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative -mb-px shrink-0 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 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{count !== undefined && count > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1.5 inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums",
|
||||
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { postAdminAgentLine } from "@/api/admin-agent-lines";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -17,21 +19,22 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { percentUiToRatio } from "@/lib/admin-rate-percent";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
||||
|
||||
export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [secrets, setSecrets] = useState<{ sso: string; wallet: string } | null>(null);
|
||||
const [sitesLoading, setSitesLoading] = useState(true);
|
||||
const [sites, setSites] = useState<AdminIntegrationSiteRow[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
site_code: "",
|
||||
code: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
currency_code: "NPR",
|
||||
wallet_api_url: "",
|
||||
notes: "",
|
||||
total_share_rate: "0",
|
||||
credit_limit: "0",
|
||||
rebate_limit: "0",
|
||||
@@ -40,33 +43,51 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
can_grant_extra_rebate: false,
|
||||
});
|
||||
|
||||
useAsyncEffect(() => {
|
||||
setSitesLoading(true);
|
||||
void getAdminIntegrationSites()
|
||||
.then((data) => setSites(data.items))
|
||||
.catch(() => setSites([]))
|
||||
.finally(() => setSitesLoading(false));
|
||||
}, []);
|
||||
|
||||
const unboundSites = useMemo(
|
||||
() => sites.filter((row) => !row.has_line_root),
|
||||
[sites],
|
||||
);
|
||||
|
||||
async function onSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!form.site_code.trim()) {
|
||||
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setSecrets(null);
|
||||
try {
|
||||
const result = await postAdminAgentLine({
|
||||
await postAdminAgentLine({
|
||||
site_code: form.site_code.trim().toLowerCase(),
|
||||
code: form.code.trim().toLowerCase(),
|
||||
name: form.name.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
currency_code: form.currency_code,
|
||||
wallet_api_url: form.wallet_api_url.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
total_share_rate: Number.parseFloat(form.total_share_rate) || 0,
|
||||
credit_limit: Number.parseInt(form.credit_limit, 10) || 0,
|
||||
rebate_limit: Number.parseFloat(form.rebate_limit) || 0,
|
||||
default_player_rebate: Number.parseFloat(form.default_player_rebate) || 0,
|
||||
rebate_limit: percentUiToRatio(form.rebate_limit),
|
||||
default_player_rebate: percentUiToRatio(form.default_player_rebate),
|
||||
settlement_cycle: form.settlement_cycle,
|
||||
can_grant_extra_rebate: form.can_grant_extra_rebate,
|
||||
});
|
||||
if (result.secrets) {
|
||||
setSecrets({
|
||||
sso: result.secrets.sso_jwt_secret,
|
||||
wallet: result.secrets.wallet_api_key,
|
||||
});
|
||||
}
|
||||
toast.success(t("agents:lineProvision.success", { defaultValue: "线路已开通" }));
|
||||
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
site_code: "",
|
||||
code: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
}));
|
||||
const data = await getAdminIntegrationSites();
|
||||
setSites(data.items);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
|
||||
@@ -77,10 +98,61 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "开通代理线路" })}>
|
||||
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
|
||||
<p className="mb-2 max-w-xl text-sm text-muted-foreground">
|
||||
{t("agents:subnav.provisionHint", {
|
||||
defaultValue:
|
||||
"请先在「平台管理 → 接入配置」创建接入站点;对接密钥在站点创建时一次性展示。",
|
||||
})}
|
||||
</p>
|
||||
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
|
||||
{t("agents:lineProvision.description", {
|
||||
defaultValue:
|
||||
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。",
|
||||
})}{" "}
|
||||
<Link
|
||||
href="/admin/config/integration-sites"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("agents:lineProvision.openIntegrationSites", {
|
||||
defaultValue: "前往接入站点",
|
||||
})}
|
||||
</Link>
|
||||
</p>
|
||||
<form className="grid max-w-xl gap-4" onSubmit={onSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.code", { defaultValue: "站点 code" })}</Label>
|
||||
<Label>{t("agents:lineProvision.siteCode", { defaultValue: "接入站点" })}</Label>
|
||||
<Select
|
||||
value={form.site_code}
|
||||
onValueChange={(value) => setForm((f) => ({ ...f, site_code: value ?? "" }))}
|
||||
disabled={sitesLoading || unboundSites.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
sitesLoading
|
||||
? t("common:loading", { defaultValue: "加载中…" })
|
||||
: unboundSites.length === 0
|
||||
? t("agents:lineProvision.noUnboundSite", {
|
||||
defaultValue: "暂无未绑定一级代理的站点",
|
||||
})
|
||||
: t("agents:lineProvision.siteCodePlaceholder", {
|
||||
defaultValue: "选择站点",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unboundSites.map((site) => (
|
||||
<SelectItem key={site.id} value={site.code}>
|
||||
{site.name} ({site.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
|
||||
<Input
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
||||
@@ -89,7 +161,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.name", { defaultValue: "线路名称" })}</Label>
|
||||
<Label>{t("agents:lineProvision.name", { defaultValue: "一级代理名称" })}</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
@@ -97,7 +169,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.username", { defaultValue: "代理账号" })}</Label>
|
||||
<Label>{t("agents:lineProvision.username", { defaultValue: "后台登录账号" })}</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||
@@ -114,13 +186,6 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.walletUrl", { defaultValue: "钱包 API URL" })}</Label>
|
||||
<Input
|
||||
value={form.wallet_api_url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, wallet_api_url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium">
|
||||
{t("agents:profile.section", { defaultValue: "占成与授信" })}
|
||||
@@ -147,24 +212,26 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.rebateLimit", { defaultValue: "回水上限" })}</Label>
|
||||
<Label>{t("agents:profile.rebateLimit", { defaultValue: "回水上限 (%)" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={form.rebate_limit}
|
||||
placeholder="0.5"
|
||||
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}</Label>
|
||||
<Label>{t("agents:profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={form.default_player_rebate}
|
||||
placeholder="0.5"
|
||||
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -208,32 +275,12 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("common:notes", { defaultValue: "备注" })}</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<Button type="submit" disabled={submitting || unboundSites.length === 0}>
|
||||
{submitting
|
||||
? t("common:submitting", { defaultValue: "提交中…" })
|
||||
: t("agents:lineProvision.submit", { defaultValue: "开通线路" })}
|
||||
: t("agents:lineProvision.submit", { defaultValue: "创建一级代理" })}
|
||||
</Button>
|
||||
</form>
|
||||
{secrets ? (
|
||||
<div className="mt-6 rounded-md border border-amber-500/40 bg-amber-500/5 p-4 text-sm">
|
||||
<p className="font-medium text-amber-700">
|
||||
{t("agents:lineProvision.secretsOnce", { defaultValue: "密钥仅显示一次,请妥善保存" })}
|
||||
</p>
|
||||
<p className="mt-2 break-all">
|
||||
SSO: <code>{secrets.sso}</code>
|
||||
</p>
|
||||
<p className="mt-1 break-all">
|
||||
Wallet API Key: <code>{secrets.wallet}</code>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
301
src/modules/agents/agent-line-sidebar.tsx
Normal file
301
src/modules/agents/agent-line-sidebar.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
||||
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
||||
|
||||
function formatCredit(amount: number, currencyCode = "NPR"): string {
|
||||
return formatAdminCreditMajorDecimal(amount, currencyCode);
|
||||
}
|
||||
|
||||
function nodeMatchesKeyword(
|
||||
node: AgentNodeRow,
|
||||
normalized: string,
|
||||
parentNameMap: Map<number, string>,
|
||||
): boolean {
|
||||
if (normalized === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentName =
|
||||
node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : "";
|
||||
|
||||
return [node.name, node.code, node.username ?? "", parentName]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalized);
|
||||
}
|
||||
|
||||
function pruneTreeForSearch(
|
||||
nodes: AgentNodeRow[],
|
||||
normalized: string,
|
||||
parentNameMap: Map<number, string>,
|
||||
): AgentNodeRow[] {
|
||||
if (normalized === "") {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const out: AgentNodeRow[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const children = pruneTreeForSearch(node.children ?? [], normalized, parentNameMap);
|
||||
const selfMatch = nodeMatchesKeyword(node, normalized, parentNameMap);
|
||||
|
||||
if (selfMatch || children.length > 0) {
|
||||
out.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectExpandableIds(nodes: AgentNodeRow[], into: Set<number>): void {
|
||||
for (const node of nodes) {
|
||||
if ((node.children?.length ?? 0) > 0) {
|
||||
into.add(node.id);
|
||||
collectExpandableIds(node.children ?? [], into);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapSiteRoots(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
return nodes.flatMap((node) => (node.is_root ? (node.children ?? []) : [node]));
|
||||
}
|
||||
|
||||
export type AgentLineSidebarProps = {
|
||||
siteLabel: string | null;
|
||||
/** API 返回的嵌套树(含 children) */
|
||||
tree: AgentNodeRow[];
|
||||
parentNameMap: Map<number, string>;
|
||||
selectedId: number | null;
|
||||
keyword: string;
|
||||
agentCount: number;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSelect: (node: AgentNodeRow) => void;
|
||||
};
|
||||
|
||||
type TreeRowProps = {
|
||||
node: AgentNodeRow;
|
||||
depth: number;
|
||||
selectedId: number | null;
|
||||
expandedIds: Set<number>;
|
||||
onToggleExpand: (id: number) => void;
|
||||
onSelect: (node: AgentNodeRow) => void;
|
||||
};
|
||||
|
||||
function TreeRow({
|
||||
node,
|
||||
depth,
|
||||
selectedId,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
}: TreeRowProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const children = node.children ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
const expanded = expandedIds.has(node.id);
|
||||
const active = selectedId === node.id;
|
||||
const indent = depth * 14;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-start gap-0.5 rounded-lg py-1.5 pr-2 transition-colors",
|
||||
active ? "bg-primary/12 ring-1 ring-primary/30 shadow-sm" : "hover:bg-background/80",
|
||||
)}
|
||||
style={{ paddingLeft: `${6 + indent}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? t("lineUi.collapse", { defaultValue: "收起" }) : t("lineUi.expand", { defaultValue: "展开" })}
|
||||
className="mt-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand(node.id);
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 transition-transform", expanded && "rotate-90")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-1 inline-block size-6 shrink-0" aria-hidden />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className="min-w-0 flex-1 px-1 py-0.5 text-left"
|
||||
onClick={() => onSelect(node)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{node.name}</span>
|
||||
<AdminStatusBadge
|
||||
tone={resolveRoleStatusTone(node.status)}
|
||||
className="shrink-0 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{node.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "启用" })
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{node.username ?? node.code}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded ? (
|
||||
<ul className="space-y-0.5">
|
||||
{children.map((child) => (
|
||||
<TreeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentLineSidebar({
|
||||
siteLabel,
|
||||
tree,
|
||||
parentNameMap,
|
||||
selectedId,
|
||||
keyword,
|
||||
agentCount,
|
||||
onKeywordChange,
|
||||
onSelect,
|
||||
}: AgentLineSidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
|
||||
const displayForest = useMemo(() => {
|
||||
const pruned = pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
|
||||
|
||||
return unwrapSiteRoots(pruned);
|
||||
}, [normalizedKeyword, parentNameMap, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Set<number>();
|
||||
collectExpandableIds(tree, next);
|
||||
setExpandedIds(next);
|
||||
}, [tree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
const walk = (nodes: AgentNodeRow[], ancestors: number[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
const chain = [...ancestors, node.id];
|
||||
if (node.id === selectedId) {
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
if (walk(node.children ?? [], chain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
walk(tree, []);
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [selectedId, tree]);
|
||||
|
||||
const toggleExpand = useCallback((id: number) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hasAnyAgent = displayForest.length > 0;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full min-h-[28rem] w-full flex-col bg-muted/10 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
|
||||
<div className="space-y-3 border-b border-border/60 bg-card px-4 py-4">
|
||||
{siteLabel ? (
|
||||
<p className="truncate text-xs font-medium text-foreground/80" title={siteLabel}>
|
||||
{siteLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("lineUi.agentCount", {
|
||||
defaultValue: "本组 {{count}} 个代理",
|
||||
count: agentCount,
|
||||
})}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => onKeywordChange(e.target.value)}
|
||||
className="h-9 pl-8 text-sm"
|
||||
placeholder={t("lineUi.searchPlaceholder", {
|
||||
defaultValue: "搜索名称或登录名",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
{!hasAnyAgent ? (
|
||||
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
|
||||
) : (
|
||||
<ul className="space-y-0.5" role="listbox" aria-label={t("listTitle", { defaultValue: "代理列表" })}>
|
||||
{displayForest.map((node) => (
|
||||
<TreeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleExpand}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export { formatCredit };
|
||||
76
src/modules/agents/agent-line-subnav-visibility.ts
Normal file
76
src/modules/agents/agent-line-subnav-visibility.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_LINE_PROVISION,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import type { AdminAgentContext } from "@/types/api/admin-agent";
|
||||
|
||||
type AdminSessionLike = {
|
||||
is_super_admin?: boolean;
|
||||
permissions?: string[] | null;
|
||||
agent?: AdminAgentContext | null;
|
||||
};
|
||||
|
||||
/** 线路内绑定代理可查看账单;平台账号需 settlement.agent.* */
|
||||
export function canAccessAgentSettlementBills(
|
||||
session: AdminSessionLike | null | undefined,
|
||||
): boolean {
|
||||
if (session?.agent != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return adminHasAnyPermission(session?.permissions ?? [], [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]);
|
||||
}
|
||||
|
||||
/** 仅平台侧可开通新线路;绑定代理永不可见 */
|
||||
export function canAccessAgentLineProvision(
|
||||
session: AdminSessionLike | null | undefined,
|
||||
): boolean {
|
||||
if (session?.agent != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return adminHasAnyPermission(session?.permissions ?? [], [PRD_AGENT_LINE_PROVISION]);
|
||||
}
|
||||
|
||||
export function isAgentLineSubnavTabVisible(
|
||||
href: string,
|
||||
session: AdminSessionLike | null | undefined,
|
||||
): boolean {
|
||||
const perms = session?.permissions ?? [];
|
||||
const isSuper = session?.is_super_admin === true;
|
||||
const boundAgent = session?.agent ?? null;
|
||||
|
||||
if (isSuper) {
|
||||
return adminHasAnyPermission(perms, tabRequiredSlugs(href));
|
||||
}
|
||||
|
||||
if (href === "/admin/agents/provision") {
|
||||
return canAccessAgentLineProvision(session);
|
||||
}
|
||||
|
||||
if (href === "/admin/settlement-center" || href === "/admin/agents/settlement-bills") {
|
||||
return canAccessAgentSettlementBills(session);
|
||||
}
|
||||
|
||||
if (href === "/admin/agents") {
|
||||
return adminHasAnyPermission(perms, [...PRD_AGENTS_ACCESS_ANY]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function tabRequiredSlugs(href: string): readonly string[] {
|
||||
switch (href) {
|
||||
case "/admin/agents":
|
||||
return PRD_AGENTS_ACCESS_ANY;
|
||||
case "/admin/agents/provision":
|
||||
return [PRD_AGENT_LINE_PROVISION];
|
||||
case "/admin/settlement-center":
|
||||
case "/admin/agents/settlement-bills":
|
||||
return PRD_SETTLEMENT_AGENT_ACCESS_ANY;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
268
src/modules/agents/agent-profile-fields.tsx
Normal file
268
src/modules/agents/agent-profile-fields.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentParentCaps } from "@/types/api/admin-agent";
|
||||
|
||||
export type AgentProfileFieldsProps = {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
parentCaps: AgentParentCaps | null;
|
||||
availableCredit: number | null;
|
||||
canCreateChildAgent: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
shareRate: string;
|
||||
onShareRateChange: (value: string) => void;
|
||||
creditLimit: string;
|
||||
onCreditLimitChange: (value: string) => void;
|
||||
rebateLimit: string;
|
||||
onRebateLimitChange: (value: string) => void;
|
||||
defaultRebate: string;
|
||||
onDefaultRebateChange: (value: string) => void;
|
||||
settlementCycle: "daily" | "weekly" | "monthly";
|
||||
onSettlementCycleChange: (value: "daily" | "weekly" | "monthly") => void;
|
||||
extraRebate: boolean;
|
||||
onExtraRebateChange: (value: boolean) => void;
|
||||
canCreatePlayer: boolean;
|
||||
onCanCreatePlayerChange: (value: boolean) => void;
|
||||
canCreateChild: boolean;
|
||||
onCanCreateChildChange: (value: boolean) => void;
|
||||
riskTags: string;
|
||||
onRiskTagsChange: (value: string) => void;
|
||||
idPrefix?: string;
|
||||
currencyCode?: string;
|
||||
/** card:用于代理线路详情 Tab 内的卡片表单 */
|
||||
variant?: "default" | "card";
|
||||
};
|
||||
|
||||
export function AgentProfileFields({
|
||||
disabled = false,
|
||||
loading = false,
|
||||
parentCaps,
|
||||
availableCredit,
|
||||
canCreateChildAgent,
|
||||
isSuperAdmin,
|
||||
shareRate,
|
||||
onShareRateChange,
|
||||
creditLimit,
|
||||
onCreditLimitChange,
|
||||
rebateLimit,
|
||||
onRebateLimitChange,
|
||||
defaultRebate,
|
||||
onDefaultRebateChange,
|
||||
settlementCycle,
|
||||
onSettlementCycleChange,
|
||||
extraRebate,
|
||||
onExtraRebateChange,
|
||||
canCreatePlayer,
|
||||
onCanCreatePlayerChange,
|
||||
canCreateChild,
|
||||
onCanCreateChildChange,
|
||||
riskTags,
|
||||
onRiskTagsChange,
|
||||
idPrefix = "agent-profile",
|
||||
currencyCode = "NPR",
|
||||
variant = "default",
|
||||
}: AgentProfileFieldsProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const fieldDisabled = disabled || loading;
|
||||
const isCard = variant === "card";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{(parentCaps || availableCredit !== null) && !loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg text-xs text-muted-foreground",
|
||||
isCard ? "border border-border/60 bg-muted/25 px-3 py-2.5 space-y-1" : "space-y-1",
|
||||
)}
|
||||
>
|
||||
{parentCaps ? (
|
||||
<p>
|
||||
{t("profile.parentCaps", {
|
||||
defaultValue: "上级占成 {{share}}%,可下发额度 {{credit}}",
|
||||
share: parentCaps.total_share_rate,
|
||||
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{availableCredit !== null ? (
|
||||
<p>
|
||||
{t("profile.availableCredit", {
|
||||
defaultValue: "可下发额度:{{amount}}",
|
||||
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-4 sm:grid-cols-2",
|
||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-share-rate`}>
|
||||
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-share-rate`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={shareRate}
|
||||
onChange={(e) => onShareRateChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-credit-limit`}>
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-credit-limit`}
|
||||
type="number"
|
||||
min={0}
|
||||
value={creditLimit}
|
||||
onChange={(e) => onCreditLimitChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-rebate-limit`}>
|
||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-rebate-limit`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={rebateLimit}
|
||||
onChange={(e) => onRebateLimitChange(e.target.value)}
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-default-rebate`}>
|
||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-default-rebate`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={defaultRebate}
|
||||
onChange={(e) => onDefaultRebateChange(e.target.value)}
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor={`${idPrefix}-risk-tags`}>
|
||||
{t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-risk-tags`}
|
||||
value={riskTags}
|
||||
onChange={(e) => onRiskTagsChange(e.target.value)}
|
||||
placeholder={t("profile.riskTagsPlaceholder", {
|
||||
defaultValue: "逗号分隔,如 overdue, high_turnover",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor={`${idPrefix}-settlement-cycle`}>
|
||||
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={settlementCycle}
|
||||
onValueChange={(value) =>
|
||||
onSettlementCycleChange((value as "daily" | "weekly" | "monthly") ?? "weekly")
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-settlement-cycle`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">{t("profile.cycleDaily", { defaultValue: "日结" })}</SelectItem>
|
||||
<SelectItem value="weekly">{t("profile.cycleWeekly", { defaultValue: "周结" })}</SelectItem>
|
||||
<SelectItem value="monthly">{t("profile.cycleMonthly", { defaultValue: "月结" })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-4 border-t border-border/60 pt-4",
|
||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{!isCard ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profile.capabilityHint", {
|
||||
defaultValue:
|
||||
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-4 sm:grid-cols-1">
|
||||
<SwitchRow
|
||||
checked={extraRebate}
|
||||
onCheckedChange={onExtraRebateChange}
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
/>
|
||||
<SwitchRow
|
||||
checked={canCreatePlayer}
|
||||
onCheckedChange={onCanCreatePlayerChange}
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
/>
|
||||
<SwitchRow
|
||||
checked={canCreateChild}
|
||||
onCheckedChange={onCanCreateChildChange}
|
||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchRow({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
|
||||
<Label className="font-normal">{label}</Label>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Eye, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -14,13 +12,14 @@ import {
|
||||
putAgentNode,
|
||||
putAgentNodeProfile,
|
||||
} from "@/api/admin-agents";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import {
|
||||
AgentLineDetailPanel,
|
||||
type AgentDetailTab,
|
||||
} from "@/modules/agents/agent-line-detail-panel";
|
||||
import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar";
|
||||
import { AgentProfileFields } from "@/modules/agents/agent-profile-fields";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,43 +29,41 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
|
||||
import {
|
||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
percentUiToRatio,
|
||||
percentValueToUi,
|
||||
parsePercentUi,
|
||||
ratioToPercentUi,
|
||||
} from "@/lib/admin-rate-percent";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_MANAGE,
|
||||
PRD_AGENT_PROFILE_MANAGE,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
PRD_AGENT_SITES_ACCESS_ANY,
|
||||
PRD_INTEGRATION_ACCESS_ANY,
|
||||
PRD_USERS_MANAGE,
|
||||
} from "@/lib/admin-prd";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||
import type { AgentNodeRow, AgentParentCaps, AgentProfileRow } from "@/types/api/admin-agent";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function parseRiskTagsInput(text: string): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
text
|
||||
.split(/[,,\s]+/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
const out: AgentNodeRow[] = [];
|
||||
const walk = (list: AgentNodeRow[]) => {
|
||||
@@ -82,10 +79,6 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function countBusinessAgents(nodes: AgentNodeRow[]): number {
|
||||
return nodes.filter((node) => !node.is_root).length;
|
||||
}
|
||||
|
||||
export function AgentsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "common"]);
|
||||
@@ -104,23 +97,18 @@ export function AgentsConsole(): React.ReactElement {
|
||||
PRD_AGENT_PROFILE_MANAGE,
|
||||
PRD_AGENT_MANAGE,
|
||||
]);
|
||||
const canProvision =
|
||||
isSuperAdmin ||
|
||||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]);
|
||||
const canViewSiteList = adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_SITES_ACCESS_ANY]);
|
||||
const canSwitchSite = isSuperAdmin || adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
|
||||
|
||||
const [siteOptions, setSiteOptions] = useState<{ id: number; label: string; code: string }[]>([]);
|
||||
const [globalVisibleNodeCount, setGlobalVisibleNodeCount] = useState<number | null>(null);
|
||||
const [globalBusinessAgentCount, setGlobalBusinessAgentCount] = useState<number | null>(null);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
||||
const [tree, setTree] = useState<AgentNodeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [operationsTab, setOperationsTab] = useState<"subordinates" | "players">("subordinates");
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
||||
const [detailTab, setDetailTab] = useState<AgentDetailTab>("overview");
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
||||
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
||||
|
||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||
@@ -141,19 +129,21 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
|
||||
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
|
||||
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
|
||||
const [profileRiskTags, setProfileRiskTags] = useState("");
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [profileLoaded, setProfileLoaded] = useState(true);
|
||||
const [profileParentCaps, setProfileParentCaps] = useState<AgentParentCaps | null>(null);
|
||||
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
|
||||
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
|
||||
const [downlineDialogNode, setDownlineDialogNode] = useState<AgentNodeRow | null>(null);
|
||||
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
/** 登录账号是否可向子代理下放「允许创建下级」 */
|
||||
const canCreateChildAgent =
|
||||
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
|
||||
const canCreateChildForNode = (_node: AgentNodeRow) => canManageNode && canCreateChildAgent;
|
||||
const canViewPlayersTab =
|
||||
const hasUsersManagePermission =
|
||||
isSuperAdmin ||
|
||||
(boundAgent?.can_create_player !== false &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
|
||||
|
||||
const resetProfileForm = (mode: "create" | "edit" = "create") => {
|
||||
setProfileShareRate("0");
|
||||
@@ -164,24 +154,42 @@ export function AgentsConsole(): React.ReactElement {
|
||||
setProfileExtraRebate(false);
|
||||
setProfileCanCreateChild(mode === "create" ? false : false);
|
||||
setProfileCanCreatePlayer(true);
|
||||
setProfileRiskTags("");
|
||||
setProfileParentCaps(null);
|
||||
setProfileAvailableCredit(null);
|
||||
};
|
||||
|
||||
const applyProfileRowToForm = (row: AgentProfileRow) => {
|
||||
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
|
||||
setProfileCreditLimit(String(row.credit_limit ?? 0));
|
||||
setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
|
||||
setProfileDefaultRebate(ratioToPercentUi(row.default_player_rebate ?? 0));
|
||||
setProfileSettlementCycle(normalizeAgentSettlementCycle(row.settlement_cycle));
|
||||
setProfileExtraRebate(Boolean(row.can_grant_extra_rebate));
|
||||
setProfileCanCreateChild(Boolean(row.can_create_child_agent));
|
||||
setProfileCanCreatePlayer(row.can_create_player !== false);
|
||||
setProfileParentCaps(row.parent_caps ?? null);
|
||||
setProfileAvailableCredit(row.available_credit ?? null);
|
||||
setProfileRiskTags((row.risk_tags ?? []).join(", "));
|
||||
};
|
||||
|
||||
const profilePayload = () => ({
|
||||
total_share_rate: Number.parseFloat(profileShareRate) || 0,
|
||||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
||||
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
|
||||
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
|
||||
rebate_limit: percentUiToRatio(profileRebateLimit),
|
||||
default_player_rebate: percentUiToRatio(profileDefaultRebate),
|
||||
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
|
||||
can_grant_extra_rebate: profileExtraRebate,
|
||||
can_create_child_agent: profileCanCreateChild,
|
||||
can_create_player: profileCanCreatePlayer,
|
||||
risk_tags: parseRiskTagsInput(profileRiskTags),
|
||||
});
|
||||
|
||||
const validateProfileFields = (): string | null => {
|
||||
const shareRate = Number.parseFloat(profileShareRate);
|
||||
const creditLimit = Number.parseInt(profileCreditLimit, 10);
|
||||
const rebateLimit = Number.parseFloat(profileRebateLimit);
|
||||
const defaultRebate = Number.parseFloat(profileDefaultRebate);
|
||||
const rebateLimit = parsePercentUi(profileRebateLimit);
|
||||
const defaultRebate = parsePercentUi(profileDefaultRebate);
|
||||
|
||||
if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) {
|
||||
return t("profile.validation.shareRange", {
|
||||
@@ -195,15 +203,15 @@ export function AgentsConsole(): React.ReactElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (Number.isNaN(rebateLimit) || rebateLimit < 0 || rebateLimit > 1) {
|
||||
if (rebateLimit === null || rebateLimit < 0 || rebateLimit > 100) {
|
||||
return t("profile.validation.rebateLimitRange", {
|
||||
defaultValue: "回水上限须在 0–1 之间(如 0.005 表示 0.5%)",
|
||||
defaultValue: "回水上限须在 0–100% 之间",
|
||||
});
|
||||
}
|
||||
|
||||
if (Number.isNaN(defaultRebate) || defaultRebate < 0 || defaultRebate > 1) {
|
||||
if (defaultRebate === null || defaultRebate < 0 || defaultRebate > 100) {
|
||||
return t("profile.validation.defaultRebateRange", {
|
||||
defaultValue: "默认玩家回水须在 0–1 之间",
|
||||
defaultValue: "默认玩家回水须在 0–100% 之间",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,10 +230,8 @@ export function AgentsConsole(): React.ReactElement {
|
||||
[flatNodes],
|
||||
);
|
||||
const businessRows = useMemo(() => flatNodes.filter((node) => !node.is_root), [flatNodes]);
|
||||
const currentSiteNodeCount = flatNodes.length;
|
||||
const currentSiteBusinessAgentCount = useMemo(() => countBusinessAgents(flatNodes), [flatNodes]);
|
||||
const selectedSiteLabel = useMemo(
|
||||
() => siteOptions.find((site) => site.id === adminSiteId)?.label ?? null,
|
||||
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
|
||||
[adminSiteId, siteOptions],
|
||||
);
|
||||
const activeSiteCode = useMemo(() => {
|
||||
@@ -239,11 +245,44 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
|
||||
}, [adminSiteId, boundAgent?.site_code, flatNodes, siteOptions]);
|
||||
const playersPanelAgentId = useMemo(
|
||||
() => (isSuperAdmin ? null : (boundAgent?.id ?? null)),
|
||||
[boundAgent?.id, isSuperAdmin],
|
||||
const rootNode = useMemo(
|
||||
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
|
||||
[flatNodes],
|
||||
);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() =>
|
||||
selectedNodeId !== null
|
||||
? (flatNodes.find((node) => node.id === selectedNodeId) ?? null)
|
||||
: null,
|
||||
[flatNodes, selectedNodeId],
|
||||
);
|
||||
|
||||
const isOwnAgentNode =
|
||||
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
|
||||
|
||||
const canEditSelectedProfile =
|
||||
canManageProfile && selectedNode !== null && (isSuperAdmin || !isOwnAgentNode);
|
||||
|
||||
const selectedChildAgents = useMemo(() => {
|
||||
if (selectedNode === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return flatNodes.filter((node) => node.parent_id === selectedNode.id);
|
||||
}, [flatNodes, selectedNode]);
|
||||
|
||||
const childCountById = useMemo(() => {
|
||||
const counts = new Map<number, number>();
|
||||
for (const node of flatNodes) {
|
||||
if (node.parent_id === null) {
|
||||
continue;
|
||||
}
|
||||
counts.set(node.parent_id, (counts.get(node.parent_id) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [flatNodes]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
@@ -262,21 +301,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
});
|
||||
}, [businessRows, keyword, parentNameMap]);
|
||||
|
||||
const total = filteredRows.length;
|
||||
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||
const currentPage = Math.min(page, lastPage);
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = (currentPage - 1) * perPage;
|
||||
return filteredRows.slice(start, start + perPage);
|
||||
}, [currentPage, filteredRows, perPage]);
|
||||
const downlineChildAgents = useMemo(() => {
|
||||
if (downlineDialogNode === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return flatNodes.filter((node) => node.parent_id === downlineDialogNode.id);
|
||||
}, [downlineDialogNode, flatNodes]);
|
||||
|
||||
const loadTree = useCallback(async (siteId?: number | null) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
@@ -292,59 +316,124 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!canViewAgents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canSwitchSite) {
|
||||
void getAdminIntegrationSites()
|
||||
.then((data) => {
|
||||
const options = data.items.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
label: `${row.name} (${row.code})`,
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
if (options.length > 0 && adminSiteId === null) {
|
||||
setAdminSiteId(options[0]?.id ?? null);
|
||||
}
|
||||
})
|
||||
.catch(() => setSiteOptions([]));
|
||||
} else if (profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
if (adminSiteId === null) {
|
||||
if (profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
return;
|
||||
}
|
||||
if (siteOptions.length > 0 && isSuperAdmin) {
|
||||
setAdminSiteId(siteOptions[0]?.id ?? null);
|
||||
}
|
||||
}
|
||||
}, [adminSiteId, canSwitchSite, canViewAgents, profile?.agent?.admin_site_id]);
|
||||
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!canSwitchSite || siteOptions.length === 0) {
|
||||
setGlobalVisibleNodeCount(null);
|
||||
setGlobalBusinessAgentCount(null);
|
||||
if (selectedNode === null) {
|
||||
setSelectedProfile(null);
|
||||
setSelectedProfileLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.all(siteOptions.map(async (site) => getAgentTree(site.id)))
|
||||
.then((results) => {
|
||||
const allNodes = results.flatMap((result) => flattenTree(result.tree));
|
||||
setGlobalVisibleNodeCount(allNodes.length);
|
||||
setGlobalBusinessAgentCount(countBusinessAgents(allNodes));
|
||||
setSelectedProfileLoading(true);
|
||||
void getAgentNodeProfile(selectedNode.id)
|
||||
.then((row) => {
|
||||
setSelectedProfile(row);
|
||||
if (!nodeDialogOpen) {
|
||||
applyProfileRowToForm(row);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setGlobalVisibleNodeCount(null);
|
||||
setGlobalBusinessAgentCount(null);
|
||||
});
|
||||
}, [canSwitchSite, siteOptions]);
|
||||
.catch(() => setSelectedProfile(null))
|
||||
.finally(() => setSelectedProfileLoading(false));
|
||||
}, [selectedNode?.id, nodeDialogOpen]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId === null && !canSwitchSite && profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
if (rootNode === null) {
|
||||
setRootProfile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (adminSiteId !== null || !canSwitchSite) {
|
||||
void getAgentNodeProfile(rootNode.id)
|
||||
.then((p) => setRootProfile(p))
|
||||
.catch(() => setRootProfile(null));
|
||||
}, [rootNode?.id]);
|
||||
|
||||
/** 仅上级/平台维护下级占成授信;代理查看自己时不展示配置 Tab */
|
||||
const canShowProfileTab = canEditSelectedProfile;
|
||||
|
||||
const canShowDownlineTab = useMemo(
|
||||
() =>
|
||||
selectedNode !== null &&
|
||||
!selectedProfileLoading &&
|
||||
selectedProfile?.can_create_child_agent === true,
|
||||
[selectedNode, selectedProfile, selectedProfileLoading],
|
||||
);
|
||||
|
||||
const canShowPlayersTab = useMemo(
|
||||
() =>
|
||||
selectedNode !== null &&
|
||||
!selectedProfileLoading &&
|
||||
selectedProfile?.can_create_player === true &&
|
||||
hasUsersManagePermission,
|
||||
[hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading],
|
||||
);
|
||||
|
||||
const canCreateChildOnSelected = useMemo(
|
||||
() => canManageNode && selectedProfile?.can_create_child_agent === true,
|
||||
[canManageNode, selectedProfile?.can_create_child_agent],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProfileLoading || selectedNode === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detailTab === "profile" && !canShowProfileTab) {
|
||||
setDetailTab("overview");
|
||||
} else if (detailTab === "downline" && !canShowDownlineTab) {
|
||||
setDetailTab(canShowPlayersTab ? "players" : "overview");
|
||||
} else if (detailTab === "players" && !canShowPlayersTab) {
|
||||
setDetailTab(canShowDownlineTab ? "downline" : "overview");
|
||||
}
|
||||
}, [
|
||||
canShowDownlineTab,
|
||||
canShowPlayersTab,
|
||||
canShowProfileTab,
|
||||
detailTab,
|
||||
selectedNode,
|
||||
selectedProfileLoading,
|
||||
]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (filteredRows.length === 0) {
|
||||
setSelectedNodeId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeId === null || !filteredRows.some((row) => row.id === selectedNodeId)) {
|
||||
setSelectedNodeId(filteredRows[0]?.id ?? null);
|
||||
}
|
||||
}, [filteredRows, selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailTab("overview");
|
||||
}, [selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOwnAgentNode && detailTab === "profile") {
|
||||
setDetailTab("overview");
|
||||
}
|
||||
}, [detailTab, isOwnAgentNode]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId !== null) {
|
||||
void loadTree(adminSiteId);
|
||||
}
|
||||
}, [adminSiteId, canSwitchSite, loadTree, profile?.agent?.admin_site_id]);
|
||||
}, [adminSiteId, loadTree]);
|
||||
|
||||
const openCreateChildForNode = (node: AgentNodeRow) => {
|
||||
setNodeDialogMode("create");
|
||||
@@ -359,6 +448,11 @@ export function AgentsConsole(): React.ReactElement {
|
||||
setProfileLoaded(true);
|
||||
setEditingNodeNeedsPrimaryAccount(false);
|
||||
setNodeDialogOpen(true);
|
||||
if (canManageProfile) {
|
||||
void getAgentNodeProfile(node.id)
|
||||
.then((p) => setProfileParentCaps(p.parent_caps ?? null))
|
||||
.catch(() => setProfileParentCaps(null));
|
||||
}
|
||||
};
|
||||
|
||||
const openEditForNode = (node: AgentNodeRow) => {
|
||||
@@ -383,14 +477,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
setProfileLoaded(false);
|
||||
void getAgentNodeProfile(node.id)
|
||||
.then((p) => {
|
||||
setProfileShareRate(String(p.total_share_rate ?? 0));
|
||||
setProfileCreditLimit(String(p.credit_limit ?? 0));
|
||||
setProfileRebateLimit(String(p.rebate_limit ?? 0));
|
||||
setProfileDefaultRebate(String(p.default_player_rebate ?? 0));
|
||||
setProfileSettlementCycle(normalizeAgentSettlementCycle(p.settlement_cycle));
|
||||
setProfileExtraRebate(Boolean(p.can_grant_extra_rebate));
|
||||
setProfileCanCreateChild(Boolean(p.can_create_child_agent));
|
||||
setProfileCanCreatePlayer(p.can_create_player !== false);
|
||||
applyProfileRowToForm(p);
|
||||
setProfileLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -404,64 +491,118 @@ export function AgentsConsole(): React.ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
const renderRowActions = (node: AgentNodeRow) => {
|
||||
const rowDeleteBlockedByChildren = (node.children?.length ?? 0) > 0;
|
||||
const rowDeleteBlockedBySelf = profile?.agent?.id === node.id;
|
||||
const rowCanDelete =
|
||||
canManageNode && !rowDeleteBlockedByChildren && !rowDeleteBlockedBySelf;
|
||||
const canDeleteNode = (node: AgentNodeRow): boolean => {
|
||||
const blockedByChildren = (node.children?.length ?? 0) > 0;
|
||||
const blockedBySelf = profile?.agent?.id === node.id;
|
||||
|
||||
return (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: t("editNode", { defaultValue: "编辑代理" }),
|
||||
icon: Pencil,
|
||||
hidden: !canManageNode,
|
||||
onClick: () => openEditForNode(node),
|
||||
},
|
||||
{
|
||||
key: "create-child",
|
||||
label: t("createChild", { defaultValue: "添加下级代理" }),
|
||||
icon: Plus,
|
||||
hidden: !canCreateChildForNode(node),
|
||||
onClick: () => openCreateChildForNode(node),
|
||||
},
|
||||
{
|
||||
key: "view-downline",
|
||||
label: t("viewDownline", { defaultValue: "查看下级代理和玩家" }),
|
||||
icon: Eye,
|
||||
onClick: () => setDownlineDialogNode(node),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("deleteNode", { defaultValue: "删除代理" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
hidden: !canManageNode,
|
||||
disabled: !rowCanDelete,
|
||||
onClick: () => {
|
||||
if (!rowCanDelete) {
|
||||
return;
|
||||
}
|
||||
requestConfirm({
|
||||
title: t("deleteNode", { defaultValue: "删除代理" }),
|
||||
description: t("deleteNodeConfirm", {
|
||||
defaultValue: "删除后将同时移除该代理的唯一登录账号,且不可恢复。",
|
||||
}),
|
||||
confirmLabel: t("deleteNode", { defaultValue: "删除代理" }),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: async () => {
|
||||
await deleteAgentNode(node.id);
|
||||
toast.success(t("deleteSuccess", { name: node.name }));
|
||||
await loadTree(adminSiteId);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
return canManageNode && !blockedByChildren && !blockedBySelf;
|
||||
};
|
||||
|
||||
const handleDeleteNode = (node: AgentNodeRow): void => {
|
||||
if (!canDeleteNode(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestConfirm({
|
||||
title: t("deleteNode", { defaultValue: "删除代理" }),
|
||||
description: t("deleteNodeConfirm", {
|
||||
defaultValue: "删除后将同时移除该代理的唯一登录账号,且不可恢复。",
|
||||
}),
|
||||
confirmLabel: t("deleteNode", { defaultValue: "删除代理" }),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: async () => {
|
||||
await deleteAgentNode(node.id);
|
||||
toast.success(t("deleteSuccess", { name: node.name }));
|
||||
if (selectedNodeId === node.id) {
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
await loadTree(adminSiteId);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveInlineProfile = async (): Promise<void> => {
|
||||
if (selectedNode === null || !canEditSelectedProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateProfileFields();
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileSaving(true);
|
||||
try {
|
||||
const updated = await putAgentNodeProfile(selectedNode.id, profilePayload());
|
||||
setSelectedProfile(updated);
|
||||
applyProfileRowToForm(updated);
|
||||
toast.success(t("profile.saveSuccess", { defaultValue: "占成与授信已保存" }));
|
||||
await loadTree(adminSiteId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inlineProfileFields = useMemo(() => {
|
||||
if (!canShowProfileTab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: !canEditSelectedProfile,
|
||||
loading: selectedProfileLoading,
|
||||
parentCaps: profileParentCaps,
|
||||
availableCredit: profileAvailableCredit,
|
||||
canCreateChildAgent,
|
||||
isSuperAdmin,
|
||||
shareRate: profileShareRate,
|
||||
onShareRateChange: setProfileShareRate,
|
||||
creditLimit: profileCreditLimit,
|
||||
onCreditLimitChange: setProfileCreditLimit,
|
||||
rebateLimit: profileRebateLimit,
|
||||
onRebateLimitChange: setProfileRebateLimit,
|
||||
defaultRebate: profileDefaultRebate,
|
||||
onDefaultRebateChange: setProfileDefaultRebate,
|
||||
settlementCycle: profileSettlementCycle,
|
||||
onSettlementCycleChange: setProfileSettlementCycle,
|
||||
extraRebate: profileExtraRebate,
|
||||
onExtraRebateChange: setProfileExtraRebate,
|
||||
canCreatePlayer: profileCanCreatePlayer,
|
||||
onCanCreatePlayerChange: setProfileCanCreatePlayer,
|
||||
canCreateChild: profileCanCreateChild,
|
||||
onCanCreateChildChange: setProfileCanCreateChild,
|
||||
riskTags: profileRiskTags,
|
||||
onRiskTagsChange: setProfileRiskTags,
|
||||
};
|
||||
}, [
|
||||
canCreateChildAgent,
|
||||
canEditSelectedProfile,
|
||||
canShowProfileTab,
|
||||
isSuperAdmin,
|
||||
profileAvailableCredit,
|
||||
profileCanCreateChild,
|
||||
profileCanCreatePlayer,
|
||||
profileCreditLimit,
|
||||
profileDefaultRebate,
|
||||
profileExtraRebate,
|
||||
profileParentCaps,
|
||||
profileRebateLimit,
|
||||
profileRiskTags,
|
||||
profileSettlementCycle,
|
||||
profileShareRate,
|
||||
selectedProfileLoading,
|
||||
]);
|
||||
|
||||
const showAgentSidebar = businessRows.length > 1;
|
||||
|
||||
const openAddAgent = (): void => {
|
||||
const parent = selectedNode ?? rootNode;
|
||||
if (parent !== null) {
|
||||
openCreateChildForNode(parent);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNode = async () => {
|
||||
@@ -499,7 +640,12 @@ export function AgentsConsole(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canManageProfile) {
|
||||
const includeProfileInDialog =
|
||||
canManageProfile &&
|
||||
(nodeDialogMode === "create" ||
|
||||
(editingNodeId !== null && boundAgent?.id !== editingNodeId));
|
||||
|
||||
if (includeProfileInDialog) {
|
||||
if (nodeDialogMode === "edit" && !profileLoaded) {
|
||||
toast.error(
|
||||
t("profile.loadingBlocked", {
|
||||
@@ -537,7 +683,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
: nodePassword.trim() || undefined,
|
||||
status: nodeStatus,
|
||||
});
|
||||
if (canManageProfile) {
|
||||
if (includeProfileInDialog) {
|
||||
await putAgentNodeProfile(editingNodeId, profilePayload());
|
||||
}
|
||||
toast.success(t("updateSuccess", { name: nodeName.trim() }));
|
||||
@@ -545,12 +691,26 @@ export function AgentsConsole(): React.ReactElement {
|
||||
|
||||
setNodeDialogOpen(false);
|
||||
await loadTree(adminSiteId);
|
||||
if (nodeDialogMode === "create" && targetParentId !== null && downlineDialogNode?.id === targetParentId) {
|
||||
const refreshedParent = flattenTree(
|
||||
(await getAgentTree(adminSiteId ?? undefined)).tree,
|
||||
).find((node) => node.id === targetParentId);
|
||||
if (refreshedParent) {
|
||||
setDownlineDialogNode(refreshedParent);
|
||||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
||||
const refreshed = flattenTree((await getAgentTree(adminSiteId ?? undefined)).tree);
|
||||
const created = refreshed.find(
|
||||
(node) => node.parent_id === targetParentId && node.name === nodeName.trim(),
|
||||
);
|
||||
if (created) {
|
||||
setSelectedNodeId(created.id);
|
||||
}
|
||||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||||
setSelectedNodeId(editingNodeId);
|
||||
if (canManageProfile) {
|
||||
void getAgentNodeProfile(editingNodeId)
|
||||
.then((p) => {
|
||||
if (selectedNodeId === editingNodeId || selectedNodeId === null) {
|
||||
setSelectedProfile(p);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 树已刷新,占成区可能短暂不可用 */
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -560,6 +720,21 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const addParent = selectedNode ?? rootNode;
|
||||
const parentProfileForAdd = useMemo(() => {
|
||||
if (addParent === null) {
|
||||
return null;
|
||||
}
|
||||
if (addParent.id === selectedNodeId && selectedProfile !== null) {
|
||||
return selectedProfile;
|
||||
}
|
||||
if (rootNode !== null && addParent.id === rootNode.id) {
|
||||
return rootProfile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
|
||||
|
||||
if (!canViewAgents) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -573,295 +748,71 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex min-h-[32rem] flex-col gap-0">
|
||||
<ConfirmDialog />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("title", { defaultValue: "代理经营" })}
|
||||
</h1>
|
||||
{canProvision ? (
|
||||
<Link
|
||||
href="/admin/agents/provision"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("lineProvision.link", { defaultValue: "开通线路" })}
|
||||
</Link>
|
||||
) : null}
|
||||
{canSwitchSite && siteOptions.length > 0 ? (
|
||||
<Select
|
||||
value={adminSiteId !== null ? String(adminSiteId) : undefined}
|
||||
onValueChange={(value) => setAdminSiteId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder={t("siteLabel", { defaultValue: "站点" })}>
|
||||
{selectedSiteLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
{canViewSiteList ? (
|
||||
<Link
|
||||
href="/admin/agents/sites"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-muted-foreground")}
|
||||
>
|
||||
{t("sitesListLink", { defaultValue: "站点列表" })}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.currentSiteNodes", { defaultValue: "当前站点节点总数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{currentSiteNodeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.currentSiteAgents", { defaultValue: "当前站点经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{currentSiteBusinessAgentCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSuperAdmin
|
||||
? t("summary.globalNodes", { defaultValue: "全部站点节点总数" })
|
||||
: t("summary.visibleList", { defaultValue: "当前最上级代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
{isSuperAdmin ? (globalVisibleNodeCount ?? "—") : filteredRows.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSuperAdmin
|
||||
? t("summary.globalAgents", { defaultValue: "全部站点经营代理数" })
|
||||
: t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
{isSuperAdmin ? (globalBusinessAgentCount ?? "—") : currentSiteBusinessAgentCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminPageCard title={t("listTitle", { defaultValue: "代理列表" })}>
|
||||
{canViewPlayersTab ? (
|
||||
<nav className="mb-4 flex flex-wrap gap-2 border-b border-border/60 pb-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={operationsTab === "subordinates" ? "default" : "outline"}
|
||||
onClick={() => setOperationsTab("subordinates")}
|
||||
>
|
||||
{t("tabs.subordinates", { defaultValue: "下级管理" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={operationsTab === "players" ? "default" : "outline"}
|
||||
onClick={() => setOperationsTab("players")}
|
||||
>
|
||||
{t("tabs.players", { defaultValue: "玩家管理" })}
|
||||
</Button>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
{operationsTab === "subordinates" ? (
|
||||
<div className="relative mb-3 min-w-[16rem] max-w-md">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="pl-8"
|
||||
placeholder={t("listSearch", {
|
||||
defaultValue: "搜索代理名称 / 编码 / 登录名",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{operationsTab === "players" ? (
|
||||
<AgentsPlayersPanel siteCode={activeSiteCode} agentNodeId={playersPanelAgentId} />
|
||||
) : null}
|
||||
|
||||
<div className={cn("admin-table-shell mt-3", operationsTab === "players" ? "hidden" : "")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name", { defaultValue: "名称" })}</TableHead>
|
||||
<TableHead>{t("code", { defaultValue: "编码" })}</TableHead>
|
||||
<TableHead>{t("users.username", { defaultValue: "登录名" })}</TableHead>
|
||||
<TableHead>{t("parentAgent", { defaultValue: "上级代理" })}</TableHead>
|
||||
<TableHead className="w-16">{t("depth", { defaultValue: "层级" })}</TableHead>
|
||||
<TableHead className="w-20">{t("childrenCount", { defaultValue: "直属下级" })}</TableHead>
|
||||
<TableHead className="w-24">{t("status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("common:states.noData", { defaultValue: "暂无数据" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pagedRows.map((node) => (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell className="font-medium">{node.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{node.code}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{node.username ?? "—"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "—") : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{node.depth}</TableCell>
|
||||
<TableCell>{node.children?.length ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)}>
|
||||
{node.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "Enabled" })
|
||||
: t("common:status.disabled", { defaultValue: "Disabled" })}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell>{renderRowActions(node)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{operationsTab === "subordinates" ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="agents-operations-per-page"
|
||||
total={total}
|
||||
page={currentPage}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(value) => {
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm lg:flex-row">
|
||||
{showAgentSidebar ? (
|
||||
<AgentLineSidebar
|
||||
siteLabel={selectedSiteLabel}
|
||||
tree={tree}
|
||||
parentNameMap={parentNameMap}
|
||||
selectedId={selectedNodeId}
|
||||
keyword={keyword}
|
||||
agentCount={businessRows.length}
|
||||
onKeywordChange={(value) => {
|
||||
setKeyword(value);
|
||||
}}
|
||||
onSelect={(node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</AdminPageCard>
|
||||
|
||||
<Dialog
|
||||
open={downlineDialogNode !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDownlineDialogNode(null);
|
||||
<AgentLineDetailPanel
|
||||
node={selectedNode}
|
||||
profile={selectedProfile}
|
||||
profileLoading={selectedProfileLoading}
|
||||
childAgents={selectedChildAgents}
|
||||
childCountById={childCountById}
|
||||
siteCode={activeSiteCode}
|
||||
siteLabel={selectedSiteLabel}
|
||||
parentName={
|
||||
selectedNode?.parent_id !== null && selectedNode?.parent_id !== undefined
|
||||
? (parentNameMap.get(selectedNode.parent_id) ?? null)
|
||||
: null
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[min(90vh,52rem)] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{downlineDialogNode
|
||||
? t("downlineDialogTitle", {
|
||||
name: downlineDialogNode.name,
|
||||
defaultValue: "{{name}} — 下级代理与玩家",
|
||||
})
|
||||
: t("viewDownline", { defaultValue: "查看下级代理和玩家" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto pr-1">
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("downlineAgentsSection", { defaultValue: "下级代理" })}
|
||||
</h3>
|
||||
{downlineDialogNode && canCreateChildForNode(downlineDialogNode) ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => openCreateChildForNode(downlineDialogNode)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name", { defaultValue: "名称" })}</TableHead>
|
||||
<TableHead>{t("code", { defaultValue: "编码" })}</TableHead>
|
||||
<TableHead>{t("users.username", { defaultValue: "登录名" })}</TableHead>
|
||||
<TableHead className="w-20">{t("childrenCount", { defaultValue: "直属下级" })}</TableHead>
|
||||
<TableHead className="w-24">{t("status", { defaultValue: "状态" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{downlineChildAgents.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("downlineNoAgents", { defaultValue: "暂无下级代理" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
downlineChildAgents.map((child) => (
|
||||
<TableRow key={child.id}>
|
||||
<TableCell className="font-medium">{child.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{child.code}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{child.username ?? "—"}</TableCell>
|
||||
<TableCell>{child.children?.length ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)}>
|
||||
{child.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "Enabled" })
|
||||
: t("common:status.disabled", { defaultValue: "Disabled" })}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canViewPlayersTab && downlineDialogNode ? (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("downlinePlayersSection", { defaultValue: "直属玩家" })}
|
||||
</h3>
|
||||
<AgentsPlayersPanel
|
||||
siteCode={activeSiteCode}
|
||||
agentNodeId={downlineDialogNode.id}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDownlineDialogNode(null)}>
|
||||
{t("common:actions.close", { defaultValue: "关闭" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
detailTab={detailTab}
|
||||
onDetailTabChange={setDetailTab}
|
||||
canViewProfileTab={canShowProfileTab}
|
||||
canEditProfileTab={canEditSelectedProfile}
|
||||
profileReadOnly={isOwnAgentNode}
|
||||
canViewDownlineTab={canShowDownlineTab}
|
||||
canViewPlayersTab={canShowPlayersTab}
|
||||
canManageNode={canManageNode}
|
||||
canCreateChild={canCreateChildOnSelected}
|
||||
canDeleteChild={canDeleteNode}
|
||||
onEditChild={(node) => openEditForNode(node)}
|
||||
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
||||
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
|
||||
onDeleteChild={(node) => handleDeleteNode(node)}
|
||||
onSelectChild={(child) => {
|
||||
setSelectedNodeId(child.id);
|
||||
}}
|
||||
profileFields={inlineProfileFields}
|
||||
profileSaving={profileSaving}
|
||||
onSaveProfile={() => void saveInlineProfile()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogContent
|
||||
showCloseButton
|
||||
className="flex h-[min(90vh,760px)] !max-w-[min(520px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:!max-w-[min(520px,calc(100vw-2rem))]"
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-4 pr-12">
|
||||
<DialogTitle>
|
||||
{nodeDialogMode === "create"
|
||||
? t("createChild", { defaultValue: "添加下级代理" })
|
||||
@@ -869,6 +820,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain px-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name">{t("name", { defaultValue: "名称" })}</Label>
|
||||
<Input
|
||||
@@ -923,130 +875,44 @@ export function AgentsConsole(): React.ReactElement {
|
||||
<Label>{t("status", { defaultValue: "状态" })}</Label>
|
||||
</div>
|
||||
|
||||
{canManageProfile ? (
|
||||
{canManageProfile &&
|
||||
(nodeDialogMode === "create" ||
|
||||
(editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("profile.section", { defaultValue: "占成与授信" })}
|
||||
</p>
|
||||
{profileLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
|
||||
</p>
|
||||
) : null}
|
||||
<div className={cn("grid gap-3 sm:grid-cols-2", profileLoading ? "pointer-events-none opacity-50" : "")}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-share-rate">
|
||||
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-share-rate"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={profileShareRate}
|
||||
onChange={(e) => setProfileShareRate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-credit-limit">
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-credit-limit"
|
||||
type="number"
|
||||
min={0}
|
||||
value={profileCreditLimit}
|
||||
onChange={(e) => setProfileCreditLimit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-rebate-limit">
|
||||
{t("profile.rebateLimit", { defaultValue: "回水上限" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-rebate-limit"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
value={profileRebateLimit}
|
||||
onChange={(e) => setProfileRebateLimit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-default-rebate">
|
||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-default-rebate"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
value={profileDefaultRebate}
|
||||
onChange={(e) => setProfileDefaultRebate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-settlement-cycle">
|
||||
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={profileSettlementCycle}
|
||||
onValueChange={(value) =>
|
||||
setProfileSettlementCycle((value as "daily" | "weekly" | "monthly") ?? "weekly")
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="agent-settlement-cycle">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">
|
||||
{t("profile.cycleDaily", { defaultValue: "日结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="weekly">
|
||||
{t("profile.cycleWeekly", { defaultValue: "周结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly">
|
||||
{t("profile.cycleMonthly", { defaultValue: "月结" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={profileExtraRebate}
|
||||
onCheckedChange={setProfileExtraRebate}
|
||||
/>
|
||||
<Label>
|
||||
{t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={profileCanCreatePlayer}
|
||||
onCheckedChange={setProfileCanCreatePlayer}
|
||||
/>
|
||||
<Label>
|
||||
{t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={profileCanCreateChild}
|
||||
onCheckedChange={setProfileCanCreateChild}
|
||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||
/>
|
||||
<Label>
|
||||
{t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
</Label>
|
||||
</div>
|
||||
<AgentProfileFields
|
||||
loading={profileLoading}
|
||||
parentCaps={profileParentCaps}
|
||||
availableCredit={profileAvailableCredit}
|
||||
canCreateChildAgent={canCreateChildAgent}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
shareRate={profileShareRate}
|
||||
onShareRateChange={setProfileShareRate}
|
||||
creditLimit={profileCreditLimit}
|
||||
onCreditLimitChange={setProfileCreditLimit}
|
||||
rebateLimit={profileRebateLimit}
|
||||
onRebateLimitChange={setProfileRebateLimit}
|
||||
defaultRebate={profileDefaultRebate}
|
||||
onDefaultRebateChange={setProfileDefaultRebate}
|
||||
settlementCycle={profileSettlementCycle}
|
||||
onSettlementCycleChange={setProfileSettlementCycle}
|
||||
extraRebate={profileExtraRebate}
|
||||
onExtraRebateChange={setProfileExtraRebate}
|
||||
canCreatePlayer={profileCanCreatePlayer}
|
||||
onCanCreatePlayerChange={setProfileCanCreatePlayer}
|
||||
canCreateChild={profileCanCreateChild}
|
||||
onCanCreateChildChange={setProfileCanCreateChild}
|
||||
riskTags={profileRiskTags}
|
||||
onRiskTagsChange={setProfileRiskTags}
|
||||
idPrefix="dialog-agent-profile"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="!m-0 shrink-0 rounded-b-xl border-t bg-background px-4 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { Eye, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAdminPlayers, postAdminPlayer } from "@/api/admin-player";
|
||||
import { getAgentNodeProfile } from "@/api/admin-agents";
|
||||
import {
|
||||
deleteAdminPlayer,
|
||||
getAdminPlayer,
|
||||
getAdminPlayers,
|
||||
postAdminPlayer,
|
||||
putAdminPlayer,
|
||||
} from "@/api/admin-player";
|
||||
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
@@ -19,6 +29,13 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,31 +45,119 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||
|
||||
const PLAYER_STATUS_OPTIONS = [
|
||||
{ value: 0, labelKey: "players:statusNormal" as const },
|
||||
{ value: 1, labelKey: "players:statusFrozen" as const },
|
||||
{ value: 2, labelKey: "players:statusBanned" as const },
|
||||
];
|
||||
|
||||
function playerStatusLabel(
|
||||
status: number,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
const hit = PLAYER_STATUS_OPTIONS.find((opt) => opt.value === status);
|
||||
if (hit) {
|
||||
return t(hit.labelKey, {
|
||||
defaultValue: status === 0 ? "正常" : status === 1 ? "冻结" : "封禁",
|
||||
});
|
||||
}
|
||||
|
||||
return String(status);
|
||||
}
|
||||
|
||||
function resolvePlayerRebateRate(row: AdminPlayerRow): number | null {
|
||||
if (row.rebate_rate != null) {
|
||||
return row.rebate_rate;
|
||||
}
|
||||
|
||||
const defaultProfile = row.rebate_profiles?.find((p) => p.game_type === "*");
|
||||
if (defaultProfile && !defaultProfile.inherit_from_agent) {
|
||||
return defaultProfile.rebate_rate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRiskTagsInput(text: string): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
text
|
||||
.split(/[,,\s]+/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function fillEditFormFromPlayer(row: AdminPlayerRow): {
|
||||
username: string;
|
||||
nickname: string;
|
||||
currency: string;
|
||||
status: number;
|
||||
creditLimit: string;
|
||||
rebateRate: string;
|
||||
riskTags: string;
|
||||
} {
|
||||
const rebate = resolvePlayerRebateRate(row);
|
||||
|
||||
return {
|
||||
username: row.username ?? "",
|
||||
nickname: row.nickname ?? "",
|
||||
currency: row.default_currency ?? "",
|
||||
status: row.status,
|
||||
creditLimit: row.credit_limit != null ? String(row.credit_limit) : "",
|
||||
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
|
||||
riskTags: (row.risk_tags ?? []).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
type AgentsPlayersPanelProps = {
|
||||
siteCode: string;
|
||||
/** 筛选直属玩家时的代理节点;null 表示当前登录代理或不过滤 */
|
||||
agentNodeId: number | null;
|
||||
/** 当前代理 profile 是否允许创建玩家;未传时沿用登录代理能力 */
|
||||
allowCreatePlayer?: boolean;
|
||||
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function AgentsPlayersPanel({
|
||||
siteCode,
|
||||
agentNodeId,
|
||||
allowCreatePlayer,
|
||||
embedded = false,
|
||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "players", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
|
||||
const profileAllowsCreate =
|
||||
allowCreatePlayer === undefined
|
||||
? boundAgent?.can_create_player !== false
|
||||
: allowCreatePlayer === true;
|
||||
|
||||
const canCreatePlayer =
|
||||
isSuperAdmin ||
|
||||
(boundAgent?.can_create_player !== false &&
|
||||
(profileAllowsCreate &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
||||
const canManagePlayerRows = canCreatePlayer;
|
||||
|
||||
const effectiveAgentId = useMemo(() => {
|
||||
if (agentNodeId !== null) {
|
||||
@@ -72,7 +177,22 @@ export function AgentsPlayersPanel({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sitePlayerId, setSitePlayerId] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [creditLimit, setCreditLimit] = useState("");
|
||||
const [rebateRate, setRebateRate] = useState("");
|
||||
const [parentAvailableCredit, setParentAvailableCredit] = useState<number | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
const [editingPlayer, setEditingPlayer] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"][number] | null>(null);
|
||||
const [editUsername, setEditUsername] = useState("");
|
||||
const [editNickname, setEditNickname] = useState("");
|
||||
const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
|
||||
const [editStatus, setEditStatus] = useState(0);
|
||||
const [editCreditLimit, setEditCreditLimit] = useState("");
|
||||
const [editRebateRate, setEditRebateRate] = useState("");
|
||||
const [editRiskTags, setEditRiskTags] = useState("");
|
||||
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteCode.trim() === "") {
|
||||
@@ -108,8 +228,12 @@ export function AgentsPlayersPanel({
|
||||
}, [load]);
|
||||
|
||||
async function savePlayer(): Promise<void> {
|
||||
if (siteCode.trim() === "" || sitePlayerId.trim() === "") {
|
||||
toast.error(t("players:sitePlayerIdRequired", { defaultValue: "请填写站点玩家 ID" }));
|
||||
if (siteCode.trim() === "") {
|
||||
toast.error(t("players:siteCodeRequired", { defaultValue: "请填写主站编号" }));
|
||||
return;
|
||||
}
|
||||
if (username.trim() === "" || password.trim() === "") {
|
||||
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,16 +241,30 @@ export function AgentsPlayersPanel({
|
||||
try {
|
||||
await postAdminPlayer({
|
||||
site_code: siteCode.trim(),
|
||||
site_player_id: sitePlayerId.trim(),
|
||||
username: username.trim() || null,
|
||||
...(sitePlayerId.trim() !== "" ? { site_player_id: sitePlayerId.trim() } : {}),
|
||||
username: username.trim(),
|
||||
password: password,
|
||||
nickname: nickname.trim() || null,
|
||||
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
|
||||
credit_limit:
|
||||
creditLimit.trim() === "" ? 0 : Math.max(0, Number.parseInt(creditLimit, 10) || 0),
|
||||
...(rebateRate.trim() !== ""
|
||||
? { rebate_rate: percentUiToRatio(rebateRate) }
|
||||
: {}),
|
||||
});
|
||||
toast.success(t("players:createSuccess", { name: sitePlayerId.trim() }));
|
||||
toast.success(
|
||||
t("playersPanel.createSuccessNative", {
|
||||
name: username.trim(),
|
||||
defaultValue: "玩家 {{name}} 已创建,请使用彩票端登录",
|
||||
}),
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setSitePlayerId("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setNickname("");
|
||||
setCreditLimit("");
|
||||
setRebateRate("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("players:createFailed"));
|
||||
@@ -135,56 +273,285 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog(): void {
|
||||
setDialogOpen(true);
|
||||
if (effectiveAgentId !== null) {
|
||||
void getAgentNodeProfile(effectiveAgentId)
|
||||
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
|
||||
.catch(() => setParentAvailableCredit(null));
|
||||
} else {
|
||||
setParentAvailableCredit(null);
|
||||
}
|
||||
}
|
||||
|
||||
const applyEditForm = (row: AdminPlayerRow): void => {
|
||||
const form = fillEditFormFromPlayer(row);
|
||||
setEditUsername(form.username);
|
||||
setEditNickname(form.nickname);
|
||||
setEditDefaultCurrency(form.currency);
|
||||
setEditStatus(form.status);
|
||||
setEditCreditLimit(form.creditLimit);
|
||||
setEditRebateRate(form.rebateRate);
|
||||
setEditRiskTags(form.riskTags);
|
||||
};
|
||||
|
||||
const openEditPlayer = (row: AdminPlayerRow): void => {
|
||||
setEditingPlayer(row);
|
||||
applyEditForm(row);
|
||||
setEditDialogOpen(true);
|
||||
setEditDetailLoading(true);
|
||||
void getAdminPlayer(row.id)
|
||||
.then((full) => {
|
||||
setEditingPlayer(full);
|
||||
applyEditForm(full);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("players:loadFailed", { defaultValue: "加载玩家详情失败" }));
|
||||
})
|
||||
.finally(() => {
|
||||
setEditDetailLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
function handleEditDialogOpenChange(open: boolean): void {
|
||||
setEditDialogOpen(open);
|
||||
if (!open) {
|
||||
setEditingPlayer(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEditedPlayer(): Promise<void> {
|
||||
if (!editingPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Parameters<typeof putAdminPlayer>[1] = {};
|
||||
if (editUsername.trim() !== "" && editUsername.trim() !== (editingPlayer.username ?? "")) {
|
||||
body.username = editUsername.trim();
|
||||
}
|
||||
if (editNickname.trim() !== (editingPlayer.nickname ?? "")) {
|
||||
body.nickname = editNickname.trim() || null;
|
||||
}
|
||||
const nextCurrency = editDefaultCurrency.trim().toUpperCase();
|
||||
if (nextCurrency !== editingPlayer.default_currency) {
|
||||
body.default_currency = nextCurrency;
|
||||
}
|
||||
if (editStatus !== editingPlayer.status) {
|
||||
body.status = editStatus;
|
||||
}
|
||||
const nextCredit =
|
||||
editCreditLimit.trim() === "" ? 0 : Number.parseInt(editCreditLimit, 10);
|
||||
if (!Number.isNaN(nextCredit) && nextCredit !== (editingPlayer.credit_limit ?? 0)) {
|
||||
body.credit_limit = Math.max(0, nextCredit);
|
||||
}
|
||||
const prevRebate = resolvePlayerRebateRate(editingPlayer);
|
||||
const nextPercent = parsePercentUi(editRebateRate);
|
||||
const nextRebate = nextPercent === null ? null : percentUiToRatio(nextPercent);
|
||||
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
|
||||
body.rebate_rate = nextRebate;
|
||||
}
|
||||
|
||||
const nextRiskTags = parseRiskTagsInput(editRiskTags);
|
||||
const prevRiskTags = editingPlayer.risk_tags ?? [];
|
||||
if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) {
|
||||
body.risk_tags = nextRiskTags;
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
toast.success(t("players:noChanges", { defaultValue: "没有变更" }));
|
||||
handleEditDialogOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditSaving(true);
|
||||
try {
|
||||
const updated = await putAdminPlayer(editingPlayer.id, body);
|
||||
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
|
||||
toast.success(
|
||||
t("players:updateSuccess", {
|
||||
name: updated.username ?? updated.site_player_id,
|
||||
defaultValue: "已更新 {{name}}",
|
||||
}),
|
||||
);
|
||||
handleEditDialogOpenChange(false);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : t("players:updateFailed", { defaultValue: "更新玩家失败" }),
|
||||
);
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeletePlayer(row: Awaited<ReturnType<typeof getAdminPlayers>>["items"][number]): Promise<void> {
|
||||
try {
|
||||
await deleteAdminPlayer(row.id);
|
||||
setItems((prev) => prev.filter((item) => item.id !== row.id));
|
||||
setTotal((current) => Math.max(0, current - 1));
|
||||
toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id }));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("deleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{canCreatePlayer ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
<div className="space-y-4">
|
||||
<ConfirmDialog />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{!embedded ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("playersPanel.creditListHint", {
|
||||
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("playersPanel.creditListHint", {
|
||||
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{canCreatePlayer ? (
|
||||
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("playersPanel.create", { defaultValue: "创建玩家" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<AdminLoadingState minHeight="6rem" />
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}</TableHead>
|
||||
<TableHead>{t("players:username", { defaultValue: "用户名" })}</TableHead>
|
||||
<TableHead>{t("players:nickname", { defaultValue: "昵称" })}</TableHead>
|
||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableHead className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
|
||||
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("players:fundingMode", { defaultValue: "资金模式" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("players:rebateRate", { defaultValue: "回水" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
|
||||
{!embedded ? (
|
||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||
) : null}
|
||||
<TableHead className="sticky right-0 z-10 w-14 bg-muted/40 text-center shadow-[-1px_0_0_var(--border)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
{t("common:states.noData", { defaultValue: "暂无数据" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={embedded ? 8 : 9} cellClassName="py-12 text-center" />
|
||||
) : (
|
||||
items.map((row) => (
|
||||
items.map((row) => {
|
||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||
const rebate = resolvePlayerRebateRate(row);
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.site_player_id}</TableCell>
|
||||
<TableCell>{row.username ?? "—"}</TableCell>
|
||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||
<TableCell className="tabular-nums text-xs font-medium">#{row.id}</TableCell>
|
||||
<TableCell className="max-w-[8rem] truncate font-mono text-xs" title={row.site_player_id}>
|
||||
{row.site_player_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<span className="font-medium">{row.username ?? "—"}</span>
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlayerFundingModeBadge row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
<span>{balances.balance}</span>
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
<span className="text-muted-foreground">{balances.available}</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right text-xs tabular-nums font-medium"
|
||||
title={
|
||||
row.rebate_inherited
|
||||
? t("playersPanel.rebateInherited", { defaultValue: "继承代理默认回水" })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{rebate != null ? `${ratioToPercentUi(rebate)}%` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||
</TableCell>
|
||||
{!embedded ? (
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(row.status)}>
|
||||
{row.status === 0
|
||||
? t("players:statusNormal", { defaultValue: "正常" })
|
||||
: String(row.status)}
|
||||
{playerStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
busy={confirmBusy}
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("players:viewDetail", { defaultValue: "查看详情" }),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.id),
|
||||
},
|
||||
...(canManagePlayerRows
|
||||
? [
|
||||
{
|
||||
key: "edit",
|
||||
label: t("players:edit", { defaultValue: "编辑" }),
|
||||
icon: Pencil,
|
||||
onClick: () => openEditPlayer(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("players:delete", { defaultValue: "删除" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () =>
|
||||
requestConfirm({
|
||||
title: t("players:confirmDelete", {
|
||||
defaultValue: "确认删除",
|
||||
}),
|
||||
description: t("players:confirmDeleteDesc", {
|
||||
name: row.username ?? row.site_player_id,
|
||||
defaultValue:
|
||||
"确定要删除玩家 {{name}} 吗?此操作不可恢复。",
|
||||
}),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: () => void confirmDeletePlayer(row),
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="agents-players-per-page"
|
||||
@@ -208,28 +575,42 @@ export function AgentsPlayersPanel({
|
||||
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("players:siteCode", { defaultValue: "站点" })}</Label>
|
||||
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
|
||||
<Input value={siteCode} readOnly disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-site-id">
|
||||
{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}
|
||||
{t("playersPanel.externalIdOptional", { defaultValue: "外部 ID(可选)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-site-id"
|
||||
value={sitePlayerId}
|
||||
onChange={(e) => setSitePlayerId(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={t("playersPanel.externalIdHint", { defaultValue: "留空则系统自动生成" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-username">
|
||||
{t("players:username", { defaultValue: "用户名" })}
|
||||
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-password">
|
||||
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -242,6 +623,41 @@ export function AgentsPlayersPanel({
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-credit">
|
||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-credit"
|
||||
type="number"
|
||||
min={0}
|
||||
value={creditLimit}
|
||||
onChange={(e) => setCreditLimit(e.target.value)}
|
||||
/>
|
||||
{parentAvailableCredit !== null ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("playersPanel.availableToGrant", {
|
||||
defaultValue: "代理剩余可下发:{{amount}}",
|
||||
amount: formatCredit(parentAvailableCredit),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-rebate">
|
||||
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-rebate"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={rebateRate}
|
||||
placeholder="0.5"
|
||||
onChange={(e) => setRebateRate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
@@ -252,6 +668,126 @@ export function AgentsPlayersPanel({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("players:editDialogTitle", { defaultValue: "编辑玩家" })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editDetailLoading ? <AdminLoadingState minHeight="6rem" /> : null}
|
||||
<div className={editDetailLoading ? "hidden" : "space-y-3"}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-edit-username">
|
||||
{t("players:username", { defaultValue: "用户名" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-edit-username"
|
||||
value={editUsername}
|
||||
onChange={(e) => setEditUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-edit-nickname">
|
||||
{t("players:nickname", { defaultValue: "昵称" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-edit-nickname"
|
||||
value={editNickname}
|
||||
onChange={(e) => setEditNickname(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-edit-currency">
|
||||
{t("players:defaultCurrency", { defaultValue: "默认币种" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-edit-currency"
|
||||
value={editDefaultCurrency}
|
||||
onChange={(e) => setEditDefaultCurrency(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-edit-credit">
|
||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-edit-credit"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editCreditLimit}
|
||||
onChange={(e) => setEditCreditLimit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-edit-rebate">
|
||||
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-edit-rebate"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={editRebateRate}
|
||||
onChange={(e) => setEditRebateRate(e.target.value)}
|
||||
placeholder="0.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 0.5 表示 0.5%" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-edit-risk-tags">
|
||||
{t("playersPanel.riskTags", { defaultValue: "风控标签" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-edit-risk-tags"
|
||||
value={editRiskTags}
|
||||
onChange={(e) => setEditRiskTags(e.target.value)}
|
||||
placeholder={t("playersPanel.riskTagsPlaceholder", {
|
||||
defaultValue: "逗号分隔",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-edit-status">
|
||||
{t("players:status", { defaultValue: "状态" })}
|
||||
</Label>
|
||||
<Select value={String(editStatus)} onValueChange={(value) => setEditStatus(Number(value))}>
|
||||
<SelectTrigger id="agent-player-edit-status">
|
||||
<SelectValue placeholder={t("players:status", { defaultValue: "状态" })}>
|
||||
{playerStatusLabel(editStatus, t)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={String(opt.value)}>
|
||||
{t(opt.labelKey, {
|
||||
defaultValue: opt.value === 0 ? "正常" : opt.value === 1 ? "冻结" : "封禁",
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={editSaving || editDetailLoading}
|
||||
onClick={() => void saveEditedPlayer()}
|
||||
>
|
||||
{t("players:saveChanges", { defaultValue: "保存修改" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,60 +2,43 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
PRD_AGENT_SITES_ACCESS_ANY,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const tabs: {
|
||||
const primaryTabs: {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
matchPrefix: string;
|
||||
requiredAny: readonly string[];
|
||||
}[] = [
|
||||
{
|
||||
href: "/admin/agents",
|
||||
labelKey: "subnav.operations",
|
||||
matchPrefix: "/admin/agents",
|
||||
requiredAny: PRD_AGENTS_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
href: "/admin/agents/provision",
|
||||
labelKey: "subnav.provision",
|
||||
matchPrefix: "/admin/agents/provision",
|
||||
requiredAny: PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
href: "/admin/agents/sites",
|
||||
labelKey: "subnav.sites",
|
||||
matchPrefix: "/admin/agents/sites",
|
||||
requiredAny: PRD_AGENT_SITES_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
href: "/admin/agents/settlement-bills",
|
||||
labelKey: "subnav.settlementBills",
|
||||
matchPrefix: "/admin/agents/settlement",
|
||||
requiredAny: PRD_SETTLEMENT_AGENT_ACCESS_ANY,
|
||||
},
|
||||
];
|
||||
|
||||
const provisionTab = {
|
||||
href: "/admin/agents/provision",
|
||||
labelKey: "subnav.provision",
|
||||
matchPrefix: "/admin/agents/provision",
|
||||
} as const;
|
||||
|
||||
function isTabActive(pathname: string, href: string, matchPrefix: string): boolean {
|
||||
if (href === "/admin/agents") {
|
||||
return (
|
||||
pathname === "/admin/agents" ||
|
||||
pathname === "/admin/agents/list" ||
|
||||
(pathname.startsWith("/admin/agents/") &&
|
||||
!pathname.startsWith("/admin/agents/provision") &&
|
||||
!pathname.startsWith("/admin/agents/sites") &&
|
||||
!pathname.startsWith("/admin/agents/settlement"))
|
||||
!pathname.startsWith("/admin/agents/provision"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,45 +49,105 @@ export function AgentsSubnav(): React.ReactElement {
|
||||
const { t } = useTranslation("agents");
|
||||
const pathname = usePathname();
|
||||
const profile = useAdminProfile();
|
||||
const perms = profile?.permissions;
|
||||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
||||
|
||||
const visibleTabs = useMemo(
|
||||
() =>
|
||||
tabs.filter(
|
||||
(tab) =>
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(perms, [...tab.requiredAny]),
|
||||
),
|
||||
[perms, profile?.is_super_admin],
|
||||
const canSwitchSite =
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
|
||||
|
||||
const showProvision = isAgentLineSubnavTabVisible(provisionTab.href, profile);
|
||||
|
||||
const visiblePrimaryTabs = useMemo(
|
||||
() => primaryTabs.filter((tab) => isAgentLineSubnavTabVisible(tab.href, profile)),
|
||||
[profile],
|
||||
);
|
||||
|
||||
if (visibleTabs.length === 0) {
|
||||
useEffect(() => {
|
||||
if (adminSiteId !== null || siteOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const boundSiteId = profile?.agent?.admin_site_id;
|
||||
if (boundSiteId != null) {
|
||||
setAdminSiteId(boundSiteId);
|
||||
return;
|
||||
}
|
||||
setAdminSiteId(siteOptions[0]?.id ?? null);
|
||||
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
||||
|
||||
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||
const selectedSiteLabel = useMemo(() => {
|
||||
const site = siteOptions.find((item) => item.id === selectSiteId);
|
||||
return site ? `${site.name} (${site.code})` : null;
|
||||
}, [selectSiteId, siteOptions]);
|
||||
|
||||
if (visiblePrimaryTabs.length === 0 && !showProvision) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("subnav.label", { defaultValue: "代理线路导航" })}
|
||||
className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1"
|
||||
>
|
||||
{visibleTabs.map((tab) => {
|
||||
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
|
||||
<nav
|
||||
aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1"
|
||||
>
|
||||
{visiblePrimaryTabs.map((tab) => {
|
||||
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
|
||||
|
||||
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(tab.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{showProvision ? (
|
||||
<>
|
||||
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
|
||||
<Link
|
||||
href={provisionTab.href}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(provisionTab.labelKey)}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
{canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||
<Select
|
||||
value={String(selectSiteId)}
|
||||
onValueChange={(value) => setAdminSiteId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[200px] bg-background">
|
||||
<SelectValue placeholder={t("lineFilter", { defaultValue: "一级代理" })}>
|
||||
{selectedSiteLabel ?? t("lineFilter", { defaultValue: "一级代理" })}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.name} ({site.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -220,11 +221,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={6} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={6} />
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
|
||||
@@ -43,7 +43,7 @@ const HUB_CARDS: HubCard[] = [
|
||||
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
|
||||
},
|
||||
{
|
||||
href: "/admin/agents/sites",
|
||||
href: "/admin/config/integration-sites",
|
||||
titleKey: "hub.integrationTitle",
|
||||
descKey: "hub.integrationDesc",
|
||||
requiredAny: PRD_INTEGRATION_ACCESS_ANY,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Check, ChevronRight, Layers, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -192,9 +193,7 @@ export function ConfigVersionSwitcher({
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||
{sortedVersions.length === 0 ? (
|
||||
<p className="px-2 py-8 text-center text-sm text-muted-foreground">
|
||||
{t("versionSwitcher.empty", { ns: "config" })}
|
||||
</p>
|
||||
<AdminNoResourceState compact className="px-2 py-8" />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{visibleSections.map((section) => (
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
@@ -299,11 +300,7 @@ export function OddsConfigDocScreen({
|
||||
if (!first) {
|
||||
return "0";
|
||||
}
|
||||
const n = Number.parseFloat(String(first.rebate_rate));
|
||||
if (!Number.isFinite(n)) {
|
||||
return "0";
|
||||
}
|
||||
return String(Math.round(n * 10000) / 100);
|
||||
return ratioToPercentUi(String(first.rebate_rate));
|
||||
}, [scopeRows]);
|
||||
|
||||
function rowIndex(play_code: string, prize_scope: string): number {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
||||
import type { AdminPlayTypeRow, OddsItemRow } from "@/types/api/admin-config";
|
||||
|
||||
export function rateToPercentUi(rateStr: string): string {
|
||||
const n = Number.parseFloat(rateStr);
|
||||
if (!Number.isFinite(n)) {
|
||||
return "0.00";
|
||||
}
|
||||
return (Math.round(n * 10000) / 100).toFixed(2);
|
||||
}
|
||||
/** 赔率/回水配置页展示用:小数比例 → 百分比 UI 字符串 */
|
||||
export const rateToPercentUi = ratioToPercentUi;
|
||||
|
||||
export function inferRebatePercentFromDimension(
|
||||
dim: 2 | 3 | 4,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { toast } from "sonner";
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { getAdminRiskPools } from "@/api/admin-risk";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -238,11 +239,7 @@ export function RiskCapRuntimePanel() {
|
||||
{poolsLoading ? (
|
||||
<AdminTableLoadingRow colSpan={5} />
|
||||
) : pools.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
|
||||
) : (
|
||||
pools.map((row) => (
|
||||
<TableRow
|
||||
|
||||
303
src/modules/dashboard/agent-dashboard-console.tsx
Normal file
303
src/modules/dashboard/agent-dashboard-console.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BarChart3,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Ticket,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_HUB_ACCESS_ANY,
|
||||
PRD_PLAYERS_ACCESS_ANY,
|
||||
PRD_REPORTS_VIEW_ACCESS_ANY,
|
||||
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
|
||||
PRD_TICKETS_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import {
|
||||
formatDashboardCreditMajor,
|
||||
formatDashboardMoneyMinor,
|
||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AgentDashboardConsole(): ReactElement {
|
||||
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const agent = profile?.agent ?? null;
|
||||
const permissions = profile?.permissions ?? [];
|
||||
|
||||
const todayLabel = useMemo(() => {
|
||||
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
|
||||
|
||||
return formatAdminCalendarToday(locale, weekday);
|
||||
}, [i18n.language, i18n.resolvedLanguage, t]);
|
||||
|
||||
useAdminCurrencyCatalog();
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
|
||||
const [canFinance, setCanFinance] = useState(false);
|
||||
|
||||
const analyticsScope = useMemo(
|
||||
() => ({
|
||||
siteCode: agent?.site_code ?? "",
|
||||
agentNodeId: agent?.id,
|
||||
}),
|
||||
[agent?.id, agent?.site_code],
|
||||
);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const d = await getAdminDashboard();
|
||||
setHall(d.hall);
|
||||
setOverview(d.agent_overview);
|
||||
setCanFinance(d.capabilities.draw_finance_risk);
|
||||
if (d.resolved_draw != null) {
|
||||
setDrawId(d.resolved_draw.id);
|
||||
} else {
|
||||
setDrawId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const currency = "NPR";
|
||||
|
||||
const quickLinks = useMemo(() => {
|
||||
const links: { href: string; label: string; icon: ReactElement }[] = [];
|
||||
if (adminHasAnyPermission(permissions, [...PRD_TICKETS_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/tickets",
|
||||
label: t("agent.quickLinks.tickets"),
|
||||
icon: <Ticket className="size-4" />,
|
||||
});
|
||||
}
|
||||
if (adminHasAnyPermission(permissions, [...PRD_PLAYERS_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/players",
|
||||
label: t("agent.quickLinks.players"),
|
||||
icon: <Users className="size-4" />,
|
||||
});
|
||||
}
|
||||
if (adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/reports",
|
||||
label: t("agent.quickLinks.reports"),
|
||||
icon: <BarChart3 className="size-4" />,
|
||||
});
|
||||
}
|
||||
if (adminHasAnyPermission(permissions, [...PRD_AGENT_HUB_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/agents",
|
||||
label: t("agent.quickLinks.agents"),
|
||||
icon: <Network className="size-4" />,
|
||||
});
|
||||
}
|
||||
if (adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY])) {
|
||||
links.push({
|
||||
href: "/admin/settlement-center",
|
||||
label: t("agent.quickLinks.bills"),
|
||||
icon: <Wallet className="size-4" />,
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [permissions, t]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="admin-list-title">{t("agent.title")}</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{agent
|
||||
? t("agent.subtitle", { name: agent.name || agent.code })
|
||||
: todayLabel}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={loading || refreshing}
|
||||
onClick={() => void load(true)}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||
<AlertTitle>{t("notice")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : overview ? (
|
||||
<section className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{formatDashboardCreditMajor(overview.credit_limit, currency)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agent.creditAvailable", {
|
||||
amount: formatDashboardCreditMajor(overview.available_credit, currency),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agent.creditAllocated", {
|
||||
amount: formatDashboardCreditMajor(overview.allocated_credit, currency),
|
||||
})}
|
||||
{" · "}
|
||||
{t("agent.creditUsed", {
|
||||
amount: formatDashboardCreditMajor(overview.used_credit, currency),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agent.shareRate", { rate: overview.total_share_rate })}
|
||||
{" · "}
|
||||
{t("agent.settlementCycle", { cycle: overview.settlement_cycle })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("agent.directChildren")}</p>
|
||||
<p className="text-xl font-semibold tabular-nums">{overview.direct_child_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("agent.directPlayers")}</p>
|
||||
<p className="text-xl font-semibold tabular-nums">{overview.direct_player_count}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs text-muted-foreground">{t("agent.subtreeAgents")}</p>
|
||||
<p className="text-lg font-semibold tabular-nums">{overview.subtree_agent_count}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="sm:col-span-2 xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("agent.pendingBills")}</CardTitle>
|
||||
{adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? (
|
||||
<Link
|
||||
href="/admin/settlement-center"
|
||||
className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0 text-xs")}
|
||||
>
|
||||
{t("agent.viewBills")}
|
||||
</Link>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-semibold tabular-nums">{overview.pending_bill_count}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("agent.pendingUnpaid", {
|
||||
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, currency),
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<DashboardCurrentDrawCard
|
||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
||||
hall={hall}
|
||||
drawId={drawId}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{canFinance ? (
|
||||
<DashboardAnalyticsPanel
|
||||
enabled={canFinance}
|
||||
playOptions={playOptions}
|
||||
scope={analyticsScope}
|
||||
/>
|
||||
) : (
|
||||
<Alert className="border-muted">
|
||||
<AlertTitle>{t("notice")}</AlertTitle>
|
||||
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{quickLinks.length > 0 ? (
|
||||
<section className="flex flex-wrap gap-2">
|
||||
{quickLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 gap-1.5")}
|
||||
>
|
||||
{link.icon}
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
|
||||
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -264,9 +265,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
|
||||
currency={currency}
|
||||
/>
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</p>
|
||||
<AdminNoResourceState className="py-10 text-center text-sm text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,9 +347,7 @@ export function DashboardPlayRankingCard({
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("analytics.noPlayData")}
|
||||
</p>
|
||||
<AdminNoResourceState className="py-10" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -456,7 +453,7 @@ export function DashboardAgentRankingCard({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noAgentData")}</p>
|
||||
<AdminNoResourceState className="py-10" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2,23 +2,14 @@
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
|
||||
export function DashboardChartEmpty({
|
||||
message,
|
||||
compact = false,
|
||||
}: {
|
||||
message: string;
|
||||
message?: string;
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"text-center text-muted-foreground",
|
||||
compact ? "py-1 text-[11px] leading-snug" : "py-10 text-sm",
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
return <AdminNoResourceState message={message} compact={compact} />;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -396,7 +397,7 @@ export function DashboardConsole(): ReactElement {
|
||||
orders: lifetimeFinance.order_count,
|
||||
tickets: lifetimeFinance.ticket_item_count,
|
||||
})
|
||||
: t("states.noData", { ns: "common" })
|
||||
: t("states.noResource", { ns: "common" })
|
||||
}
|
||||
actionLabel={t("actions.viewAll", { ns: "common" })}
|
||||
icon={<Wallet className="size-5" aria-hidden />}
|
||||
@@ -446,9 +447,7 @@ export function DashboardConsole(): ReactElement {
|
||||
) : finance ? (
|
||||
<SettlementStatusChart finance={finance} />
|
||||
) : (
|
||||
<p className="py-10 text-center text-xs text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</p>
|
||||
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -505,9 +504,7 @@ export function DashboardConsole(): ReactElement {
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-10 text-center text-xs text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</p>
|
||||
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -550,9 +547,7 @@ export function DashboardConsole(): ReactElement {
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-10 text-center text-xs text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</p>
|
||||
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ArrowRight, Clock, Ticket } from "lucide-react";
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
@@ -45,9 +46,8 @@ export function DashboardCurrentDrawCard({
|
||||
return (
|
||||
<Card className="admin-list-card overflow-hidden py-0">
|
||||
<CardContent className="flex min-h-[5.5rem] flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<Ticket className="size-9 text-muted-foreground/40" aria-hidden />
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("sections.currentDraw")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
<AdminNoResourceState compact className="py-4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
20
src/modules/dashboard/dashboard-page-client.tsx
Normal file
20
src/modules/dashboard/dashboard-page-client.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console";
|
||||
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
/** 平台账号走全站仪表盘;绑定代理节点的经营账号走代理仪表盘。 */
|
||||
export function DashboardPageClient(): ReactElement {
|
||||
const profile = useAdminProfile();
|
||||
const isAgentOperator =
|
||||
profile?.agent != null && profile.is_super_admin !== true;
|
||||
|
||||
if (isAgentOperator) {
|
||||
return <AgentDashboardConsole />;
|
||||
}
|
||||
|
||||
return <DashboardConsole />;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export function DailyTrendChart({
|
||||
);
|
||||
|
||||
if (series.length === 0) {
|
||||
return <DashboardChartEmpty message={t("states.noData", { ns: "common" })} />;
|
||||
return <DashboardChartEmpty />;
|
||||
}
|
||||
|
||||
const plotHeight = series.length <= 7 ? 240 : series.length <= 14 ? 260 : 280;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ChartContainer,
|
||||
@@ -516,9 +517,7 @@ export function AbnormalTransferPanelFooter({
|
||||
|
||||
if (total == null) {
|
||||
return (
|
||||
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</p>
|
||||
<AdminNoResourceState className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50" />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import {
|
||||
coerceAdminMinor,
|
||||
formatAdminCreditMajor,
|
||||
formatAdminMinorUnits,
|
||||
getAdminCurrencyDecimalPlaces,
|
||||
} from "@/lib/money";
|
||||
@@ -33,6 +34,23 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
|
||||
|
||||
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
|
||||
|
||||
/** 代理/玩家授信类字段:主货币整数 → 带小数展示(勿用 {@link formatDashboardMoneyMinor})。 */
|
||||
export function formatDashboardCreditMajor(major: number, currencyCode: string | null): string {
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
const safeMajor = Number.isFinite(major) ? major : 0;
|
||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||
try {
|
||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(safeMajor);
|
||||
} catch {
|
||||
return formatAdminCreditMajor(safeMajor, code, decimals);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||
const safeMinor = coerceAdminMinor(minor);
|
||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||
|
||||
@@ -17,12 +17,13 @@ import {
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { canManageDrawResults } from "@/lib/draw-access";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -68,7 +69,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const idNum = Number(drawId);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
||||
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
||||
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_PAYOUT_MANAGE,
|
||||
@@ -213,12 +214,18 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
const batch = data.result_batch_counts;
|
||||
const hasResultActivity = batch.total > 0 || batch.pending_review > 0 || batch.published > 0;
|
||||
const pendingReview = batch.pending_review ?? 0;
|
||||
const totalBatches = batch.total ?? batch.published;
|
||||
const hasResultActivity =
|
||||
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
|
||||
const showActions =
|
||||
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
|
||||
|
||||
@@ -264,21 +271,25 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
<h3 className="text-sm font-medium">{t("resultBatchesTitle")}</h3>
|
||||
{hasResultActivity ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="rounded-md bg-muted px-2.5 py-1">
|
||||
{t("batchSummaryTotal", { count: batch.total })}
|
||||
</span>
|
||||
{batch.pending_review > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="rounded-md bg-amber-500/15 px-2.5 py-1 font-medium text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t("batchSummaryPending", { count: batch.pending_review })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
|
||||
{t("batchSummaryPending", { count: 0 })}
|
||||
{canManageDraw ? (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1">
|
||||
{t("batchSummaryTotal", { count: totalBatches })}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
{canManageDraw ? (
|
||||
pendingReview > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="rounded-md bg-amber-500/15 px-2.5 py-1 font-medium text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t("batchSummaryPending", { count: pendingReview })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
|
||||
{t("batchSummaryPending", { count: 0 })}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
{batch.published > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
@@ -294,13 +305,18 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noResultBatchesYet")}{" "}
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("goToReviewTab")}
|
||||
</Link>
|
||||
{t("noResultBatchesYet")}
|
||||
{canManageDraw ? (
|
||||
<>
|
||||
{" "}
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("goToReviewTab")}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -96,8 +97,11 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (err || !data) {
|
||||
return <p className="text-destructive text-sm">{err ?? t("states.noData", { ns: "common" })}</p>;
|
||||
if (err) {
|
||||
return <p className="text-destructive text-sm">{err}</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
const currencyCode = data.currency_code ?? "NPR";
|
||||
@@ -180,7 +184,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.settlement_batches.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("noSettlementBatches")}</p>
|
||||
<AdminNoResourceState className="py-4" />
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<div className="admin-table-toolbar">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -119,8 +120,11 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
if (!batch) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { getAdminDrawResultBatches } from "@/api/admin-draws";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -20,22 +21,19 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { canManageDrawResults } from "@/lib/draw-access";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||
|
||||
import { drawPrizeTypeLabel } from "./draw-display";
|
||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
|
||||
export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
]);
|
||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
||||
const idNum = Number(drawId);
|
||||
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -67,8 +65,11 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
const published = data.batches.filter((b) => b.status === "published");
|
||||
@@ -82,41 +83,57 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
{t("drawNo")} {data.draw_no} · <DrawStatusBadge status={data.draw_status} />
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{canManageDraw ? t("reviewAndPublish") : t("viewReviewQueue")}
|
||||
</Link>
|
||||
{canManageDraw ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("reviewAndPublish")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{published.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("noPublishedBatch")}
|
||||
<CardContent className="py-4">
|
||||
<AdminNoResourceState />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
published.map((batch) => <BatchTable key={batch.id} batch={batch} />)
|
||||
published.map((batch) => (
|
||||
<BatchTable key={batch.id} batch={batch} showOperationalMeta={canManageDraw} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
|
||||
function BatchTable({
|
||||
batch,
|
||||
showOperationalMeta,
|
||||
}: {
|
||||
batch: AdminDrawBatchRow;
|
||||
showOperationalMeta: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("draws");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("version", { version: batch.result_version })}</CardTitle>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{t("sourceType", {
|
||||
source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
|
||||
})}{" "}
|
||||
· {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
|
||||
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
|
||||
</p>
|
||||
{showOperationalMeta ? (
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{t("sourceType", {
|
||||
source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
|
||||
})}{" "}
|
||||
· {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
|
||||
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto pt-0">
|
||||
<Table>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -152,8 +153,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -233,9 +237,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
{t("noPendingBatches")}
|
||||
</p>
|
||||
<AdminNoResourceState className="py-6" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -2,18 +2,35 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_DRAW_FINANCE_ACCESS_ANY, PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const segments = [
|
||||
{ suffix: "", key: "status", label: "subnav.status" },
|
||||
{ suffix: "/results", key: "results", label: "subnav.results" },
|
||||
{ suffix: "/finance", key: "finance", label: "subnav.finance" },
|
||||
{ suffix: "/review", key: "review", label: "subnav.review" },
|
||||
{ suffix: "/risk/occupancy", key: "riskLockLogs", label: "subnav.riskLockLogs" },
|
||||
{ suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" },
|
||||
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false },
|
||||
{ suffix: "/results", key: "results", label: "subnav.results", requiresManage: false },
|
||||
{ suffix: "/finance", key: "finance", label: "subnav.finance", requiresManage: false },
|
||||
{ suffix: "/review", key: "review", label: "subnav.review", requiresManage: true },
|
||||
{
|
||||
suffix: "/risk/occupancy",
|
||||
key: "riskLockLogs",
|
||||
label: "subnav.riskLockLogs",
|
||||
requiresManage: false,
|
||||
requiresRisk: true,
|
||||
},
|
||||
{
|
||||
suffix: "/risk/pools",
|
||||
key: "riskPools",
|
||||
label: "subnav.riskPools",
|
||||
requiresManage: false,
|
||||
requiresRisk: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function isRiskPoolsTabActive(pathname: string, base: string): boolean {
|
||||
@@ -23,6 +40,7 @@ function isRiskPoolsTabActive(pathname: string, base: string): boolean {
|
||||
}
|
||||
|
||||
const rest = pathname.slice(riskPrefix.length);
|
||||
|
||||
return (
|
||||
rest === "pools"
|
||||
|| rest.startsWith("pools/")
|
||||
@@ -34,21 +52,50 @@ function isRiskPoolsTabActive(pathname: string, base: string): boolean {
|
||||
function isReviewTabActive(pathname: string, base: string): boolean {
|
||||
const reviewPrefix = `${base}/review`;
|
||||
const publishPrefix = `${base}/publish`;
|
||||
|
||||
return (
|
||||
pathname === reviewPrefix ||
|
||||
pathname.startsWith(`${reviewPrefix}/`) ||
|
||||
pathname.startsWith(`${publishPrefix}/`)
|
||||
pathname === reviewPrefix
|
||||
|| pathname.startsWith(`${reviewPrefix}/`)
|
||||
|| pathname.startsWith(`${publishPrefix}/`)
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawSubnav({ drawId }: { drawId: string }) {
|
||||
export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const { t } = useTranslation("draws");
|
||||
const pathname = usePathname();
|
||||
const base = `/admin/draws/${drawId}`;
|
||||
const profile = useAdminProfile();
|
||||
const perms = profile?.permissions ?? [];
|
||||
|
||||
const canViewDraw = canViewDrawResults(perms);
|
||||
const canManageDraw = canManageDrawResults(perms);
|
||||
const canViewFinance = canViewDrawFinance(perms);
|
||||
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
|
||||
|
||||
const visibleSegments = useMemo(
|
||||
() =>
|
||||
segments.filter((segment) => {
|
||||
if (!canViewDraw) {
|
||||
return false;
|
||||
}
|
||||
if (segment.requiresManage && !canManageDraw) {
|
||||
return false;
|
||||
}
|
||||
if (segment.key === "finance" && !canViewFinance) {
|
||||
return false;
|
||||
}
|
||||
if ("requiresRisk" in segment && segment.requiresRisk && !canViewRisk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
[canManageDraw, canViewDraw, canViewRisk],
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3">
|
||||
{segments.map(({ suffix, key, label }) => {
|
||||
{visibleSegments.map(({ suffix, key, label }) => {
|
||||
const href = `${base}${suffix}`;
|
||||
const active =
|
||||
suffix === ""
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { formatAdminInstant } from "@/lib/admin-datetime";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -58,6 +59,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
|
||||
|
||||
import { drawStatusLabel } from "./draw-display";
|
||||
import { canManageDrawResults, canViewDrawFinance } from "@/lib/draw-access";
|
||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
|
||||
@@ -94,7 +96,8 @@ export function DrawsIndexConsole() {
|
||||
useAdminCurrencyCatalog();
|
||||
const defaultCurrency = "NPR";
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
||||
const canViewFinance = canViewDrawFinance(profile?.permissions);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||
const formatDt = useCallback(
|
||||
@@ -395,21 +398,21 @@ export function DrawsIndexConsole() {
|
||||
<TableHead>{t("closeTime")}</TableHead>
|
||||
<TableHead>{t("drawTime")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-center">{t("betTotal")}</TableHead>
|
||||
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
||||
<TableHead className="text-center">{t("profitLoss")}</TableHead>
|
||||
{canViewFinance ? (
|
||||
<>
|
||||
<TableHead className="text-center">{t("betTotal")}</TableHead>
|
||||
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
||||
<TableHead className="text-center">{t("profitLoss")}</TableHead>
|
||||
</>
|
||||
) : null}
|
||||
<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>
|
||||
{loading ? (
|
||||
<AdminTableLoadingRow colSpan={10} />
|
||||
<AdminTableLoadingRow colSpan={canViewFinance ? 10 : 7} />
|
||||
) : data === null || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={canViewFinance ? 10 : 7} className="text-muted-foreground" />
|
||||
) : (
|
||||
data.items.map((row: AdminDrawListItem) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -431,26 +434,30 @@ export function DrawsIndexConsole() {
|
||||
label={drawStatusLabel(row.status, t)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.total_bet_minor != null
|
||||
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.total_payout_minor != null
|
||||
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-center text-xs tabular-nums",
|
||||
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
|
||||
)}
|
||||
>
|
||||
{row.profit_loss_minor != null
|
||||
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
{canViewFinance ? (
|
||||
<>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.total_bet_minor != null
|
||||
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.total_payout_minor != null
|
||||
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-center text-xs tabular-nums",
|
||||
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
|
||||
)}
|
||||
>
|
||||
{row.profit_loss_minor != null
|
||||
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</>
|
||||
) : null}
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Copy, Download, Link2, Pencil, ShieldAlert } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
getAdminIntegrationSite,
|
||||
getAdminIntegrationSiteExport,
|
||||
getAdminIntegrationSites,
|
||||
getAdminIntegrationSiteSecrets,
|
||||
postAdminIntegrationSite,
|
||||
postAdminIntegrationSiteConnectivityTest,
|
||||
postAdminIntegrationSiteRotateSecrets,
|
||||
putAdminIntegrationSite,
|
||||
} from "@/api/admin-integration-sites";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -54,6 +56,60 @@ import type {
|
||||
AdminIntegrationSiteWithSecrets,
|
||||
} from "@/types/api/admin-integration-site";
|
||||
|
||||
function CopyIconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
busy,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
disabled={disabled || busy}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function MaskedValueWithCopy({
|
||||
configured,
|
||||
masked,
|
||||
copyLabel,
|
||||
canCopy,
|
||||
copying,
|
||||
onCopy,
|
||||
}: {
|
||||
configured: boolean;
|
||||
masked: string | null;
|
||||
copyLabel: string;
|
||||
canCopy: boolean;
|
||||
copying: boolean;
|
||||
onCopy: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex min-w-[7.5rem] max-w-[11rem] items-center gap-0.5">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
{configured ? (masked ?? "••••••••") : "—"}
|
||||
</span>
|
||||
{configured && canCopy ? (
|
||||
<CopyIconButton label={copyLabel} onClick={onCopy} busy={copying} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -140,7 +196,7 @@ function formToPayload(
|
||||
}
|
||||
|
||||
type IntegrationSitesConsoleProps = {
|
||||
/** 代理线路内站点列表:仅超管可新建站点,普通账号走「开通线路」。 */
|
||||
/** 为 true 时仅超管可新建站点(默认有 integration.site.manage 即可创建)。 */
|
||||
restrictCreateToSuperAdmin?: boolean;
|
||||
};
|
||||
|
||||
@@ -180,9 +236,12 @@ export function IntegrationSitesConsole({
|
||||
const [connectivityResult, setConnectivityResult] =
|
||||
useState<AdminIntegrationSiteConnectivityResult | null>(null);
|
||||
const [exportBusyId, setExportBusyId] = useState<number | null>(null);
|
||||
const [secretCopyBusyKey, setSecretCopyBusyKey] = useState<string | null>(null);
|
||||
const secretsCacheRef = useRef(new Map<number, AdminIntegrationSiteSecrets>());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
secretsCacheRef.current.clear();
|
||||
try {
|
||||
const data = await getAdminIntegrationSites();
|
||||
setItems(data.items);
|
||||
@@ -273,6 +332,7 @@ export function IntegrationSitesConsole({
|
||||
const result = await postAdminIntegrationSiteRotateSecrets(rotateTarget.id);
|
||||
toast.success(t("integrationSites.rotateSuccess", { code: rotateTarget.code }));
|
||||
setRotateTarget(null);
|
||||
secretsCacheRef.current.delete(rotateTarget.id);
|
||||
showSecretsOnce(result);
|
||||
await load();
|
||||
} catch (error) {
|
||||
@@ -350,6 +410,55 @@ export function IntegrationSitesConsole({
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSiteSecrets(siteId: number): Promise<AdminIntegrationSiteSecrets> {
|
||||
const cached = secretsCacheRef.current.get(siteId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const secrets = await getAdminIntegrationSiteSecrets(siteId);
|
||||
secretsCacheRef.current.set(siteId, secrets);
|
||||
return secrets;
|
||||
}
|
||||
|
||||
async function copySiteSecret(
|
||||
row: AdminIntegrationSiteRow,
|
||||
field: "sso" | "wallet",
|
||||
): Promise<void> {
|
||||
if (!canManage) {
|
||||
toast.error(t("integrationSites.secretCopyRequiresManage"));
|
||||
return;
|
||||
}
|
||||
|
||||
const configured = field === "sso" ? row.has_sso_secret : row.has_wallet_api_key;
|
||||
if (!configured) {
|
||||
toast.error(t("integrationSites.secretNotConfigured"));
|
||||
return;
|
||||
}
|
||||
|
||||
const busyKey = `${row.id}:${field}`;
|
||||
setSecretCopyBusyKey(busyKey);
|
||||
try {
|
||||
const secrets = await resolveSiteSecrets(row.id);
|
||||
const value = field === "sso" ? secrets.sso_jwt_secret : secrets.wallet_api_key;
|
||||
if (!value) {
|
||||
toast.error(t("integrationSites.secretNotConfigured"));
|
||||
return;
|
||||
}
|
||||
await copyText(
|
||||
field === "sso"
|
||||
? t("integrationSites.fields.ssoSecret")
|
||||
: t("integrationSites.fields.walletApiKey"),
|
||||
value,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.copyFailed"),
|
||||
);
|
||||
} finally {
|
||||
setSecretCopyBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
@@ -366,23 +475,38 @@ export function IntegrationSitesConsole({
|
||||
{loading ? (
|
||||
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
|
||||
<AdminNoResourceState />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("integrationSites.columns.code")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.name")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.currency")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.status")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.lineRoot")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.h5Url")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.ssoSecret")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.walletApiKey")}</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>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.code}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex max-w-[10rem] items-center gap-0.5">
|
||||
<span className="truncate font-mono text-xs">{row.code}</span>
|
||||
<CopyIconButton
|
||||
label={t("integrationSites.copy")}
|
||||
onClick={() => void copyText(t("integrationSites.columns.code"), row.code)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{row.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.currency_code}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge
|
||||
tone={row.status === 1 ? "success" : "neutral"}
|
||||
@@ -392,8 +516,53 @@ export function IntegrationSitesConsole({
|
||||
: t("integrationSites.statusDisabled")}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
|
||||
{row.wallet_api_url ?? "—"}
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={row.has_line_root ? "success" : "neutral"}>
|
||||
{row.has_line_root
|
||||
? t("integrationSites.lineRootBound")
|
||||
: t("integrationSites.lineRootUnbound")}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex max-w-[14rem] items-center gap-0.5">
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{row.wallet_api_url ?? "—"}
|
||||
</span>
|
||||
{row.wallet_api_url ? (
|
||||
<CopyIconButton
|
||||
label={t("integrationSites.copy")}
|
||||
onClick={() =>
|
||||
void copyText(
|
||||
t("integrationSites.columns.walletUrl"),
|
||||
row.wallet_api_url ?? "",
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
|
||||
{row.lottery_h5_base_url ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MaskedValueWithCopy
|
||||
configured={row.has_sso_secret}
|
||||
masked={row.sso_secret_masked}
|
||||
copyLabel={t("integrationSites.copy")}
|
||||
canCopy={canManage}
|
||||
copying={secretCopyBusyKey === `${row.id}:sso`}
|
||||
onCopy={() => void copySiteSecret(row, "sso")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MaskedValueWithCopy
|
||||
configured={row.has_wallet_api_key}
|
||||
masked={row.wallet_api_key_masked}
|
||||
copyLabel={t("integrationSites.copy")}
|
||||
canCopy={canManage}
|
||||
copying={secretCopyBusyKey === `${row.id}:wallet`}
|
||||
onCopy={() => void copySiteSecret(row, "wallet")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
@@ -434,6 +603,7 @@ export function IntegrationSitesConsole({
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
|
||||
@@ -645,36 +815,30 @@ export function IntegrationSitesConsole({
|
||||
<Label>{t("integrationSites.fields.ssoSecret")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={secretsDialog.secrets.sso_jwt_secret} className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<CopyIconButton
|
||||
label={t("integrationSites.copy")}
|
||||
onClick={() =>
|
||||
void copyText(
|
||||
t("integrationSites.fields.ssoSecret"),
|
||||
secretsDialog.secrets.sso_jwt_secret,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("integrationSites.copy")}
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("integrationSites.fields.walletApiKey")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={secretsDialog.secrets.wallet_api_key} className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<CopyIconButton
|
||||
label={t("integrationSites.copy")}
|
||||
onClick={() =>
|
||||
void copyText(
|
||||
t("integrationSites.fields.walletApiKey"),
|
||||
secretsDialog.secrets.wallet_api_key,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("integrationSites.copy")}
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -43,6 +44,11 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
formatRatioAsPercent,
|
||||
percentUiToRatio,
|
||||
ratioToPercentUi,
|
||||
} from "@/lib/admin-rate-percent";
|
||||
|
||||
type Draft = {
|
||||
contribution_rate: string;
|
||||
@@ -63,9 +69,9 @@ type AdjustmentDraft = {
|
||||
|
||||
function toDraft(p: AdminJackpotPoolRow): Draft {
|
||||
return {
|
||||
contribution_rate: String(p.contribution_rate),
|
||||
contribution_rate: ratioToPercentUi(p.contribution_rate),
|
||||
trigger_threshold: formatAdminMinorDecimal(p.trigger_threshold, p.currency_code),
|
||||
payout_rate: String(p.payout_rate),
|
||||
payout_rate: ratioToPercentUi(p.payout_rate),
|
||||
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
|
||||
min_bet_amount: formatAdminMinorDecimal(p.min_bet_amount, p.currency_code),
|
||||
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
|
||||
@@ -148,9 +154,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
setSavingId(p.id);
|
||||
try {
|
||||
await putAdminJackpotPool(p.id, {
|
||||
contribution_rate: Number(d.contribution_rate),
|
||||
contribution_rate: percentUiToRatio(d.contribution_rate),
|
||||
trigger_threshold: parseAdminMajorToMinor(d.trigger_threshold, p.currency_code) ?? 0,
|
||||
payout_rate: Number(d.payout_rate),
|
||||
payout_rate: percentUiToRatio(d.payout_rate),
|
||||
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
|
||||
min_bet_amount: parseAdminMajorToMinor(d.min_bet_amount, p.currency_code) ?? 0,
|
||||
combo_trigger_play_codes: d.combo_trigger_play_codes
|
||||
@@ -233,7 +239,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
<div className={embedded ? "space-y-4" : "space-y-8"}>
|
||||
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
|
||||
<AdminNoResourceState />
|
||||
) : null}
|
||||
{items.map((p) => {
|
||||
const d = drafts[p.id] ?? toDraft(p);
|
||||
@@ -269,7 +275,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("payoutRate")}</p>
|
||||
<p className="mt-0.5 font-mono text-base font-semibold">{d.payout_rate}</p>
|
||||
<p className="mt-0.5 font-mono text-base font-semibold">
|
||||
{formatRatioAsPercent(percentUiToRatio(d.payout_rate))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("forceTriggerGap")}</p>
|
||||
@@ -381,6 +389,10 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
|
||||
<Input
|
||||
id={`pr-${p.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="font-mono"
|
||||
value={d.payout_rate}
|
||||
placeholder={t("payoutRatePlaceholder")}
|
||||
@@ -401,6 +413,10 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
|
||||
<Input
|
||||
id={`cr-${p.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="font-mono"
|
||||
value={d.contribution_rate}
|
||||
placeholder={t("contributionRatePlaceholder")}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
@@ -254,11 +255,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(payouts?.items ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={6} className="py-10 text-center text-muted-foreground" />
|
||||
) : (
|
||||
(payouts?.items ?? []).map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
@@ -303,11 +300,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(contribs?.items ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-10 text-center text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={8} className="py-10 text-center text-muted-foreground" />
|
||||
) : (
|
||||
(contribs?.items ?? []).map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getAdminPlayer } from "@/api/admin-player";
|
||||
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
||||
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -29,6 +30,13 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import {
|
||||
isCreditFundingPlayer,
|
||||
playerAuthSourceLabel,
|
||||
playerFundingModeLabel,
|
||||
playerShowsTransferOrders,
|
||||
} from "@/lib/player-funding";
|
||||
import { formatPlayerCreditAmount } from "@/lib/admin-player-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
@@ -209,7 +217,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
}, [player, loadTxns]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!player) return;
|
||||
if (!player || !playerShowsTransferOrders(player)) return;
|
||||
void loadTransfers();
|
||||
}, [player, loadTransfers]);
|
||||
|
||||
@@ -217,6 +225,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
return <AdminLoadingState minHeight="8rem" className="py-8" />;
|
||||
}
|
||||
|
||||
const isCreditPlayer = player ? isCreditFundingPlayer(player) : false;
|
||||
const showTransferTab = player ? playerShowsTransferOrders(player) : false;
|
||||
|
||||
if (playerErr || !player) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -224,7 +235,11 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
<p className="text-sm text-destructive">{playerErr ?? t("states.noData", { ns: "common" })}</p>
|
||||
{playerErr ? (
|
||||
<p className="text-sm text-destructive">{playerErr}</p>
|
||||
) : (
|
||||
<AdminNoResourceState />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -263,11 +278,13 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
{t("tabTickets")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="wallet" className="rounded-none px-3">
|
||||
{t("tabWalletTxns")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transfers" className="rounded-none px-3">
|
||||
{t("tabTransferOrders")}
|
||||
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
|
||||
</TabsTrigger>
|
||||
{showTransferTab ? (
|
||||
<TabsTrigger value="transfers" className="rounded-none px-3">
|
||||
{t("tabTransferOrders")}
|
||||
</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-0 space-y-4">
|
||||
@@ -289,6 +306,12 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</dl>
|
||||
<dl className="space-y-3">
|
||||
<ProfileField label={t("currency")}>{player.default_currency}</ProfileField>
|
||||
<ProfileField label={t("fundingMode")}>
|
||||
{playerFundingModeLabel(player, t)}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("authSource")}>
|
||||
{playerAuthSourceLabel(player, t)}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("status")}>
|
||||
<PlayerStatusBadge status={player.status} t={t} />
|
||||
</ProfileField>
|
||||
@@ -312,11 +335,40 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("walletsSection")}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{isCreditPlayer ? t("creditSection") : t("walletsSection")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{player.wallets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
{isCreditPlayer ? (
|
||||
<dl className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("creditLimit")}</p>
|
||||
<p className="mt-1 text-sm font-semibold tabular-nums">
|
||||
{player.credit_limit != null
|
||||
? formatPlayerCreditAmount(player.credit_limit, player.default_currency)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("availableCredit")}</p>
|
||||
<p className="mt-1 text-sm font-semibold tabular-nums">
|
||||
{player.available_credit != null
|
||||
? formatPlayerCreditAmount(player.available_credit, player.default_currency)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("usedCredit")}</p>
|
||||
<p className="mt-1 text-sm tabular-nums text-muted-foreground">
|
||||
{player.used_credit != null
|
||||
? formatPlayerCreditAmount(player.used_credit, player.default_currency)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</dl>
|
||||
) : player.wallets.length === 0 ? (
|
||||
<AdminNoResourceState className="text-sm text-muted-foreground" />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{player.wallets.map((w: AdminPlayerWalletRow) => (
|
||||
@@ -399,11 +451,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</TableRow>
|
||||
))}
|
||||
{!ticketsLoading && tickets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={7} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -428,7 +476,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<TabsContent value="wallet" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabWalletTxns")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">
|
||||
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
@@ -466,11 +516,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</TableRow>
|
||||
))}
|
||||
{!txnsLoading && txns.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={6} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -492,6 +538,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{showTransferTab ? (
|
||||
<TabsContent value="transfers" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
@@ -531,11 +578,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</TableRow>
|
||||
))}
|
||||
{!transfersLoading && transfers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -556,6 +599,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -61,9 +62,11 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||
|
||||
function playerStatusLabelT(status: number, t: (key: string) => string): string {
|
||||
if (status === 0) return t("statusNormal");
|
||||
@@ -72,15 +75,6 @@ function playerStatusLabelT(status: number, t: (key: string) => string): string
|
||||
return String(status);
|
||||
}
|
||||
|
||||
function preferredDisplayWallet(row: AdminPlayerRow): AdminPlayerWalletRow | null {
|
||||
const { wallets, default_currency } = row;
|
||||
if (wallets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const code = default_currency.trim().toUpperCase();
|
||||
return wallets.find((w) => w.currency_code.toUpperCase() === code) ?? wallets[0];
|
||||
}
|
||||
|
||||
const PLAYER_STATUS_OPTIONS = [
|
||||
{ value: 0, label: "statusNormal" },
|
||||
{ value: 1, label: "statusFrozen" },
|
||||
@@ -108,6 +102,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [keyword, setKeyword] = useState(keywordFromUrl);
|
||||
const [query, setQuery] = useState(keywordFromUrl);
|
||||
const [siteFilter, setSiteFilter] = useState("");
|
||||
|
||||
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -138,14 +133,43 @@ export function PlayersConsole(): React.ReactElement {
|
||||
[items, editingAccountId],
|
||||
);
|
||||
|
||||
const showSiteFilter =
|
||||
isSuperAdmin || (profile?.accessible_sites?.length ?? 0) > 1;
|
||||
|
||||
const scopeHint = useMemo(() => {
|
||||
if (isSuperAdmin) {
|
||||
return siteFilter.trim() !== ""
|
||||
? t("scopeFilteredSite", { site: siteFilter.trim() })
|
||||
: t("scopeAllSites");
|
||||
}
|
||||
if (boundAgent) {
|
||||
return t("scopeAgentLine", {
|
||||
site: boundAgent.site_code,
|
||||
name: boundAgent.name,
|
||||
});
|
||||
}
|
||||
const sites = profile?.accessible_sites ?? [];
|
||||
if (sites.length === 1) {
|
||||
return t("scopeSingleSite", { site: sites[0].code });
|
||||
}
|
||||
if (sites.length > 1) {
|
||||
return siteFilter.trim() !== ""
|
||||
? t("scopeFilteredSite", { site: siteFilter.trim() })
|
||||
: t("scopeMultiSite", { count: sites.length });
|
||||
}
|
||||
return "";
|
||||
}, [boundAgent, isSuperAdmin, profile?.accessible_sites, siteFilter, t]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const scopedSite = siteFilter.trim();
|
||||
const data = await getAdminPlayers({
|
||||
page,
|
||||
per_page: perPage,
|
||||
keyword: query.trim() || undefined,
|
||||
...(scopedSite !== "" ? { site_code: scopedSite } : {}),
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
@@ -159,11 +183,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query]);
|
||||
}, [page, perPage, query, siteFilter]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, query]);
|
||||
}, [page, perPage, query, siteFilter]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
setKeyword(keywordFromUrl);
|
||||
@@ -385,7 +409,36 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{scopeHint ? (
|
||||
<p className="text-xs text-muted-foreground">{scopeHint}</p>
|
||||
) : null}
|
||||
<div className="admin-list-toolbar">
|
||||
{showSiteFilter ? (
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="player-site-filter" className="sm:w-20 sm:shrink-0">
|
||||
{t("filterSite")}
|
||||
</Label>
|
||||
<Select
|
||||
value={siteFilter || "__all__"}
|
||||
onValueChange={(value) => {
|
||||
setSiteFilter(value === "__all__" ? "" : value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="player-site-filter" className="w-full sm:w-[12rem]">
|
||||
<SelectValue placeholder={t("filterAllSites")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
|
||||
{(isSuperAdmin ? siteOptions : profile?.accessible_sites ?? []).map((site) => (
|
||||
<SelectItem key={site.code} value={site.code}>
|
||||
{site.name ? `${site.name} (${site.code})` : site.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("search")}
|
||||
@@ -454,6 +507,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<TableHead>{t("username")}</TableHead>
|
||||
<TableHead>{t("nickname")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
|
||||
@@ -463,16 +517,12 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={12} />
|
||||
<AdminTableLoadingRow colSpan={13} />
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={13} className="text-muted-foreground" />
|
||||
) : (
|
||||
items.map((row) => {
|
||||
const displayWallet = preferredDisplayWallet(row);
|
||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="tabular-nums">#{row.id}</TableCell>
|
||||
@@ -486,18 +536,14 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<TableCell>{row.username ?? "—"}</TableCell>
|
||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||
<TableCell>{row.default_currency}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
|
||||
{displayWallet
|
||||
? formatAdminMinorUnits(displayWallet.balance, displayWallet.currency_code)
|
||||
: "—"}
|
||||
<TableCell>
|
||||
<PlayerFundingModeBadge row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
|
||||
{displayWallet
|
||||
? formatAdminMinorUnits(
|
||||
displayWallet.available_balance,
|
||||
displayWallet.currency_code,
|
||||
)
|
||||
: "—"}
|
||||
{balances.balance}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
|
||||
{balances.available}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.status === 2 ? (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/api/admin-reconcile";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
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";
|
||||
@@ -333,7 +334,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{playerLoading ? (
|
||||
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
|
||||
) : playerResults.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
|
||||
<AdminNoResourceState compact className="px-3 py-4" />
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{playerResults.map((player) => {
|
||||
@@ -456,11 +457,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{jobsLoading && !jobs ? (
|
||||
<AdminTableLoadingRow colSpan={10} />
|
||||
) : jobs.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
|
||||
) : (
|
||||
jobs.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -616,11 +613,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("noDetails")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={6} />
|
||||
) : (
|
||||
items.items.map((r) => (
|
||||
<TableRow
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Download, RefreshCw } from "lucide-react";
|
||||
|
||||
import { downloadAdminReportJob, getAdminReportJobs } from "@/api/admin-report-jobs";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -115,11 +116,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
||||
{loading ? (
|
||||
<AdminTableLoadingRow colSpan={6} />
|
||||
) : jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("taskEmpty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={6} />
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -92,7 +93,7 @@ import type {
|
||||
AdminReportRebateCommissionRow,
|
||||
} from "@/types/api/admin-reports";
|
||||
|
||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit" | "legacy";
|
||||
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";
|
||||
@@ -191,7 +192,7 @@ const REPORTS: ReportDefinition[] = [
|
||||
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
|
||||
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
|
||||
{ key: "rebate_commission", category: "wallet", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||
{ key: "rebate_commission", category: "legacy", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
|
||||
];
|
||||
|
||||
@@ -225,6 +226,8 @@ function categoryTone(category: ReportCategory): string {
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
case "audit":
|
||||
return "border-slate-200 bg-slate-50 text-slate-700";
|
||||
case "legacy":
|
||||
return "border-amber-200 bg-amber-50 text-amber-800";
|
||||
default:
|
||||
return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
}
|
||||
@@ -1248,11 +1251,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}
|
||||
if (!result || result.rows.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("preview.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={8} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1507,6 +1506,15 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedReport.key === "rebate_commission" ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
{t("items.rebate_commission.disclaimer", {
|
||||
defaultValue:
|
||||
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
const tabs = [
|
||||
{ category: "profit", href: "/admin/reports/profit" },
|
||||
{ category: "wallet", href: "/admin/reports/wallet" },
|
||||
{ category: "legacy", href: "/admin/reports/legacy" },
|
||||
{ category: "risk", href: "/admin/reports/risk" },
|
||||
{ category: "audit", href: "/admin/reports/audit" },
|
||||
] as const;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -190,11 +191,7 @@ export function RiskIndexConsole() {
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={4} />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={4} className="text-muted-foreground" />
|
||||
) : (
|
||||
(data?.items ?? []).map((row: AdminDrawListItem) => (
|
||||
<TableRow key={row.id}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
postAdminCurrency,
|
||||
putAdminCurrency,
|
||||
} from "@/api/admin-currencies";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
@@ -242,11 +243,7 @@ export function CurrencySettingsPanel() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
||||
{t("currencies.empty", { ns: "config" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={6} cellClassName="text-center" />
|
||||
) : (
|
||||
items.map((row) => (
|
||||
<TableRow key={row.code}>
|
||||
|
||||
313
src/modules/settlement/agent-bill-detail.tsx
Normal file
313
src/modules/settlement/agent-bill-detail.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSettlementBill,
|
||||
postSettlementBillAdjustment,
|
||||
postSettlementBillBadDebtWriteOff,
|
||||
postSettlementBillConfirm,
|
||||
postSettlementBillPayment,
|
||||
type RebateAllocationRow,
|
||||
type SettlementBillRow,
|
||||
type SettlementPaymentRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): {
|
||||
share_profit?: number;
|
||||
platform_share_profit?: number;
|
||||
} {
|
||||
if (metaJson == null || metaJson === "") {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed =
|
||||
typeof metaJson === "string" ? (JSON.parse(metaJson) as Record<string, unknown>) : metaJson;
|
||||
return {
|
||||
share_profit: parsed.share_profit != null ? Number(parsed.share_profit) : undefined,
|
||||
platform_share_profit:
|
||||
parsed.platform_share_profit != null ? Number(parsed.platform_share_profit) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type AgentBillDetailProps = {
|
||||
billId: number;
|
||||
currencyCode: string;
|
||||
canManage?: boolean;
|
||||
onUpdated?: () => void;
|
||||
};
|
||||
|
||||
export function AgentBillDetail({
|
||||
billId,
|
||||
currencyCode,
|
||||
canManage = true,
|
||||
onUpdated,
|
||||
}: AgentBillDetailProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [bill, setBill] = useState<SettlementBillRow | null>(null);
|
||||
const [payments, setPayments] = useState<SettlementPaymentRow[]>([]);
|
||||
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
|
||||
const [tierEdge, setTierEdge] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payAmount, setPayAmount] = useState("");
|
||||
const [payMethod, setPayMethod] = useState("");
|
||||
const [payProof, setPayProof] = useState("");
|
||||
const [adjustAmount, setAdjustAmount] = useState("");
|
||||
const [badDebtReason, setBadDebtReason] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getSettlementBill(billId);
|
||||
setBill(data.bill);
|
||||
setPayments(data.payments ?? []);
|
||||
setRebateAllocations(data.rebate_allocations ?? []);
|
||||
setTierEdge(data.tier_edge ?? null);
|
||||
setPayAmount(String(data.bill.unpaid_amount ?? 0));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [billId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (loading || !bill) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
const owner =
|
||||
bill.owner_label ??
|
||||
`${bill.owner_type}#${bill.owner_id}`;
|
||||
const counterparty =
|
||||
bill.counterparty_label === "platform"
|
||||
? t("settlementBills.platform", { defaultValue: "平台" })
|
||||
: bill.counterparty_label ?? `${bill.counterparty_type}#${bill.counterparty_id}`;
|
||||
|
||||
const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
|
||||
const ownerOwes = bill.net_amount > 0;
|
||||
const paymentTitle = ownerOwes
|
||||
? t("settlementBills.recordReceipt", { defaultValue: "登记收款" })
|
||||
: t("settlementBills.recordPayout", { defaultValue: "登记付款" });
|
||||
const paymentSubmit = ownerOwes
|
||||
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
|
||||
: t("settlementBills.submitPayout", { defaultValue: "确认付款" });
|
||||
const canWriteOff =
|
||||
canManage &&
|
||||
bill.unpaid_amount > 0 &&
|
||||
["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
|
||||
!["adjustment", "reversal", "bad_debt"].includes(bill.bill_type);
|
||||
const meta = parseBillMeta(bill.meta_json);
|
||||
const hasSubtreeFields =
|
||||
bill.gross_win_loss != null ||
|
||||
bill.rebate_amount != null ||
|
||||
bill.platform_rounding_adjustment != null ||
|
||||
meta.share_profit != null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settlementBills.columns.party", { defaultValue: "本方" })}: </span>
|
||||
{owner}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.columns.counterparty", { defaultValue: "对方" })}:{" "}
|
||||
</span>
|
||||
{counterparty}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settlementBills.columns.type", { defaultValue: "类型" })}: </span>
|
||||
{bill.bill_type} / {bill.status}
|
||||
{tierEdge ? ` · ${tierEdge}` : ""}
|
||||
</div>
|
||||
{hasSubtreeFields ? (
|
||||
<div className="space-y-1 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">
|
||||
{t("settlementBills.subtreeSummary", { defaultValue: "子树汇总" })}
|
||||
</p>
|
||||
{bill.gross_win_loss != null ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.grossWinLoss", { defaultValue: "输赢 (gross_win_loss)" })}:{" "}
|
||||
</span>
|
||||
{formatDashboardMoneyMinor(bill.gross_win_loss, currencyCode)}
|
||||
</div>
|
||||
) : null}
|
||||
{bill.rebate_amount != null ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.rebateAmount", { defaultValue: "回水" })}:{" "}
|
||||
</span>
|
||||
{formatDashboardMoneyMinor(bill.rebate_amount, currencyCode)}
|
||||
</div>
|
||||
) : null}
|
||||
{meta.share_profit != null ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.shareProfit", { defaultValue: "占成利润" })}:{" "}
|
||||
</span>
|
||||
{formatDashboardMoneyMinor(meta.share_profit, currencyCode)}
|
||||
</div>
|
||||
) : null}
|
||||
{bill.platform_rounding_adjustment != null && bill.platform_rounding_adjustment !== 0 ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.platformRounding", { defaultValue: "平台尾差" })}:{" "}
|
||||
</span>
|
||||
{formatDashboardMoneyMinor(bill.platform_rounding_adjustment, currencyCode)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settlementBills.columns.net", { defaultValue: "净额" })}: </span>
|
||||
{formatDashboardMoneyMinor(bill.net_amount, currencyCode)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settlementBills.columns.unpaid", { defaultValue: "未结" })}: </span>
|
||||
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
|
||||
</div>
|
||||
|
||||
{rebateAllocations.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
{rebateAllocations.map((row) => (
|
||||
<li key={row.id}>
|
||||
{row.participant_type}#{row.participant_id} · {row.allocation_rule} ·{" "}
|
||||
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payments.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
{payments.map((p) => (
|
||||
<li key={p.id}>
|
||||
{formatDashboardMoneyMinor(p.amount, currencyCode)}
|
||||
{p.method ? ` · ${p.method}` : ""}
|
||||
{p.remark ? ` · ${p.remark}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && bill.status === "pending_confirm" ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void postSettlementBillConfirm(billId)
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() => toast.success(t("settlementBills.confirmed", { defaultValue: "已确认" })))
|
||||
}
|
||||
>
|
||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
||||
<div className="space-y-2 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">{paymentTitle}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentMethod", { defaultValue: "方式" })}</Label>
|
||||
<Input value={payMethod} onChange={(e) => setPayMethod(e.target.value)} placeholder="cash" />
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||
<Input value={payProof} onChange={(e) => setPayProof(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void postSettlementBillPayment(billId, {
|
||||
amount: Number(payAmount),
|
||||
method: payMethod.trim() || undefined,
|
||||
proof: payProof.trim() || undefined,
|
||||
})
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() => toast.success(t("settlementBills.paid", { defaultValue: "已登记收付" })))
|
||||
}
|
||||
>
|
||||
{paymentSubmit}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canWriteOff ? (
|
||||
<div className="space-y-2 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}</p>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||
<Input value={badDebtReason} onChange={(e) => setBadDebtReason(e.target.value)} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
void postSettlementBillBadDebtWriteOff(billId, {
|
||||
reason: badDebtReason.trim() || undefined,
|
||||
})
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() =>
|
||||
toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" })),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && locked ? (
|
||||
<div className="space-y-2 rounded-md border border-dashed border-border/60 p-3">
|
||||
<p className="font-medium">{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}</p>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
||||
<Input value={adjustAmount} onChange={(e) => setAdjustAmount(e.target.value)} type="number" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void postSettlementBillAdjustment(billId, {
|
||||
amount: Number(adjustAmount),
|
||||
reason: "manual_adjustment",
|
||||
})
|
||||
.then(() => toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })))
|
||||
.then(onUpdated)
|
||||
}
|
||||
>
|
||||
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { adminRequest } from "@/lib/admin-http";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type BillRow = {
|
||||
id: number;
|
||||
bill_type: string;
|
||||
net_amount: number;
|
||||
unpaid_amount: number;
|
||||
status: string;
|
||||
};
|
||||
import { SettlementCenterShell } from "@/modules/settlement/settlement-center-shell";
|
||||
|
||||
/** 兼容旧引用:结算中心完整表格化界面 */
|
||||
export function AgentBillsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [rows, setRows] = useState<BillRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminRequest.get<{ items: BillRow[] }>("/admin/settlement-bills");
|
||||
setRows(data.items ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("agents:settlementBills.title", { defaultValue: "代理账单" })}>
|
||||
{loading ? (
|
||||
<AdminLoadingState />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("common:states.noData", { defaultValue: "暂无数据" })}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("agents:settlementBills.columns.id", { defaultValue: "ID" })}</TableHead>
|
||||
<TableHead>{t("agents:settlementBills.columns.type", { defaultValue: "类型" })}</TableHead>
|
||||
<TableHead>{t("agents:settlementBills.columns.net", { defaultValue: "净额" })}</TableHead>
|
||||
<TableHead>{t("agents:settlementBills.columns.unpaid", { defaultValue: "未结" })}</TableHead>
|
||||
<TableHead>{t("agents:settlementBills.columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.id}</TableCell>
|
||||
<TableCell>{row.bill_type}</TableCell>
|
||||
<TableCell>{row.net_amount}</TableCell>
|
||||
<TableCell>{row.unpaid_amount}</TableCell>
|
||||
<TableCell>{row.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
);
|
||||
return <SettlementCenterShell />;
|
||||
}
|
||||
|
||||
307
src/modules/settlement/agent-periods-console.tsx
Normal file
307
src/modules/settlement/agent-periods-console.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSettlementPeriods,
|
||||
postSettlementPeriod,
|
||||
postSettlementPeriodClose,
|
||||
type SettlementPeriodCloseResult,
|
||||
type SettlementPeriodRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
defaultSettlementPeriodPreset,
|
||||
formatSettlementPeriodSpan,
|
||||
settlementPeriodPresetRange,
|
||||
type SettlementPeriodPresetKey,
|
||||
} from "@/lib/agent-settlement-period-range";
|
||||
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentPeriodsConsoleProps = {
|
||||
adminSiteId: number;
|
||||
canManagePeriods: boolean;
|
||||
settlementCycle?: string | null;
|
||||
siteCurrencyCode?: string;
|
||||
/** 嵌入结算中心主区时不重复外层卡片标题 */
|
||||
embedded?: boolean;
|
||||
onPeriodsChange?: (periods: SettlementPeriodRow[]) => void;
|
||||
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
|
||||
};
|
||||
|
||||
const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"];
|
||||
|
||||
export function AgentPeriodsConsole({
|
||||
adminSiteId,
|
||||
canManagePeriods,
|
||||
settlementCycle,
|
||||
siteCurrencyCode = "NPR",
|
||||
embedded = false,
|
||||
onPeriodsChange,
|
||||
onPeriodClosed,
|
||||
}: AgentPeriodsConsoleProps): React.ReactElement | null {
|
||||
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
|
||||
const [rows, setRows] = useState<SettlementPeriodRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [periodStart, setPeriodStart] = useState("");
|
||||
const [periodEnd, setPeriodEnd] = useState("");
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const cycle = normalizeAgentSettlementCycle(settlementCycle);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(key: SettlementPeriodPresetKey) => {
|
||||
const range = settlementPeriodPresetRange(key);
|
||||
setPeriodStart(range.period_start);
|
||||
setPeriodEnd(range.period_end);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onPeriodsChangeRef = useRef(onPeriodsChange);
|
||||
const onPeriodClosedRef = useRef(onPeriodClosed);
|
||||
onPeriodsChangeRef.current = onPeriodsChange;
|
||||
onPeriodClosedRef.current = onPeriodClosed;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
|
||||
const items = data.items ?? [];
|
||||
setRows(items);
|
||||
onPeriodsChangeRef.current?.(items);
|
||||
} catch {
|
||||
setRows([]);
|
||||
setLoadError(true);
|
||||
onPeriodsChangeRef.current?.([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManagePeriods || periodStart !== "" || periodEnd !== "") {
|
||||
return;
|
||||
}
|
||||
applyPreset(defaultSettlementPeriodPreset(cycle));
|
||||
}, [applyPreset, canManagePeriods, cycle, periodEnd, periodStart]);
|
||||
|
||||
async function openPeriod(): Promise<void> {
|
||||
if (!periodStart || !periodEnd) {
|
||||
toast.error(t("settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await postSettlementPeriod({
|
||||
admin_site_id: adminSiteId,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
});
|
||||
toast.success(t("settlementPeriods.opened", { defaultValue: "账期已开启" }));
|
||||
await load();
|
||||
} catch {
|
||||
toast.error(t("settlementPeriods.openFailed", { defaultValue: "开期失败" }));
|
||||
}
|
||||
}
|
||||
|
||||
async function closePeriod(id: number): Promise<void> {
|
||||
try {
|
||||
const result = await postSettlementPeriodClose(id);
|
||||
await load();
|
||||
onPeriodClosedRef.current?.(result);
|
||||
} catch {
|
||||
toast.error(t("settlementPeriods.closeFailed", { defaultValue: "关账失败" }));
|
||||
}
|
||||
}
|
||||
|
||||
const presetLabel = (key: SettlementPeriodPresetKey): string => {
|
||||
switch (key) {
|
||||
case "this_week":
|
||||
return t("settlementPeriods.presetThisWeek", { defaultValue: "本周" });
|
||||
case "last_week":
|
||||
return t("settlementPeriods.presetLastWeek", { defaultValue: "上周" });
|
||||
case "this_month":
|
||||
return t("settlementPeriods.presetThisMonth", { defaultValue: "本月" });
|
||||
}
|
||||
};
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{canManagePeriods ? (
|
||||
<div className={embedded ? "space-y-4" : "mb-4 space-y-3"}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_KEYS.map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => applyPreset(key)}
|
||||
>
|
||||
{presetLabel(key)}
|
||||
</Button>
|
||||
))}
|
||||
<Button type="button" size="sm" onClick={() => void openPeriod()}>
|
||||
{t("settlementPeriods.openWithPreset", { defaultValue: "按上方时间开期" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
>
|
||||
{advancedOpen
|
||||
? t("settlementPeriods.hideAdvanced", { defaultValue: "收起自定义时间" })
|
||||
: t("settlementPeriods.showAdvanced", { defaultValue: "自定义起止时间" })}
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="flex flex-wrap items-end gap-3 pt-1">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementPeriods.start", { defaultValue: "开始" })}</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={periodStart}
|
||||
onChange={(e) => setPeriodStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementPeriods.end", { defaultValue: "结束" })}</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => void openPeriod()}>
|
||||
{t("settlementPeriods.open", { defaultValue: "开期" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<AdminLoadingState />
|
||||
) : loadError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{t("settlementPeriods.loadFailed", { defaultValue: "账期列表加载失败,请稍后重试。" })}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<AdminNoResourceState />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("settlementPeriods.range", { defaultValue: "账期" })}</TableHead>
|
||||
<TableHead>{t("settlementPeriods.status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("settlementPeriods.billCounts", { defaultValue: "账单笔数" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("settlementPeriods.pendingConfirm", { defaultValue: "待确认" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("settlementPeriods.awaitingPayment", { defaultValue: "待收付" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("settlementPeriods.totalUnpaid", { defaultValue: "未结合计" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const summary = row.summary;
|
||||
const billCountLabel =
|
||||
summary != null
|
||||
? t("settlementPeriods.billCountsValue", {
|
||||
defaultValue: "玩家 {{player}} · 代理 {{agent}}",
|
||||
player: summary.player_bills,
|
||||
agent: summary.agent_bills,
|
||||
})
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-sm">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
row.status === "open"
|
||||
? "text-amber-700"
|
||||
: row.status === "completed"
|
||||
? "text-emerald-700"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{settlementPeriodStatusLabel(row.status, t)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs text-muted-foreground">
|
||||
{billCountLabel}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{summary?.pending_confirm ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{summary?.awaiting_payment ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{summary != null
|
||||
? formatDashboardMoneyMinor(summary.total_unpaid, siteCurrencyCode)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.status === "open" ? (
|
||||
<Button type="button" size="sm" onClick={() => void closePeriod(row.id)}>
|
||||
{t("settlementPeriods.close", { defaultValue: "关账并生成账单" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("settlementPeriods.manageTitle", { defaultValue: "账期管理" })}>
|
||||
{body}
|
||||
</AdminPageCard>
|
||||
);
|
||||
}
|
||||
72
src/modules/settlement/agent-settlement-period-select.tsx
Normal file
72
src/modules/settlement/agent-settlement-period-select.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
|
||||
export type AgentSettlementPeriodFilter = number | "all";
|
||||
|
||||
type AgentSettlementPeriodSelectProps = {
|
||||
periods: SettlementPeriodRow[];
|
||||
value: AgentSettlementPeriodFilter;
|
||||
onChange: (value: AgentSettlementPeriodFilter) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AgentSettlementPeriodSelect({
|
||||
periods,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: AgentSettlementPeriodSelectProps): React.ReactElement {
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
const sorted = [...periods].sort((a, b) => b.id - a.id);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value === "all" ? "all" : String(value)}
|
||||
onValueChange={(next) => {
|
||||
onChange(next === "all" ? "all" : Number(next));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={className ?? "h-9 w-full max-w-md"}>
|
||||
<SelectValue placeholder={t("settlementBills.periodPlaceholder", { defaultValue: "选择账期" })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("settlementBills.allPeriods", { defaultValue: "全部账期" })}
|
||||
</SelectItem>
|
||||
{sorted.map((row) => (
|
||||
<SelectItem key={row.id} value={String(row.id)}>
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
{" · "}
|
||||
{periodStatusLabel(row.status, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function periodStatusLabel(
|
||||
status: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
if (status === "open") {
|
||||
return t("settlementPeriods.statusOpen", { defaultValue: "进行中" });
|
||||
}
|
||||
if (status === "closed") {
|
||||
return t("settlementPeriods.statusClosed", { defaultValue: "已关账" });
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
286
src/modules/settlement/agent-settlement-report-view.tsx
Normal file
286
src/modules/settlement/agent-settlement-report-view.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatDashboardCreditMajor, formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { AgentSettlementReportType } from "@/api/admin-agent-settlement";
|
||||
|
||||
type AgentSettlementReportViewProps = {
|
||||
reportType: AgentSettlementReportType;
|
||||
data: unknown;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asRows(value: unknown): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((row): row is Record<string, unknown> => row !== null && typeof row === "object");
|
||||
}
|
||||
|
||||
function money(
|
||||
value: unknown,
|
||||
currencyCode: string,
|
||||
): string {
|
||||
return formatDashboardMoneyMinor(Number(value ?? 0), currencyCode);
|
||||
}
|
||||
|
||||
function creditMoney(value: unknown, currencyCode: string): string {
|
||||
return formatDashboardCreditMajor(Number(value ?? 0), currencyCode);
|
||||
}
|
||||
|
||||
export function AgentSettlementReportView({
|
||||
reportType,
|
||||
data,
|
||||
currencyCode,
|
||||
}: AgentSettlementReportViewProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const root = asRecord(data);
|
||||
|
||||
if (reportType === "summary" && root) {
|
||||
const stats = [
|
||||
{ label: t("settlementReports.summary.billCount", { defaultValue: "账单数" }), value: String(root.bill_count ?? 0) },
|
||||
{ label: t("settlementReports.summary.totalNet", { defaultValue: "净额合计" }), value: money(root.total_net, currencyCode) },
|
||||
{ label: t("settlementReports.summary.totalUnpaid", { defaultValue: "未结合计" }), value: money(root.total_unpaid, currencyCode) },
|
||||
{ label: t("settlementReports.summary.overdueCount", { defaultValue: "逾期账单" }), value: String(root.overdue_count ?? 0) },
|
||||
{
|
||||
label: t("settlementReports.summary.platformRounding", { defaultValue: "平台尾差合计" }),
|
||||
value: money(root.platform_rounding_total, currencyCode),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{stats.map((item) => (
|
||||
<div key={item.label} className="rounded-md border border-border/60 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold tabular-nums">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reportType === "rebate" && root) {
|
||||
const byType = asRows(root.by_type);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
["accrued_total", t("settlementReports.rebate.accrued", { defaultValue: "应计" })],
|
||||
["in_bill_total", t("settlementReports.rebate.inBill", { defaultValue: "已入账单" })],
|
||||
["settled_total", t("settlementReports.rebate.settled", { defaultValue: "已结算" })],
|
||||
["allocated_total", t("settlementReports.rebate.allocated", { defaultValue: "已分摊" })],
|
||||
].map(([key, label]) => (
|
||||
<div key={key} className="rounded-md border border-border/60 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold tabular-nums">{money(root[key], currencyCode)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{byType.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("settlementReports.columns.rebateType", { defaultValue: "类型" })}</TableHead>
|
||||
<TableHead>{t("settlementReports.columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="text-right">{t("settlementReports.columns.amount", { defaultValue: "金额" })}</TableHead>
|
||||
<TableHead className="text-right">{t("settlementReports.columns.count", { defaultValue: "笔数" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{byType.map((row, idx) => (
|
||||
<TableRow key={`${row.rebate_type}-${row.status}-${idx}`}>
|
||||
<TableCell>{String(row.rebate_type ?? "")}</TableCell>
|
||||
<TableCell>{String(row.status ?? "")}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{money(row.total, currencyCode)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{String(row.count ?? 0)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reportType === "credit" && root) {
|
||||
const agents = asRows(root.agents);
|
||||
const players = asRows(root.players);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">{t("settlementReports.credit.agents", { defaultValue: "代理授信" })}</p>
|
||||
<ReportTable
|
||||
rows={agents}
|
||||
columns={[
|
||||
{ key: "code", header: t("settlementReports.columns.code", { defaultValue: "编码" }) },
|
||||
{ key: "name", header: t("settlementReports.columns.name", { defaultValue: "名称" }) },
|
||||
{ key: "credit_limit", header: t("settlementReports.columns.creditLimit", { defaultValue: "授信" }), creditMajor: true },
|
||||
{ key: "allocated_credit", header: t("settlementReports.columns.allocated", { defaultValue: "已下发" }), creditMajor: true },
|
||||
{ key: "available_credit", header: t("settlementReports.columns.available", { defaultValue: "可用" }), creditMajor: true },
|
||||
]}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">{t("settlementReports.credit.players", { defaultValue: "玩家授信" })}</p>
|
||||
<ReportTable
|
||||
rows={players}
|
||||
columns={[
|
||||
{ key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) },
|
||||
{ key: "credit_limit", header: t("settlementReports.columns.creditLimit", { defaultValue: "授信" }), creditMajor: true },
|
||||
{ key: "used_credit", header: t("settlementReports.columns.used", { defaultValue: "已用" }), creditMajor: true },
|
||||
{ key: "frozen_credit", header: t("settlementReports.columns.frozen", { defaultValue: "冻结" }), creditMajor: true },
|
||||
{ key: "available_credit", header: t("settlementReports.columns.available", { defaultValue: "可用" }), creditMajor: true },
|
||||
]}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reportType === "platform_pnl" && root) {
|
||||
if (root.error) {
|
||||
return (
|
||||
<p className="text-sm text-amber-800">
|
||||
{t("settlementReports.platformPnl.periodRequired", {
|
||||
defaultValue: "请选择具体账期后查看平台盈亏(需 settlement_period_id)。",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const stats = [
|
||||
{ label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }), value: money(root.platform_bill_net, currencyCode) },
|
||||
{
|
||||
label: t("settlementReports.platformPnl.rounding", { defaultValue: "尾差调整" }),
|
||||
value: money(root.platform_rounding_adjustment, currencyCode),
|
||||
},
|
||||
{
|
||||
label: t("settlementReports.platformPnl.shareProfit", { defaultValue: "占成利润(元数据)" }),
|
||||
value: money(root.share_profit_meta, currencyCode),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{stats.map((item) => (
|
||||
<div key={item.label} className="rounded-md border border-border/60 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold tabular-nums">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = asRows(root?.items ?? (reportType === "player_win_loss" || reportType === "agent_share" || reportType === "unpaid_bills" || reportType === "overdue" || reportType === "draw_period" ? data : null));
|
||||
|
||||
const columnSets: Record<string, { key: string; header: string; money?: boolean }[]> = {
|
||||
player_win_loss: [
|
||||
{ key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) },
|
||||
{ key: "game_type", header: t("settlementReports.columns.gameType", { defaultValue: "玩法" }) },
|
||||
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
|
||||
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
|
||||
],
|
||||
agent_share: [
|
||||
{ key: "agent_node_id", header: t("settlementReports.columns.agentId", { defaultValue: "代理 ID" }) },
|
||||
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
|
||||
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
|
||||
{ key: "entry_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
|
||||
],
|
||||
unpaid_bills: [
|
||||
{ key: "bill_id", header: t("settlementReports.columns.billId", { defaultValue: "账单" }) },
|
||||
{ key: "bill_type", header: t("settlementReports.columns.billType", { defaultValue: "类型" }) },
|
||||
{ key: "unpaid_amount", header: t("settlementReports.columns.unpaid", { defaultValue: "未结" }), money: true },
|
||||
{ key: "status", header: t("settlementReports.columns.status", { defaultValue: "状态" }) },
|
||||
],
|
||||
overdue: [
|
||||
{ key: "bill_id", header: t("settlementReports.columns.billId", { defaultValue: "账单" }) },
|
||||
{ key: "overdue_days", header: t("settlementReports.columns.overdueDays", { defaultValue: "逾期天数" }) },
|
||||
{ key: "unpaid_amount", header: t("settlementReports.columns.unpaid", { defaultValue: "未结" }), money: true },
|
||||
],
|
||||
draw_period: [
|
||||
{ key: "draw_no", header: t("settlementReports.columns.drawNo", { defaultValue: "期号" }) },
|
||||
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
|
||||
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
|
||||
{ key: "ticket_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
|
||||
],
|
||||
};
|
||||
|
||||
const columns = columnSets[reportType];
|
||||
if (!columns) {
|
||||
return (
|
||||
<AdminNoResourceState className="text-sm text-muted-foreground" />
|
||||
);
|
||||
}
|
||||
|
||||
return <ReportTable rows={items} columns={columns} currencyCode={currencyCode} />;
|
||||
}
|
||||
|
||||
function ReportTable({
|
||||
rows,
|
||||
columns,
|
||||
currencyCode,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: { key: string; header: string; money?: boolean; creditMajor?: boolean }[];
|
||||
currencyCode: string;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <AdminNoResourceState className="text-sm text-muted-foreground" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell max-h-96 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={col.money || col.creditMajor ? "text-right" : undefined}
|
||||
>
|
||||
{col.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={col.money || col.creditMajor ? "text-right tabular-nums" : undefined}
|
||||
>
|
||||
{col.creditMajor
|
||||
? creditMoney(row[col.key], currencyCode)
|
||||
: col.money
|
||||
? money(row[col.key], currencyCode)
|
||||
: String(row[col.key] ?? "—")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/modules/settlement/agent-settlement-reports-panel.tsx
Normal file
115
src/modules/settlement/agent-settlement-reports-panel.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
getAgentSettlementReport,
|
||||
type AgentSettlementReportResponse,
|
||||
type AgentSettlementReportType,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AgentSettlementReportView } from "@/modules/settlement/agent-settlement-report-view";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const REPORT_TYPES: AgentSettlementReportType[] = [
|
||||
"summary",
|
||||
"player_win_loss",
|
||||
"agent_share",
|
||||
"rebate",
|
||||
"credit",
|
||||
"unpaid_bills",
|
||||
"overdue",
|
||||
"platform_pnl",
|
||||
"draw_period",
|
||||
];
|
||||
|
||||
type AgentSettlementReportsPanelProps = {
|
||||
adminSiteId: number;
|
||||
settlementPeriodId: number | null;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
export function AgentSettlementReportsPanel({
|
||||
adminSiteId,
|
||||
settlementPeriodId,
|
||||
currencyCode,
|
||||
}: AgentSettlementReportsPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [reportType, setReportType] = useState<AgentSettlementReportType>("summary");
|
||||
const [response, setResponse] = useState<AgentSettlementReportResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAgentSettlementReport({
|
||||
type: reportType,
|
||||
settlement_period_id: settlementPeriodId ?? undefined,
|
||||
admin_site_id: adminSiteId,
|
||||
});
|
||||
setResponse(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, reportType, settlementPeriodId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border/60 p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementReports.type", { defaultValue: "报表类型" })}</Label>
|
||||
<Select
|
||||
value={reportType}
|
||||
onValueChange={(v) => setReportType(v as AgentSettlementReportType)}
|
||||
>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_TYPES.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{t(`settlementReports.types.${key}`, { defaultValue: key })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{settlementPeriodId === null ? (
|
||||
<p className="text-xs text-muted-foreground pb-1">
|
||||
{t("settlementReports.noPeriodHint", {
|
||||
defaultValue: "未选具体账期时使用近 7 日区间;平台盈亏需选择账期。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementReports.footnote", {
|
||||
defaultValue: "本组报表为信用占成盘账期口径,与「佣金/回水」旧钱包报表不同。",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<AdminLoadingState minHeight="8rem" />
|
||||
) : response ? (
|
||||
<AgentSettlementReportView
|
||||
reportType={reportType}
|
||||
data={response.data}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/modules/settlement/settlement-adjustments-table.tsx
Normal file
92
src/modules/settlement/settlement-adjustments-table.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementAdjustmentRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type SettlementAdjustmentsTableProps = {
|
||||
rows: SettlementAdjustmentRow[];
|
||||
loading: boolean;
|
||||
currencyCode: string;
|
||||
onOpenBill: (billId: number) => void;
|
||||
};
|
||||
|
||||
export function SettlementAdjustmentsTable({
|
||||
rows,
|
||||
loading,
|
||||
currencyCode,
|
||||
onOpenBill,
|
||||
}: SettlementAdjustmentsTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "common"]);
|
||||
|
||||
if (loading) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
|
||||
<TableHead>{t("columns.adjustmentType", { defaultValue: "调账类型" })}</TableHead>
|
||||
<TableHead>{t("columns.originalBill", { defaultValue: "原账单" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.amount", { defaultValue: "调整金额" })}</TableHead>
|
||||
<TableHead>{t("columns.reason", { defaultValue: "原因" })}</TableHead>
|
||||
<TableHead>{t("columns.time", { defaultValue: "时间" })}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(`adjustmentType.${row.adjustment_type}`, {
|
||||
defaultValue: row.adjustment_type,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{row.original_bill_id != null ? `#${row.original_bill_id}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatDashboardMoneyMinor(row.amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-sm">{row.reason ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.created_at ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{row.original_bill_id != null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-primary underline"
|
||||
onClick={() => onOpenBill(row.original_bill_id!)}
|
||||
>
|
||||
{t("actions.viewBill", { defaultValue: "查看原账单" })}
|
||||
</button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/modules/settlement/settlement-bad-debt-table.tsx
Normal file
88
src/modules/settlement/settlement-bad-debt-table.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementAdjustmentRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
|
||||
type SettlementBadDebtTableProps = {
|
||||
rows: SettlementAdjustmentRow[];
|
||||
loading: boolean;
|
||||
currencyCode: string;
|
||||
onOpenBill: (billId: number) => void;
|
||||
};
|
||||
|
||||
export function SettlementBadDebtTable({
|
||||
rows,
|
||||
loading,
|
||||
currencyCode,
|
||||
onOpenBill,
|
||||
}: SettlementBadDebtTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "common"]);
|
||||
|
||||
if (loading) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-x-auto rounded-lg border border-border/60">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
|
||||
<TableHead>{t("columns.originalBill", { defaultValue: "原账单" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.badDebtAmount", { defaultValue: "核销金额" })}</TableHead>
|
||||
<TableHead>{t("columns.reason", { defaultValue: "原因" })}</TableHead>
|
||||
<TableHead className="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{row.period_start && row.period_end
|
||||
? formatSettlementPeriodSpan(row.period_start, row.period_end)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">#{row.original_bill_id ?? "—"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatDashboardMoneyMinor(row.amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-sm text-muted-foreground">
|
||||
{row.reason?.trim() || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.original_bill_id != null ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onOpenBill(row.original_bill_id!)}
|
||||
>
|
||||
{t("actions.viewBill", { defaultValue: "查看账单" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
postAdminRejectSettlementBatch,
|
||||
} from "@/api/admin-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
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";
|
||||
@@ -388,14 +389,10 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
) : loading ? (
|
||||
<AdminLoadingInline label={t("loadingDetails")} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{loading ? (
|
||||
<AdminLoadingInline label={t("loadingDetails")} />
|
||||
) : (
|
||||
t("states.noData", { ns: "common" })
|
||||
)}
|
||||
</p>
|
||||
<AdminNoResourceState className="py-6" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
140
src/modules/settlement/settlement-bills-panel.tsx
Normal file
140
src/modules/settlement/settlement-bills-panel.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSettlementBills,
|
||||
type SettlementBillListScope,
|
||||
type SettlementBillRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export type BillCategory = "all" | "player" | "agent" | "pending_confirm" | "awaiting_payment";
|
||||
|
||||
const CATEGORY_OPTIONS: { value: BillCategory; labelKey: string }[] = [
|
||||
{ value: "all", labelKey: "billsPanel.category.all" },
|
||||
{ value: "player", labelKey: "billsPanel.category.player" },
|
||||
{ value: "agent", labelKey: "billsPanel.category.agent" },
|
||||
{ value: "pending_confirm", labelKey: "billsPanel.category.pendingConfirm" },
|
||||
{ value: "awaiting_payment", labelKey: "billsPanel.category.awaitingPayment" },
|
||||
];
|
||||
|
||||
function categoryQuery(category: BillCategory): {
|
||||
bill_type?: string;
|
||||
scope?: SettlementBillListScope;
|
||||
} {
|
||||
switch (category) {
|
||||
case "player":
|
||||
return { bill_type: "player" };
|
||||
case "agent":
|
||||
return { bill_type: "agent" };
|
||||
case "pending_confirm":
|
||||
return { scope: "pending_confirm" };
|
||||
case "awaiting_payment":
|
||||
return { scope: "awaiting_payment" };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type SettlementBillsPanelProps = {
|
||||
adminSiteId: number;
|
||||
periodFilter: AgentSettlementPeriodFilter;
|
||||
currencyCode: string;
|
||||
onOpenDetail: (billId: number) => void;
|
||||
initialCategory?: BillCategory;
|
||||
refreshKey?: number;
|
||||
};
|
||||
|
||||
export function SettlementBillsPanel({
|
||||
adminSiteId,
|
||||
periodFilter,
|
||||
currencyCode,
|
||||
onOpenDetail,
|
||||
initialCategory = "all",
|
||||
refreshKey = 0,
|
||||
}: SettlementBillsPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
const [category, setCategory] = useState<BillCategory>(initialCategory);
|
||||
|
||||
useEffect(() => {
|
||||
setCategory(initialCategory);
|
||||
}, [initialCategory]);
|
||||
const [rows, setRows] = useState<SettlementBillRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const periodId = periodFilter === "all" ? undefined : periodFilter;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const q = categoryQuery(category);
|
||||
const data = await getSettlementBills({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: periodId,
|
||||
bill_type: q.bill_type,
|
||||
scope: q.scope,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
} catch (err: unknown) {
|
||||
setRows([]);
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("errors.loadBills", { defaultValue: "账单加载失败" }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, category, periodId, t]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [load, refreshKey]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("billsPanel.intro", {
|
||||
defaultValue: "关账后生成的占成账单;可按类型与状态筛选,行内打开详情进行确认与收付。",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 border-b border-border/60 pb-3">
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setCategory(opt.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
category === opt.value
|
||||
? "border-primary/40 bg-primary/10 text-foreground"
|
||||
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(opt.labelKey, { defaultValue: opt.value })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<AdminLoadingState />
|
||||
) : (
|
||||
<SettlementBillsTable
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
currencyCode={currencyCode}
|
||||
onOpenDetail={onOpenDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/modules/settlement/settlement-bills-table.tsx
Normal file
119
src/modules/settlement/settlement-bills-table.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import {
|
||||
settlementBillStatusLabel,
|
||||
settlementBillTypeLabel,
|
||||
} from "@/modules/settlement/settlement-status-label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type SettlementBillsTableProps = {
|
||||
rows: SettlementBillRow[];
|
||||
loading: boolean;
|
||||
currencyCode: string;
|
||||
onOpenDetail: (billId: number) => void;
|
||||
};
|
||||
|
||||
export function SettlementBillsTable({
|
||||
rows,
|
||||
loading,
|
||||
currencyCode,
|
||||
onOpenDetail,
|
||||
}: SettlementBillsTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
|
||||
if (loading) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
|
||||
<TableHead>{t("columns.type", { defaultValue: "类型" })}</TableHead>
|
||||
<TableHead>{t("columns.owner", { defaultValue: "本方" })}</TableHead>
|
||||
<TableHead>{t("columns.counterparty", { defaultValue: "对方" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.gross", { defaultValue: "输赢" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.net", { defaultValue: "净额" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.paid", { defaultValue: "已收付" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.unpaid", { defaultValue: "未结" })}</TableHead>
|
||||
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
</TableCell>
|
||||
<TableCell>{settlementBillTypeLabel(row.bill_type, t)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span>{row.owner_label ?? `${row.owner_type}#${row.owner_id}`}</span>
|
||||
{row.owner_type === "player" && row.owner_funding_mode ? (
|
||||
<PlayerFundingModeBadge
|
||||
row={{
|
||||
funding_mode: row.owner_funding_mode,
|
||||
uses_credit: row.owner_funding_mode === "credit",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.counterparty_label === "platform"
|
||||
? t("agents:settlementBills.platform", { defaultValue: "平台" })
|
||||
: row.counterparty_label ?? `${row.counterparty_type}#${row.counterparty_id}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{row.gross_win_loss != null
|
||||
? formatDashboardMoneyMinor(row.gross_win_loss, currencyCode)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatDashboardMoneyMinor(row.net_amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell>{settlementBillStatusLabel(row.status, t)}</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-primary underline"
|
||||
onClick={() => onOpenDetail(row.id)}
|
||||
>
|
||||
{t("actions.detail", { defaultValue: "详情 / 收付" })}
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/modules/settlement/settlement-center-console.tsx
Normal file
8
src/modules/settlement/settlement-center-console.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SettlementCenterShell } from "@/modules/settlement/settlement-center-shell";
|
||||
|
||||
/** @deprecated 使用 SettlementCenterShell */
|
||||
export function SettlementCenterConsole(): React.ReactElement {
|
||||
return <SettlementCenterShell />;
|
||||
}
|
||||
101
src/modules/settlement/settlement-center-nav.tsx
Normal file
101
src/modules/settlement/settlement-center-nav.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SettlementCenterSection =
|
||||
| "overview"
|
||||
| "periods"
|
||||
| "ledger"
|
||||
| "bills";
|
||||
|
||||
type TabDef = {
|
||||
key: SettlementCenterSection;
|
||||
labelKey: string;
|
||||
defaultLabel: string;
|
||||
group: "hub" | "finance";
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
type SettlementCenterNavProps = {
|
||||
active: SettlementCenterSection;
|
||||
onChange: (section: SettlementCenterSection) => void;
|
||||
counts: {
|
||||
pendingConfirm: number;
|
||||
awaitingPayment: number;
|
||||
};
|
||||
siteSelector?: React.ReactNode;
|
||||
};
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ key: "overview", labelKey: "nav.overview", defaultLabel: "概览", group: "hub" },
|
||||
{ key: "periods", labelKey: "nav.periods", defaultLabel: "账期管理", group: "hub" },
|
||||
{ key: "ledger", labelKey: "nav.ledger", defaultLabel: "账务流水", group: "finance" },
|
||||
{
|
||||
key: "bills",
|
||||
labelKey: "nav.bills",
|
||||
defaultLabel: "账单",
|
||||
group: "finance",
|
||||
},
|
||||
];
|
||||
|
||||
export function SettlementCenterNav({
|
||||
active,
|
||||
onChange,
|
||||
counts,
|
||||
siteSelector,
|
||||
}: SettlementCenterNavProps): React.ReactElement {
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
|
||||
const billBadge =
|
||||
counts.pendingConfirm + counts.awaitingPayment > 0
|
||||
? String(counts.pendingConfirm + counts.awaitingPayment)
|
||||
: undefined;
|
||||
|
||||
const hubTabs = TABS.filter((tab) => tab.group === "hub");
|
||||
const financeTabs = TABS.filter((tab) => tab.group === "finance");
|
||||
|
||||
function renderTab(tab: TabDef, showSeparatorBefore: boolean): React.ReactElement {
|
||||
const isActive = active === tab.key;
|
||||
const badge = tab.key === "bills" ? billBadge : tab.badge;
|
||||
|
||||
return (
|
||||
<span key={tab.key} className="inline-flex items-center">
|
||||
{showSeparatorBefore ? (
|
||||
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(tab.key)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey, { defaultValue: tab.defaultLabel })}
|
||||
{badge ? (
|
||||
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-amber-900">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
|
||||
<nav
|
||||
aria-label={t("subnav.label", { defaultValue: "结算中心导航" })}
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1"
|
||||
>
|
||||
{hubTabs.map((tab) => renderTab(tab, false))}
|
||||
{financeTabs.map((tab, index) => renderTab(tab, index === 0))}
|
||||
</nav>
|
||||
{siteSelector ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
439
src/modules/settlement/settlement-center-shell.tsx
Normal file
439
src/modules/settlement/settlement-center-shell.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, CircleDollarSign, ClipboardCheck, Landmark } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
|
||||
import { AgentPeriodsConsole } from "@/modules/settlement/agent-periods-console";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||
import {
|
||||
SettlementCenterNav,
|
||||
type SettlementCenterSection,
|
||||
} from "@/modules/settlement/settlement-center-nav";
|
||||
import {
|
||||
SettlementBillsPanel,
|
||||
type BillCategory,
|
||||
} from "@/modules/settlement/settlement-bills-panel";
|
||||
import { SettlementLedgerPanel } from "@/modules/settlement/settlement-ledger-panel";
|
||||
import { SettlementPeriodToolbar } from "@/modules/settlement/settlement-period-toolbar";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_SETTLEMENT_AGENT_MANAGE } from "@/lib/admin-prd";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
type SiteOption = { id: number; label: string; currency_code: string };
|
||||
|
||||
function pickDefaultPeriodId(periods: SettlementPeriodRow[]): number | "all" {
|
||||
const closed = periods
|
||||
.filter((row) => row.status === "closed" || row.status === "completed")
|
||||
.sort((a, b) => b.id - a.id);
|
||||
if (closed[0]) {
|
||||
return closed[0].id;
|
||||
}
|
||||
const open = periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id);
|
||||
if (open[0]) {
|
||||
return open[0].id;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function sectionTitle(
|
||||
section: SettlementCenterSection,
|
||||
t: ReturnType<typeof useTranslation<["settlementCenter", "agents", "common"]>>["t"],
|
||||
): string {
|
||||
switch (section) {
|
||||
case "overview":
|
||||
return t("panels.overview.title", { defaultValue: "结算概览" });
|
||||
case "periods":
|
||||
return t("nav.periods", { defaultValue: "账期管理" });
|
||||
case "ledger":
|
||||
return t("panels.ledger.title", { defaultValue: "账务流水" });
|
||||
case "bills":
|
||||
return t("panels.bills.title", { defaultValue: "账单" });
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function SettlementCenterShell(): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
|
||||
const canManagePeriods =
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<SettlementCenterSection>("overview");
|
||||
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
||||
const [periodFilter, setPeriodFilter] = useState<AgentSettlementPeriodFilter>("all");
|
||||
const [periodFilterReady, setPeriodFilterReady] = useState(false);
|
||||
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
||||
const [billsInitialCategory, setBillsInitialCategory] = useState<BillCategory>("all");
|
||||
const [listRevision, setListRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (boundAgent?.admin_site_id) {
|
||||
const label = boundAgent.name
|
||||
? `${boundAgent.name} (${boundAgent.site_code || boundAgent.code})`
|
||||
: boundAgent.code;
|
||||
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
|
||||
setAdminSiteId(boundAgent.admin_site_id);
|
||||
return;
|
||||
}
|
||||
|
||||
void getAdminIntegrationSites().then((sites) => {
|
||||
const options = (sites.items ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: site.name ? `${site.name} (${site.code})` : site.code,
|
||||
currency_code: site.currency_code ?? "NPR",
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
if (adminSiteId === null && options[0]) {
|
||||
setAdminSiteId(options[0].id);
|
||||
}
|
||||
});
|
||||
}, [adminSiteId, boundAgent]);
|
||||
|
||||
const loadPeriods = useCallback(async () => {
|
||||
if (adminSiteId === null) {
|
||||
setPeriods([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
|
||||
setPeriods(data.items ?? []);
|
||||
} catch {
|
||||
setPeriods([]);
|
||||
toast.error(t("periods.loadFailed", { defaultValue: "账期列表加载失败" }));
|
||||
}
|
||||
}, [adminSiteId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManagePeriods || adminSiteId === null) {
|
||||
return;
|
||||
}
|
||||
void loadPeriods();
|
||||
}, [adminSiteId, canManagePeriods, loadPeriods]);
|
||||
|
||||
const handlePeriodsChange = useCallback((items: SettlementPeriodRow[]) => {
|
||||
setPeriods(items);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (periodFilterReady || adminSiteId === null) {
|
||||
return;
|
||||
}
|
||||
setPeriodFilter(periods.length === 0 ? "all" : pickDefaultPeriodId(periods));
|
||||
setPeriodFilterReady(true);
|
||||
}, [adminSiteId, periodFilterReady, periods]);
|
||||
|
||||
const activeCurrency =
|
||||
siteOptions.find((site) => site.id === adminSiteId)?.currency_code ?? "NPR";
|
||||
const openPeriod = useMemo(
|
||||
() => periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id)[0] ?? null,
|
||||
[periods],
|
||||
);
|
||||
const summaryTotals = useMemo(
|
||||
() =>
|
||||
periods.reduce(
|
||||
(acc, row) => {
|
||||
acc.pendingConfirm += row.summary?.pending_confirm ?? 0;
|
||||
acc.awaitingPayment += row.summary?.awaiting_payment ?? 0;
|
||||
acc.totalUnpaid += row.summary?.total_unpaid ?? 0;
|
||||
return acc;
|
||||
},
|
||||
{ pendingConfirm: 0, awaitingPayment: 0, totalUnpaid: 0 },
|
||||
),
|
||||
[periods],
|
||||
);
|
||||
|
||||
const handlePeriodClosed = useCallback(
|
||||
(result?: { unsettled_ticket_count?: number }) => {
|
||||
void loadPeriods();
|
||||
setActiveSection("bills");
|
||||
setBillsInitialCategory("pending_confirm");
|
||||
setListRevision((n) => n + 1);
|
||||
const unsettled = result?.unsettled_ticket_count ?? 0;
|
||||
if (unsettled > 0) {
|
||||
toast.warning(
|
||||
t("toast.periodClosedUnsettled", {
|
||||
defaultValue: "账期已关账;仍有 {{count}} 笔注单未结算,请尽快处理。",
|
||||
count: unsettled,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("toast.periodClosed", { defaultValue: "账期已关账" }));
|
||||
}
|
||||
},
|
||||
[loadPeriods, t],
|
||||
);
|
||||
|
||||
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||
const selectedSiteLabel = siteOptions.find((site) => site.id === selectSiteId)?.label ?? null;
|
||||
const panelTitle = sectionTitle(activeSection, t);
|
||||
const allPeriodsCompleted =
|
||||
periods.length > 0 && periods.every((row) => row.status === "completed");
|
||||
const showPeriodToolbar =
|
||||
(activeSection === "ledger" || activeSection === "bills") && periods.length > 0;
|
||||
|
||||
const selectedPeriod =
|
||||
periodFilter !== "all" ? (periods.find((row) => row.id === periodFilter) ?? null) : openPeriod;
|
||||
const pipelineCounts = selectedPeriod?.pipeline ?? {
|
||||
credit_ledger_count: 0,
|
||||
share_ledger_count: 0,
|
||||
};
|
||||
|
||||
const overviewStats = [
|
||||
{
|
||||
label: t("overview.pendingConfirm", { defaultValue: "待确认" }),
|
||||
value: String(summaryTotals.pendingConfirm),
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
{
|
||||
label: t("overview.awaitingPayment", { defaultValue: "待收付" }),
|
||||
value: String(summaryTotals.awaitingPayment),
|
||||
icon: CircleDollarSign,
|
||||
},
|
||||
{
|
||||
label: t("overview.totalUnpaid", { defaultValue: "未结合计" }),
|
||||
value: formatDashboardMoneyMinor(summaryTotals.totalUnpaid, activeCurrency),
|
||||
icon: Landmark,
|
||||
},
|
||||
{
|
||||
label: t("overview.openPeriod", { defaultValue: "进行中账期" }),
|
||||
value: openPeriod
|
||||
? formatSettlementPeriodSpan(openPeriod.period_start, openPeriod.period_end)
|
||||
: "—",
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
label: t("overview.creditLedger", { defaultValue: "信用流水(账期内)" }),
|
||||
value: String(pipelineCounts.credit_ledger_count),
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
label: t("overview.shareLedger", { defaultValue: "占成流水(账期内)" }),
|
||||
value: String(pipelineCounts.share_ledger_count),
|
||||
icon: CalendarClock,
|
||||
},
|
||||
];
|
||||
|
||||
function renderMainPanel(): React.ReactElement {
|
||||
if (activeSection === "overview") {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("overview.pipelineHint", {
|
||||
defaultValue: "账单须关账后生成;下方为账期内实时流水笔数。",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{overviewStats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<button
|
||||
key={stat.label}
|
||||
type="button"
|
||||
className="rounded-xl border border-border/70 bg-card px-4 py-4 text-left transition-colors hover:border-primary/30 hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
if (stat.label === t("overview.pendingConfirm", { defaultValue: "待确认" })) {
|
||||
setBillsInitialCategory("pending_confirm");
|
||||
setActiveSection("bills");
|
||||
} else if (
|
||||
stat.label === t("overview.awaitingPayment", { defaultValue: "待收付" })
|
||||
) {
|
||||
setBillsInitialCategory("awaiting_payment");
|
||||
setActiveSection("bills");
|
||||
} else if (
|
||||
stat.label === t("overview.creditLedger", { defaultValue: "信用流水(账期内)" })
|
||||
) {
|
||||
setActiveSection("ledger");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-2 text-base font-semibold tabular-nums">{stat.value}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "periods" && adminSiteId !== null) {
|
||||
return (
|
||||
<AgentPeriodsConsole
|
||||
adminSiteId={adminSiteId}
|
||||
canManagePeriods={canManagePeriods}
|
||||
settlementCycle="weekly"
|
||||
siteCurrencyCode={activeCurrency}
|
||||
embedded
|
||||
onPeriodsChange={handlePeriodsChange}
|
||||
onPeriodClosed={handlePeriodClosed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "ledger" && adminSiteId !== null && periodFilterReady) {
|
||||
return (
|
||||
<SettlementLedgerPanel
|
||||
adminSiteId={adminSiteId}
|
||||
periodFilter={periodFilter}
|
||||
currencyCode={activeCurrency}
|
||||
canManage={canManagePeriods}
|
||||
onOpenBill={setDetailBillId}
|
||||
refreshKey={listRevision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "bills" && adminSiteId !== null && periodFilterReady) {
|
||||
return (
|
||||
<SettlementBillsPanel
|
||||
adminSiteId={adminSiteId}
|
||||
periodFilter={periodFilter}
|
||||
currencyCode={activeCurrency}
|
||||
onOpenDetail={setDetailBillId}
|
||||
initialCategory={billsInitialCategory}
|
||||
refreshKey={listRevision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{t("title", { defaultValue: "结算中心" })}
|
||||
</h1>
|
||||
<AdminStatusBadge
|
||||
status={openPeriod ? "processing" : allPeriodsCompleted ? "completed" : "idle"}
|
||||
>
|
||||
{openPeriod
|
||||
? t("header.statusRunning", { defaultValue: "账期进行中" })
|
||||
: allPeriodsCompleted
|
||||
? t("header.statusCompleted", { defaultValue: "账期已结清" })
|
||||
: t("header.statusIdle", { defaultValue: "等待开期" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("header.subtitle", { defaultValue: "信用占成账务" })}
|
||||
</p>
|
||||
</div>
|
||||
{siteOptions.length <= 1 && selectedSiteLabel ? (
|
||||
<p className="text-sm text-muted-foreground">{selectedSiteLabel}</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{adminSiteId === null ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择接入站点。" })}</p>
|
||||
) : (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<SettlementCenterNav
|
||||
active={activeSection}
|
||||
onChange={(section) => {
|
||||
if (section === "bills") {
|
||||
setBillsInitialCategory("all");
|
||||
}
|
||||
setActiveSection(section);
|
||||
}}
|
||||
counts={{
|
||||
pendingConfirm: summaryTotals.pendingConfirm,
|
||||
awaitingPayment: summaryTotals.awaitingPayment,
|
||||
}}
|
||||
siteSelector={
|
||||
siteOptions.length > 1 && selectSiteId !== null ? (
|
||||
<Select
|
||||
value={String(selectSiteId)}
|
||||
onValueChange={(value) => {
|
||||
setAdminSiteId(Number(value));
|
||||
setPeriodFilter("all");
|
||||
setPeriodFilterReady(false);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[220px] bg-background">
|
||||
<SelectValue>{selectedSiteLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{showPeriodToolbar && periodFilterReady ? (
|
||||
<SettlementPeriodToolbar
|
||||
periods={periods}
|
||||
value={periodFilter}
|
||||
onChange={(next) => {
|
||||
setPeriodFilter(next);
|
||||
setPeriodFilterReady(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AdminPageCard title={panelTitle}>{renderMainPanel()}</AdminPageCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("actions.billDetail", { defaultValue: "账单详情 · 确认 / 收付" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailBillId !== null ? (
|
||||
<AgentBillDetail
|
||||
billId={detailBillId}
|
||||
currencyCode={activeCurrency}
|
||||
canManage={canManagePeriods}
|
||||
onUpdated={() => {
|
||||
void loadPeriods();
|
||||
setListRevision((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
src/modules/settlement/settlement-credit-ledger-table.tsx
Normal file
172
src/modules/settlement/settlement-credit-ledger-table.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementLedgerRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { SettlementLedgerRowActions } from "@/modules/settlement/settlement-ledger-row-actions";
|
||||
import {
|
||||
creditLedgerReasonLabel,
|
||||
settlementAdjustmentTypeLabel,
|
||||
settlementBillStatusLabel,
|
||||
} from "@/modules/settlement/settlement-status-label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type SettlementCreditLedgerTableProps = {
|
||||
rows: SettlementLedgerRow[];
|
||||
loading: boolean;
|
||||
currencyCode: string;
|
||||
canManage: boolean;
|
||||
onOpenBill: (billId: number) => void;
|
||||
onRefresh: () => void;
|
||||
showStatusColumn?: boolean;
|
||||
};
|
||||
|
||||
function ledgerBizLabel(
|
||||
row: SettlementLedgerRow,
|
||||
t: ReturnType<typeof useTranslation<["settlementCenter", "agents"]>>["t"],
|
||||
): string {
|
||||
if (row.entry_kind === "payment") {
|
||||
return t("creditLedger.reason.payment_record", { defaultValue: "账单收付" });
|
||||
}
|
||||
if (row.entry_kind === "adjustment") {
|
||||
return settlementAdjustmentTypeLabel(row.biz_type, t);
|
||||
}
|
||||
|
||||
return creditLedgerReasonLabel(row.biz_type, t);
|
||||
}
|
||||
|
||||
function ledgerSourceForBadge(row: SettlementLedgerRow): string | null {
|
||||
if (row.entry_kind === "credit") {
|
||||
return "credit_ledger";
|
||||
}
|
||||
if (row.entry_kind === "payment") {
|
||||
return "wallet_txn";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SettlementCreditLedgerTable({
|
||||
rows,
|
||||
loading,
|
||||
currencyCode,
|
||||
canManage,
|
||||
onOpenBill,
|
||||
onRefresh,
|
||||
showStatusColumn = false,
|
||||
}: SettlementCreditLedgerTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
|
||||
if (loading) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("creditLedger.columns.txn", { defaultValue: "流水号" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.player", { defaultValue: "玩家" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.ref", { defaultValue: "关联" })}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("creditLedger.columns.amount", { defaultValue: "金额" })}
|
||||
</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.channel", { defaultValue: "渠道" })}</TableHead>
|
||||
{showStatusColumn ? (
|
||||
<TableHead>{t("creditLedger.columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
) : null}
|
||||
<TableHead>{t("creditLedger.columns.time", { defaultValue: "时间" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const signed = row.signed_amount ?? (row.direction === 1 ? row.amount : -row.amount);
|
||||
const playerLabel =
|
||||
row.username?.trim() ||
|
||||
row.nickname?.trim() ||
|
||||
row.site_player_id?.trim() ||
|
||||
`#${row.player_id}`;
|
||||
const badgeSource = ledgerSourceForBadge(row);
|
||||
|
||||
return (
|
||||
<TableRow key={row.row_key ?? `${row.entry_kind}-${row.id}`}>
|
||||
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{playerLabel}</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">#{row.player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell>{ledgerBizLabel(row, t)}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.biz_no ?? (row.settlement_bill_id ? `bill#${row.settlement_bill_id}` : "—")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right tabular-nums font-medium ${signed < 0 ? "text-destructive" : "text-emerald-700"}`}
|
||||
>
|
||||
{signed < 0 ? "−" : "+"}
|
||||
{formatDashboardMoneyMinor(Math.abs(signed), row.currency_code || currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{badgeSource ? (
|
||||
<PlayerLedgerSourceBadge ledgerSource={badgeSource} />
|
||||
) : (
|
||||
t("creditLedger.entryKind.adjustment", { defaultValue: "调账流水" })
|
||||
)}
|
||||
</TableCell>
|
||||
{showStatusColumn ? (
|
||||
<TableCell>
|
||||
{row.bill_status ? (
|
||||
<AdminStatusBadge status={row.bill_status}>
|
||||
{settlementBillStatusLabel(row.bill_status, t)}
|
||||
</AdminStatusBadge>
|
||||
) : (
|
||||
<AdminStatusBadge status="posted">
|
||||
{t("ledgerPanel.rowPosted", { defaultValue: "已记账" })}
|
||||
</AdminStatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SettlementLedgerRowActions
|
||||
row={row}
|
||||
canManage={canManage}
|
||||
onOpenBill={onOpenBill}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
378
src/modules/settlement/settlement-ledger-panel.tsx
Normal file
378
src/modules/settlement/settlement-ledger-panel.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getCreditLedger, type SettlementLedgerRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||
import { SettlementCreditLedgerTable } from "@/modules/settlement/settlement-credit-ledger-table";
|
||||
import {
|
||||
creditLedgerReasonLabel,
|
||||
settlementAdjustmentTypeLabel,
|
||||
settlementBillStatusLabel,
|
||||
} from "@/modules/settlement/settlement-status-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export type LedgerCategory =
|
||||
| "all"
|
||||
| "credit"
|
||||
| "payment"
|
||||
| "adjustment"
|
||||
| "bad_debt"
|
||||
| "actionable";
|
||||
|
||||
type LedgerFilters = {
|
||||
txnNo: string;
|
||||
playerAccount: string;
|
||||
playerId: string;
|
||||
bizType: string;
|
||||
billStatus: string;
|
||||
createdFrom: string;
|
||||
createdTo: string;
|
||||
};
|
||||
|
||||
const emptyFilters: LedgerFilters = {
|
||||
txnNo: "",
|
||||
playerAccount: "",
|
||||
playerId: "",
|
||||
bizType: "",
|
||||
billStatus: "",
|
||||
createdFrom: "",
|
||||
createdTo: "",
|
||||
};
|
||||
|
||||
/** 下拉「不限」哨兵;请求时转为空串 */
|
||||
const FILTER_ALL = "__all__";
|
||||
|
||||
function ledgerFilterSelectLabel(
|
||||
raw: unknown,
|
||||
t: ReturnType<typeof useTranslation<"settlementCenter">>["t"],
|
||||
kind: "biz" | "billStatus",
|
||||
): string {
|
||||
const v = raw == null ? "" : String(raw);
|
||||
if (v === "" || v === FILTER_ALL) {
|
||||
return t("ledgerPanel.filterAll", { defaultValue: "不限" });
|
||||
}
|
||||
if (kind === "billStatus") {
|
||||
return settlementBillStatusLabel(v, t);
|
||||
}
|
||||
if (v === "adjustment" || v === "reversal" || v === "bad_debt") {
|
||||
return settlementAdjustmentTypeLabel(v, t);
|
||||
}
|
||||
return creditLedgerReasonLabel(v, t);
|
||||
}
|
||||
|
||||
/** 与流水 biz_type / adjustment_type 一致 */
|
||||
const CREDIT_BIZ_OPTIONS = [
|
||||
"bet_hold",
|
||||
"bet_hold_release",
|
||||
"game_settlement_loss",
|
||||
"settlement_confirm",
|
||||
"payment_record",
|
||||
"adjustment",
|
||||
"reversal",
|
||||
"bad_debt",
|
||||
] as const;
|
||||
|
||||
/** 与 settlement_bills.status 一致 */
|
||||
const BILL_STATUS_OPTIONS = [
|
||||
"pending_confirm",
|
||||
"confirmed",
|
||||
"partial_paid",
|
||||
"settled",
|
||||
"overdue",
|
||||
"reversed",
|
||||
] as const;
|
||||
|
||||
const CATEGORY_OPTIONS: { value: LedgerCategory; labelKey: string }[] = [
|
||||
{ value: "all", labelKey: "ledgerPanel.category.all" },
|
||||
{ value: "credit", labelKey: "ledgerPanel.category.credit" },
|
||||
{ value: "payment", labelKey: "ledgerPanel.category.payment" },
|
||||
{ value: "adjustment", labelKey: "ledgerPanel.category.adjustment" },
|
||||
{ value: "bad_debt", labelKey: "ledgerPanel.category.badDebt" },
|
||||
{ value: "actionable", labelKey: "ledgerPanel.category.actionable" },
|
||||
];
|
||||
|
||||
function categoryQueryParams(category: LedgerCategory): Record<string, string | boolean | undefined> {
|
||||
switch (category) {
|
||||
case "credit":
|
||||
return { entry_kind: "credit" };
|
||||
case "payment":
|
||||
return { entry_kind: "payment" };
|
||||
case "adjustment":
|
||||
return { entry_kind: "adjustment" };
|
||||
case "bad_debt":
|
||||
return { bad_debt_only: true };
|
||||
case "actionable":
|
||||
return { actionable_only: true };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type SettlementLedgerPanelProps = {
|
||||
adminSiteId: number;
|
||||
periodFilter: AgentSettlementPeriodFilter;
|
||||
currencyCode: string;
|
||||
canManage: boolean;
|
||||
onOpenBill: (billId: number) => void;
|
||||
refreshKey?: number;
|
||||
};
|
||||
|
||||
export function SettlementLedgerPanel({
|
||||
adminSiteId,
|
||||
periodFilter,
|
||||
currencyCode,
|
||||
canManage,
|
||||
onOpenBill,
|
||||
refreshKey = 0,
|
||||
}: SettlementLedgerPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "common"]);
|
||||
const [category, setCategory] = useState<LedgerCategory>("all");
|
||||
const [draft, setDraft] = useState<LedgerFilters>(emptyFilters);
|
||||
const [applied, setApplied] = useState<LedgerFilters>(emptyFilters);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [rows, setRows] = useState<SettlementLedgerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const periodId = periodFilter === "all" ? undefined : periodFilter;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const player_id =
|
||||
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
|
||||
const data = await getCreditLedger({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: periodId,
|
||||
page,
|
||||
per_page: perPage,
|
||||
player_id:
|
||||
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
|
||||
? player_id
|
||||
: undefined,
|
||||
txn_no: applied.txnNo.trim() || undefined,
|
||||
player_account: applied.playerAccount.trim() || undefined,
|
||||
reason: applied.bizType.trim() || undefined,
|
||||
bill_status: applied.billStatus.trim() || undefined,
|
||||
created_from: applied.createdFrom.trim() || undefined,
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
...categoryQueryParams(category),
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (err: unknown) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("errors.loadCreditLedger", { defaultValue: "账务流水加载失败" }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, applied, category, page, perPage, periodId, t]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [load, refreshKey]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyFilters);
|
||||
setApplied(emptyFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("creditLedger.intro")}</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-txn">{t("creditLedger.columns.txn", { defaultValue: "流水号" })}</Label>
|
||||
<Input
|
||||
id="sl-txn"
|
||||
placeholder={t("ledgerPanel.search", { defaultValue: "搜索" })}
|
||||
value={draft.txnNo}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-account">{t("ledgerPanel.playerAccount", { defaultValue: "玩家账号" })}</Label>
|
||||
<Input
|
||||
id="sl-account"
|
||||
placeholder={t("ledgerPanel.playerAccountPh", { defaultValue: "用户名 / 站点玩家 ID" })}
|
||||
value={draft.playerAccount}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-player">{t("ledgerPanel.playerId", { defaultValue: "玩家 ID" })}</Label>
|
||||
<Input
|
||||
id="sl-player"
|
||||
inputMode="numeric"
|
||||
placeholder={t("ledgerPanel.optional", { defaultValue: "可选" })}
|
||||
value={draft.playerId}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-biz">{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.bizType === "" ? FILTER_ALL : draft.bizType}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
bizType: v == null || v === FILTER_ALL ? "" : String(v),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sl-biz" className="h-9 w-full">
|
||||
<SelectValue>
|
||||
{(v) => ledgerFilterSelectLabel(v, t, "biz")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_ALL}>
|
||||
{t("ledgerPanel.filterAll", { defaultValue: "不限" })}
|
||||
</SelectItem>
|
||||
{CREDIT_BIZ_OPTIONS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{ledgerFilterSelectLabel(value, t, "biz")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-bill-status">{t("ledgerPanel.billStatus", { defaultValue: "账单状态" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.billStatus === "" ? FILTER_ALL : draft.billStatus}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
billStatus: v == null || v === FILTER_ALL ? "" : String(v),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sl-bill-status" className="h-9 w-full">
|
||||
<SelectValue>
|
||||
{(v) => ledgerFilterSelectLabel(v, t, "billStatus")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_ALL}>
|
||||
{t("ledgerPanel.filterAll", { defaultValue: "不限" })}
|
||||
</SelectItem>
|
||||
{BILL_STATUS_OPTIONS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{ledgerFilterSelectLabel(value, t, "billStatus")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-2">
|
||||
<AdminDateRangeField
|
||||
id="sl-created-range"
|
||||
label={t("ledgerPanel.dateRange", { defaultValue: "时间范围" })}
|
||||
from={draft.createdFrom}
|
||||
to={draft.createdTo}
|
||||
onRangeChange={(r) =>
|
||||
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 border-b border-border/60 pb-3">
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCategory(opt.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
category === opt.value
|
||||
? "border-primary/40 bg-primary/10 text-foreground"
|
||||
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(opt.labelKey, { defaultValue: opt.value })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<AdminLoadingState />
|
||||
) : (
|
||||
<>
|
||||
<SettlementCreditLedgerTable
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
currencyCode={currencyCode}
|
||||
canManage={canManage}
|
||||
onOpenBill={onOpenBill}
|
||||
onRefresh={() => void load()}
|
||||
showStatusColumn
|
||||
/>
|
||||
<AdminListPaginationFooter
|
||||
selectId="settlement-ledger-per-page"
|
||||
total={total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(total / Math.max(1, perPage)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/modules/settlement/settlement-ledger-row-actions.tsx
Normal file
127
src/modules/settlement/settlement-ledger-row-actions.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CircleDollarSign,
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
SlidersHorizontal,
|
||||
TriangleAlert,
|
||||
Undo2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { postSettlementBillConfirm } from "@/api/admin-agent-settlement";
|
||||
import type { SettlementLedgerRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
type SettlementLedgerRowActionsProps = {
|
||||
row: SettlementLedgerRow;
|
||||
canManage: boolean;
|
||||
onOpenBill: (billId: number) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
export function SettlementLedgerRowActions({
|
||||
row,
|
||||
canManage,
|
||||
onOpenBill,
|
||||
onRefresh,
|
||||
}: SettlementLedgerRowActionsProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog, busy } = useConfirmAction();
|
||||
|
||||
const billId = row.settlement_bill_id ?? null;
|
||||
const actions = row.available_actions ?? [];
|
||||
|
||||
const show = (code: string): boolean => actions.includes(code);
|
||||
|
||||
const billAction = (code: string): boolean =>
|
||||
canManage && billId !== null && show(code);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminRowActionsMenu
|
||||
busy={busy}
|
||||
actions={[
|
||||
{
|
||||
key: "view_player",
|
||||
label: t("creditLedger.actions.viewPlayer", { defaultValue: "玩家详情" }),
|
||||
icon: User,
|
||||
href: adminPlayerDetailPath(row.player_id),
|
||||
hidden: !show("view_player"),
|
||||
},
|
||||
{
|
||||
key: "view_bill",
|
||||
label: t("creditLedger.actions.viewBill", { defaultValue: "账单详情" }),
|
||||
icon: Eye,
|
||||
onClick: () => onOpenBill(billId!),
|
||||
hidden: !show("view_bill") || billId === null,
|
||||
},
|
||||
{
|
||||
key: "confirm",
|
||||
label: t("creditLedger.actions.confirm", { defaultValue: "确认账单" }),
|
||||
icon: ClipboardCheck,
|
||||
hidden: !billAction("confirm"),
|
||||
onClick: () =>
|
||||
requestConfirm({
|
||||
title: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
|
||||
description: t("creditLedger.actions.confirmDesc", {
|
||||
defaultValue: "确认后账单进入待收付状态。",
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await postSettlementBillConfirm(billId!);
|
||||
toast.success(
|
||||
t("agents:settlementBills.confirmed", { defaultValue: "已确认" }),
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("common:states.error", { defaultValue: "操作失败" }),
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: t("creditLedger.actions.payment", { defaultValue: "登记收付" }),
|
||||
icon: CircleDollarSign,
|
||||
hidden: !billAction("payment"),
|
||||
onClick: () => onOpenBill(billId!),
|
||||
},
|
||||
{
|
||||
key: "adjustment",
|
||||
label: t("creditLedger.actions.adjustment", { defaultValue: "调账" }),
|
||||
icon: SlidersHorizontal,
|
||||
hidden: !billAction("adjustment"),
|
||||
onClick: () => onOpenBill(billId!),
|
||||
},
|
||||
{
|
||||
key: "reversal",
|
||||
label: t("creditLedger.actions.reversal", { defaultValue: "冲正" }),
|
||||
icon: Undo2,
|
||||
hidden: !billAction("reversal"),
|
||||
onClick: () => onOpenBill(billId!),
|
||||
},
|
||||
{
|
||||
key: "bad_debt",
|
||||
label: t("creditLedger.actions.badDebt", { defaultValue: "坏账核销" }),
|
||||
icon: TriangleAlert,
|
||||
destructive: true,
|
||||
hidden: !billAction("bad_debt"),
|
||||
onClick: () => onOpenBill(billId!),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
src/modules/settlement/settlement-payments-table.tsx
Normal file
99
src/modules/settlement/settlement-payments-table.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementPaymentRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { settlementBillTypeLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type SettlementPaymentsTableProps = {
|
||||
rows: SettlementPaymentRow[];
|
||||
loading: boolean;
|
||||
currencyCode: string;
|
||||
onOpenBill: (billId: number) => void;
|
||||
};
|
||||
|
||||
export function SettlementPaymentsTable({
|
||||
rows,
|
||||
loading,
|
||||
currencyCode,
|
||||
onOpenBill,
|
||||
}: SettlementPaymentsTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
|
||||
if (loading) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
|
||||
<TableHead>{t("columns.billId", { defaultValue: "账单 ID" })}</TableHead>
|
||||
<TableHead>{t("columns.type", { defaultValue: "类型" })}</TableHead>
|
||||
<TableHead>{t("columns.payer", { defaultValue: "付款方" })}</TableHead>
|
||||
<TableHead>{t("columns.payee", { defaultValue: "收款方" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.amount", { defaultValue: "金额" })}</TableHead>
|
||||
<TableHead>{t("columns.method", { defaultValue: "方式" })}</TableHead>
|
||||
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead>{t("columns.time", { defaultValue: "时间" })}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">#{row.settlement_bill_id}</TableCell>
|
||||
<TableCell>{settlementBillTypeLabel(row.bill_type, t)}</TableCell>
|
||||
<TableCell>
|
||||
{row.payer_type}#{row.payer_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.payee_type === "platform"
|
||||
? t("agents:settlementBills.platform", { defaultValue: "平台" })
|
||||
: `${row.payee_type}#${row.payee_id}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">
|
||||
{formatDashboardMoneyMinor(row.amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell>{row.method ?? "—"}</TableCell>
|
||||
<TableCell>{row.status}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.confirmed_at ?? row.created_at ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-primary underline"
|
||||
onClick={() => onOpenBill(row.settlement_bill_id)}
|
||||
>
|
||||
{t("actions.viewBill", { defaultValue: "查看账单" })}
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/modules/settlement/settlement-period-toolbar.tsx
Normal file
51
src/modules/settlement/settlement-period-toolbar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
import {
|
||||
AgentSettlementPeriodSelect,
|
||||
type AgentSettlementPeriodFilter,
|
||||
} from "@/modules/settlement/agent-settlement-period-select";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
|
||||
type SettlementPeriodToolbarProps = {
|
||||
periods: SettlementPeriodRow[];
|
||||
value: AgentSettlementPeriodFilter;
|
||||
onChange: (next: AgentSettlementPeriodFilter) => void;
|
||||
};
|
||||
|
||||
export function SettlementPeriodToolbar({
|
||||
periods,
|
||||
value,
|
||||
onChange,
|
||||
}: SettlementPeriodToolbarProps): React.ReactElement | null {
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
|
||||
if (periods.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected =
|
||||
typeof value === "number" ? periods.find((row) => row.id === value) ?? null : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("filters.period", { defaultValue: "账期范围" })}
|
||||
</span>
|
||||
<AgentSettlementPeriodSelect periods={periods} value={value} onChange={onChange} />
|
||||
{selected ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatSettlementPeriodSpan(selected.period_start, selected.period_end)}
|
||||
{` · ${settlementPeriodStatusLabel(selected.status, t)}`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("filters.allPeriods", { defaultValue: "全部账期" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/modules/settlement/settlement-status-label.ts
Normal file
69
src/modules/settlement/settlement-status-label.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export function settlementBillStatusLabel(
|
||||
status: string,
|
||||
t: TFunction<"settlementCenter">,
|
||||
): string {
|
||||
const key = `billStatus.${status}` as const;
|
||||
return t(key, { defaultValue: status });
|
||||
}
|
||||
|
||||
export function settlementBillTypeLabel(
|
||||
billType: string,
|
||||
t: TFunction<["settlementCenter", "agents"]>,
|
||||
): string {
|
||||
if (billType === "player") {
|
||||
return t("agents:settlementBills.typePlayer", { defaultValue: "玩家账单" });
|
||||
}
|
||||
if (billType === "agent") {
|
||||
return t("agents:settlementBills.typeAgent", { defaultValue: "代理层级账单" });
|
||||
}
|
||||
if (billType === "adjustment") {
|
||||
return t("settlementCenter:billType.adjustment", { defaultValue: "补差单" });
|
||||
}
|
||||
if (billType === "reversal") {
|
||||
return t("settlementCenter:billType.reversal", { defaultValue: "冲正单" });
|
||||
}
|
||||
if (billType === "bad_debt") {
|
||||
return t("settlementCenter:billType.badDebt", { defaultValue: "坏账核销" });
|
||||
}
|
||||
|
||||
return billType;
|
||||
}
|
||||
|
||||
export function settlementPeriodStatusLabel(
|
||||
status: string,
|
||||
t: TFunction<"settlementCenter">,
|
||||
): string {
|
||||
if (status === "open") {
|
||||
return t("filters.statusOpen", { defaultValue: "进行中" });
|
||||
}
|
||||
if (status === "closed") {
|
||||
return t("filters.statusClosed", { defaultValue: "已关账" });
|
||||
}
|
||||
if (status === "completed") {
|
||||
return t("filters.statusCompleted", { defaultValue: "已结清" });
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export function creditLedgerReasonLabel(
|
||||
reason: string,
|
||||
t: TFunction<"settlementCenter">,
|
||||
): string {
|
||||
const key = `creditLedger.reason.${reason}` as const;
|
||||
return t(key, { defaultValue: reason });
|
||||
}
|
||||
|
||||
export function settlementAdjustmentTypeLabel(
|
||||
type: string,
|
||||
t: TFunction<"settlementCenter">,
|
||||
): string {
|
||||
if (type === "bad_debt") {
|
||||
return t("adjustmentType.bad_debt", { defaultValue: "坏账核销" });
|
||||
}
|
||||
|
||||
const key = `adjustmentType.${type}` as const;
|
||||
return t(key, { defaultValue: type });
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
@@ -357,11 +358,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={17} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={17} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={17} />
|
||||
) : (
|
||||
data.items.map((row) => {
|
||||
const winLabel = row.jackpot_win_amount > 0
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
@@ -51,7 +52,9 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
@@ -560,11 +563,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={13} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={13} />
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -637,6 +636,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
|
||||
export function WalletTxnsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const { t: tSettlement } = useTranslation("settlementCenter");
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("walletTransactions");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
@@ -863,6 +863,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
|
||||
@@ -872,13 +873,9 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={11} />
|
||||
<AdminTableLoadingRow colSpan={12} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={12} />
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
@@ -890,7 +887,14 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
|
||||
<TableCell>
|
||||
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-0 text-xs">
|
||||
{row.ledger_source === "credit_ledger"
|
||||
? creditLedgerReasonLabel(row.biz_type, tSettlement)
|
||||
: row.biz_type}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-xs">
|
||||
{row.amount} ({row.direction === 1 ? t("in") : t("out")})
|
||||
</TableCell>
|
||||
@@ -1006,11 +1010,7 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.wallets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
{t("noWalletRows")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<AdminTableNoResourceRow colSpan={4} />
|
||||
) : (
|
||||
result.wallets.map((w) => (
|
||||
<TableRow key={w.id}>
|
||||
|
||||
36
src/modules/wallet/wallet-scope-hint.tsx
Normal file
36
src/modules/wallet/wallet-scope-hint.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
/** 钱包模块仅服务主站钱包玩家;信用盘流水在结算中心。 */
|
||||
export function WalletScopeHint(): React.ReactElement {
|
||||
const { t } = useTranslation("wallet");
|
||||
const profile = useAdminProfile();
|
||||
const canSettlement = adminHasAnyPermission(profile?.permissions, [
|
||||
...PRD_SETTLEMENT_AGENT_ACCESS_ANY,
|
||||
]);
|
||||
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("scopeHint", {
|
||||
defaultValue:
|
||||
"本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的下注占用、结算记账请查看",
|
||||
})}
|
||||
{canSettlement ? (
|
||||
<Link href="/admin/settlement-center" className="mx-1 text-primary underline">
|
||||
{t("scopeHintSettlementLink", { defaultValue: "结算中心 → 信用流水" })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="mx-1 font-medium text-foreground">
|
||||
{t("scopeHintSettlement", { defaultValue: "结算中心 → 信用流水" })}
|
||||
</span>
|
||||
)}
|
||||
。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user