feat(api, agents): add agent node profile retrieval and update functionality
Implemented new API functions to fetch and update agent node profiles, enhancing the management capabilities for agent data. This addition improves the overall functionality of the admin agents console, allowing for better user interaction with agent profiles. Updated related types for improved type safety and clarity in the codebase.
This commit is contained in:
@@ -248,10 +248,10 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<Card>
|
||||
<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")}</CardTitle>
|
||||
<CardTitle>{t("roleListTitle", { defaultValue: "平台角色管理" })}</CardTitle>
|
||||
{canManageRoles ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateRole()}>
|
||||
{t("createRole")}
|
||||
{t("createRole", { defaultValue: "新增平台角色" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -414,15 +414,28 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.slug")}</div>
|
||||
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} disabled={roleMode === "edit"} />
|
||||
<Input
|
||||
value={roleSlug}
|
||||
placeholder={t("roleDialog.slugPlaceholder")}
|
||||
onChange={(e) => setRoleSlug(e.target.value)}
|
||||
disabled={roleMode === "edit"}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.name")}</div>
|
||||
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||
<Input
|
||||
value={roleName}
|
||||
placeholder={t("roleDialog.namePlaceholder")}
|
||||
onChange={(e) => setRoleName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.descriptionLabel")}</div>
|
||||
<Input value={roleDescription} onChange={(e) => setRoleDescription(e.target.value)} />
|
||||
<Input
|
||||
value={roleDescription}
|
||||
placeholder={t("roleDialog.descriptionPlaceholder")}
|
||||
onChange={(e) => setRoleDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -313,17 +313,19 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">
|
||||
{t("listTitle", { defaultValue: "平台账号列表" })}
|
||||
</CardTitle>
|
||||
{canManageUsers ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
{t("createAdmin")}
|
||||
{t("createAdmin", { defaultValue: "新建平台账号" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("modelGuide", {
|
||||
{t("modelGuidePlatform", {
|
||||
defaultValue:
|
||||
"账号层只绑定角色,不直接分配功能权限;具体权限请到“角色管理”维护。",
|
||||
"这里只管理平台账号与平台角色。代理账号请到「代理经营」中创建和维护;账号层只绑定角色,不直接分配功能权限。",
|
||||
})}
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
@@ -545,12 +547,19 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<DialogContent showCloseButton className="max-h-[90vh] max-w-lg gap-4 overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{accountMode === "create" ? t("accountDialog.createTitle") : t("accountDialog.editTitle")}
|
||||
{accountMode === "create"
|
||||
? t("accountDialog.createTitle", { defaultValue: "新建平台账号" })
|
||||
: t("accountDialog.editTitle", { defaultValue: "编辑平台账号" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{accountMode === "create"
|
||||
? t("accountDialog.createDescription")
|
||||
: t("accountDialog.editDescription")}
|
||||
? t("accountDialog.createDescriptionPlatform", {
|
||||
defaultValue:
|
||||
"须为平台账号指定至少一个平台角色。登录账号仅可使用字母、数字、点、下划线与连字符,保存后为小写。",
|
||||
})
|
||||
: t("accountDialog.editDescriptionPlatform", {
|
||||
defaultValue: "这里只编辑平台账号。登录账号不可修改,留空密码表示不修改。",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
@@ -600,7 +609,11 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
{accountMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("accountDialog.rolesDescription")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("accountDialog.rolesDescriptionPlatform", {
|
||||
defaultValue: "这里只能选择平台角色;代理角色请到「代理经营」中分配。",
|
||||
})}
|
||||
</p>
|
||||
<div className="max-h-52 space-y-2 overflow-y-auto rounded-md border p-2.5 sm:grid sm:max-h-56 sm:grid-cols-2 sm:gap-2 sm:space-y-0">
|
||||
{(catalog?.roles ?? []).length === 0 ? (
|
||||
<p className="col-span-full text-xs text-muted-foreground">
|
||||
|
||||
239
src/modules/agents/agent-line-provision-wizard.tsx
Normal file
239
src/modules/agents/agent-line-provision-wizard.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { postAdminAgentLine } from "@/api/admin-agent-lines";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
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 [form, setForm] = useState({
|
||||
code: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
currency_code: "NPR",
|
||||
wallet_api_url: "",
|
||||
notes: "",
|
||||
total_share_rate: "0",
|
||||
credit_limit: "0",
|
||||
rebate_limit: "0",
|
||||
default_player_rebate: "0",
|
||||
settlement_cycle: "weekly" as "daily" | "weekly" | "monthly",
|
||||
can_grant_extra_rebate: false,
|
||||
});
|
||||
|
||||
async function onSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setSecrets(null);
|
||||
try {
|
||||
const result = await postAdminAgentLine({
|
||||
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,
|
||||
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: "线路已开通" }));
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "开通代理线路" })}>
|
||||
<form className="grid max-w-xl gap-4" onSubmit={onSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.code", { defaultValue: "站点 code" })}</Label>
|
||||
<Input
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
||||
required
|
||||
pattern="[a-z0-9][a-z0-9_-]*"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.name", { defaultValue: "线路名称" })}</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.username", { defaultValue: "代理账号" })}</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.password", { defaultValue: "初始密码" })}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
required
|
||||
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: "占成与授信" })}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.totalShareRate", { defaultValue: "占成比例 (%)" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={form.total_share_rate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, total_share_rate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.creditLimit", { defaultValue: "授信额度" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.credit_limit}
|
||||
onChange={(e) => setForm((f) => ({ ...f, credit_limit: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.rebateLimit", { defaultValue: "回水上限" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
value={form.rebate_limit}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
value={form.default_player_rebate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.settlementCycle", { defaultValue: "结算周期" })}</Label>
|
||||
<Select
|
||||
value={form.settlement_cycle}
|
||||
onValueChange={(value) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
settlement_cycle: (value as "daily" | "weekly" | "monthly") ?? "weekly",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">
|
||||
{t("agents:profile.cycleDaily", { defaultValue: "日结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="weekly">
|
||||
{t("agents:profile.cycleWeekly", { defaultValue: "周结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly">
|
||||
{t("agents:profile.cycleMonthly", { defaultValue: "月结" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.can_grant_extra_rebate}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((f) => ({ ...f, can_grant_extra_rebate: checked }))
|
||||
}
|
||||
/>
|
||||
<Label>
|
||||
{t("agents:profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
</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}>
|
||||
{submitting
|
||||
? t("common:submitting", { 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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, KeyRound, Pencil, Plus, Search, Trash2, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteAgentAdminUser,
|
||||
deleteAgentNode,
|
||||
deleteAgentRole,
|
||||
getAgentNodeAdminUsers,
|
||||
getAgentNodeRoles,
|
||||
getAgentNodeProfile,
|
||||
getAgentTree,
|
||||
postAgentAdminUser,
|
||||
postAgentNode,
|
||||
postAgentRole,
|
||||
putAgentNode,
|
||||
putAgentRolePermissions,
|
||||
getAgentDelegationGrants,
|
||||
putAgentDelegationGrants,
|
||||
putAgentNodeProfile,
|
||||
} from "@/api/admin-agents";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { getAdminUserPermissionCatalog } from "@/api/admin-users";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -42,7 +30,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -59,35 +46,26 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
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,
|
||||
PRD_AGENT_MANAGE,
|
||||
PRD_AGENT_ROLE_MANAGE,
|
||||
PRD_AGENT_ROLE_VIEW,
|
||||
PRD_AGENT_USER_MANAGE,
|
||||
PRD_AGENT_USER_VIEW,
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type { AgentDelegationGrantRow, AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import type { AdminPermissionCatalogData, AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
|
||||
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function permissionGroupLabel(key: string, fallback: string, t: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
const translated = t(`adminUsers:permissionGroups.${key}`);
|
||||
return translated === `adminUsers:permissionGroups.${key}` ? fallback : translated;
|
||||
}
|
||||
|
||||
function permissionPackageLabel(
|
||||
key: string,
|
||||
fallback: string,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
return t(`adminUsers:permissionLevels.${key}`, { defaultValue: fallback });
|
||||
}
|
||||
|
||||
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
const out: AgentNodeRow[] = [];
|
||||
const walk = (list: AgentNodeRow[]) => {
|
||||
@@ -103,262 +81,146 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterTree(nodes: AgentNodeRow[], keyword: string): AgentNodeRow[] {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const filterNode = (node: AgentNodeRow): AgentNodeRow | null => {
|
||||
const children = node.children
|
||||
?.map((child) => filterNode(child))
|
||||
.filter((child): child is AgentNodeRow => child !== null) ?? [];
|
||||
const selfMatch =
|
||||
node.name.toLowerCase().includes(normalized) || node.code.toLowerCase().includes(normalized);
|
||||
if (!selfMatch && children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
return nodes.map((node) => filterNode(node)).filter((node): node is AgentNodeRow => node !== null);
|
||||
}
|
||||
|
||||
function AgentTreeNodes({
|
||||
nodes,
|
||||
depth,
|
||||
selectedId,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
}: {
|
||||
nodes: AgentNodeRow[];
|
||||
depth: number;
|
||||
selectedId: number | null;
|
||||
expandedIds: Set<number>;
|
||||
onToggleExpand: (nodeId: number) => void;
|
||||
onSelect: (node: AgentNodeRow) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ul className={depth === 0 ? "space-y-0.5" : "ml-3 border-l border-border pl-2"}>
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md pr-2 transition-colors",
|
||||
selectedId === node.id
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "hover:bg-muted/60 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle children"
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className={cn(
|
||||
"ml-1 rounded-sm p-0.5 transition-colors",
|
||||
selectedId === node.id
|
||||
? "text-primary-foreground/80 hover:bg-primary-foreground/20 hover:text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform",
|
||||
expandedIds.has(node.id) && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="ml-1 size-4 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node)}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-1 rounded-md py-1.5 text-left text-sm",
|
||||
selectedId === node.id ? "font-medium" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className={cn(
|
||||
"ml-auto font-mono text-[11px]",
|
||||
selectedId === node.id ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
{node.code}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{node.children && node.children.length > 0 && expandedIds.has(node.id) ? (
|
||||
<AgentTreeNodes
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
function countBusinessAgents(nodes: AgentNodeRow[]): number {
|
||||
return nodes.filter((node) => !node.is_root).length;
|
||||
}
|
||||
|
||||
export function AgentsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "adminUsers", "common"]);
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
const canManageNode = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_MANAGE]);
|
||||
const canViewRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_VIEW, PRD_AGENT_ROLE_MANAGE]);
|
||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_MANAGE]);
|
||||
const canViewUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_VIEW, PRD_AGENT_USER_MANAGE]);
|
||||
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_MANAGE]);
|
||||
const canViewAgents =
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENTS_ACCESS_ANY]);
|
||||
const canManageProfile = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_AGENT_PROFILE_MANAGE,
|
||||
PRD_AGENT_MANAGE,
|
||||
]);
|
||||
const canProvision =
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]);
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
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 }[]>([]);
|
||||
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 [tree, setTree] = useState<AgentNodeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [treeKeyword, setTreeKeyword] = useState("");
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||
const [users, setUsers] = useState<AdminUserPermissionRow[]>([]);
|
||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [operationsTab, setOperationsTab] = useState<"subordinates" | "players">("subordinates");
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
|
||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||
const [nodeCode, setNodeCode] = useState("");
|
||||
const [targetParentId, setTargetParentId] = useState<number | null>(null);
|
||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||
const [nodeName, setNodeName] = useState("");
|
||||
const [nodeStatus, setNodeStatus] = useState(1);
|
||||
const [nodeUsername, setNodeUsername] = useState("");
|
||||
const [nodePassword, setNodePassword] = useState("");
|
||||
const [nodeSaving, setNodeSaving] = useState(false);
|
||||
const [profileShareRate, setProfileShareRate] = useState("0");
|
||||
const [profileCreditLimit, setProfileCreditLimit] = useState("0");
|
||||
const [profileRebateLimit, setProfileRebateLimit] = useState("0");
|
||||
const [profileDefaultRebate, setProfileDefaultRebate] = useState("0");
|
||||
const [profileSettlementCycle, setProfileSettlementCycle] = useState<
|
||||
"daily" | "weekly" | "monthly"
|
||||
>("weekly");
|
||||
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
|
||||
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
|
||||
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
|
||||
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
||||
const [roleSlug, setRoleSlug] = useState("");
|
||||
const [roleName, setRoleName] = useState("");
|
||||
const [rolePerms, setRolePerms] = useState<string[]>([]);
|
||||
const [roleSaving, setRoleSaving] = useState(false);
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
const canCreateChildAgent =
|
||||
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
|
||||
const canViewPlayersTab =
|
||||
isSuperAdmin ||
|
||||
(boundAgent?.can_create_player !== false &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
||||
|
||||
const [permDialogOpen, setPermDialogOpen] = useState(false);
|
||||
const [permRoleId, setPermRoleId] = useState<number | null>(null);
|
||||
const [draftPerms, setDraftPerms] = useState<string[]>([]);
|
||||
const [permSaving, setPermSaving] = useState(false);
|
||||
const resetProfileForm = (mode: "create" | "edit" = "create") => {
|
||||
setProfileShareRate("0");
|
||||
setProfileCreditLimit("0");
|
||||
setProfileRebateLimit("0");
|
||||
setProfileDefaultRebate("0");
|
||||
setProfileSettlementCycle("weekly");
|
||||
setProfileExtraRebate(false);
|
||||
setProfileCanCreateChild(mode === "create" ? false : false);
|
||||
setProfileCanCreatePlayer(true);
|
||||
};
|
||||
|
||||
const [userDialogOpen, setUserDialogOpen] = useState(false);
|
||||
const [userUsername, setUserUsername] = useState("");
|
||||
const [userNickname, setUserNickname] = useState("");
|
||||
const [userPassword, setUserPassword] = useState("");
|
||||
const [userRoleIds, setUserRoleIds] = useState<number[]>([]);
|
||||
const [userSaving, setUserSaving] = useState(false);
|
||||
|
||||
const [delegationGrants, setDelegationGrants] = useState<AgentDelegationGrantRow[]>([]);
|
||||
const [delegationSaving, setDelegationSaving] = useState(false);
|
||||
const 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,
|
||||
settlement_cycle: profileSettlementCycle,
|
||||
can_grant_extra_rebate: profileExtraRebate,
|
||||
can_create_child_agent: profileCanCreateChild,
|
||||
can_create_player: profileCanCreatePlayer,
|
||||
});
|
||||
|
||||
const flatNodes = useMemo(() => flattenTree(tree), [tree]);
|
||||
const filteredTree = useMemo(() => filterTree(tree, treeKeyword), [tree, treeKeyword]);
|
||||
const selected = useMemo(
|
||||
() => flatNodes.find((n) => n.id === selectedId) ?? null,
|
||||
[flatNodes, selectedId],
|
||||
const parentNameMap = useMemo(
|
||||
() => new Map<number, string>(flatNodes.map((node) => [node.id, node.name])),
|
||||
[flatNodes],
|
||||
);
|
||||
const selectedChildrenCount = selected?.children?.length ?? 0;
|
||||
const selectedDescendantCount = useMemo(() => {
|
||||
if (!selected?.children?.length) {
|
||||
return 0;
|
||||
}
|
||||
return flattenTree(selected.children).length;
|
||||
}, [selected]);
|
||||
|
||||
const canManageDelegation =
|
||||
canManageNode &&
|
||||
selected !== null &&
|
||||
!selected.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === selected.parent_id);
|
||||
const blockingCustomRoleCount = useMemo(
|
||||
() => roles.filter((role) => !role.is_read_only_template).length,
|
||||
[roles],
|
||||
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,
|
||||
[adminSiteId, siteOptions],
|
||||
);
|
||||
const deleteBlockReasons = useMemo(() => {
|
||||
if (!selected || selected.is_root) {
|
||||
return [];
|
||||
const activeSiteCode = useMemo(() => {
|
||||
const fromAgent = boundAgent?.site_code?.trim();
|
||||
if (fromAgent) {
|
||||
return fromAgent;
|
||||
}
|
||||
const reasons: string[] = [];
|
||||
if (selectedChildrenCount > 0) {
|
||||
reasons.push(
|
||||
t("deleteBlocked.children", {
|
||||
count: selectedChildrenCount,
|
||||
defaultValue: "仍有 {{count}} 个下级代理",
|
||||
}),
|
||||
);
|
||||
const fromSite = siteOptions.find((site) => site.id === adminSiteId)?.code?.trim();
|
||||
if (fromSite) {
|
||||
return fromSite;
|
||||
}
|
||||
if (blockingCustomRoleCount > 0) {
|
||||
reasons.push(
|
||||
t("deleteBlocked.roles", {
|
||||
count: blockingCustomRoleCount,
|
||||
defaultValue: "仍有 {{count}} 个可编辑角色需先删除",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (users.length > 0) {
|
||||
reasons.push(
|
||||
t("deleteBlocked.users", {
|
||||
count: users.length,
|
||||
defaultValue: "仍有 {{count}} 个绑定账号",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return reasons;
|
||||
}, [blockingCustomRoleCount, selected, selectedChildrenCount, t, users.length]);
|
||||
const canDeleteSelectedNode =
|
||||
canManageNode && selected !== null && !selected.is_root && deleteBlockReasons.length === 0;
|
||||
const defaultDetailTab = canViewRoles ? "roles" : canViewUsers ? "users" : canManageDelegation ? "delegation" : "roles";
|
||||
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 assignablePermissionSlugs = useMemo(() => {
|
||||
const mine = new Set(profile?.permissions ?? []);
|
||||
const slugs: string[] = [];
|
||||
for (const group of catalog?.permission_menu_groups ?? []) {
|
||||
for (const p of group.permissions) {
|
||||
if (mine.has(p.slug)) {
|
||||
slugs.push(p.slug);
|
||||
}
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
return businessRows.filter((node) => {
|
||||
if (normalized === "") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (slugs.length === 0) {
|
||||
for (const p of catalog?.permissions ?? []) {
|
||||
if (mine.has(p.slug)) {
|
||||
slugs.push(p.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}, [catalog, profile?.permissions]);
|
||||
const parentName =
|
||||
node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : "";
|
||||
|
||||
const selectedRoleCountText = useMemo(
|
||||
() => t("roles.selectedCount", {
|
||||
defaultValue: "已选 {{selected}} / {{total}} 项",
|
||||
selected: rolePerms.length,
|
||||
total: assignablePermissionSlugs.length,
|
||||
}),
|
||||
[assignablePermissionSlugs.length, rolePerms.length, t],
|
||||
);
|
||||
return [node.name, node.code, node.username ?? "", parentName]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalized);
|
||||
});
|
||||
}, [businessRows, keyword, parentNameMap]);
|
||||
|
||||
const selectedDraftCountText = useMemo(
|
||||
() => t("roles.selectedCount", {
|
||||
defaultValue: "已选 {{selected}} / {{total}} 项",
|
||||
selected: draftPerms.length,
|
||||
total: assignablePermissionSlugs.length,
|
||||
}),
|
||||
[assignablePermissionSlugs.length, draftPerms.length, t],
|
||||
);
|
||||
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 loadTree = useCallback(async (siteId?: number | null) => {
|
||||
setLoading(true);
|
||||
@@ -367,131 +229,203 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const data = await getAgentTree(siteId ?? undefined);
|
||||
setTree(data.tree);
|
||||
setAdminSiteId(data.admin_site_id);
|
||||
setExpandedNodeIds(new Set(flattenTree(data.tree).map((node) => node.id)));
|
||||
if (selectedId === null && data.tree.length > 0) {
|
||||
const first = flattenTree(data.tree)[0];
|
||||
if (first) {
|
||||
setSelectedId(first.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setTree([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedId, tRef]);
|
||||
|
||||
const loadDetail = useCallback(async (nodeId: number) => {
|
||||
const needRoleRows = canViewRoles || canManageNode;
|
||||
const needUserRows = canViewUsers || canManageNode;
|
||||
if (needRoleRows) {
|
||||
const roleData = await getAgentNodeRoles(nodeId);
|
||||
setRoles(roleData.items);
|
||||
} else {
|
||||
setRoles([]);
|
||||
}
|
||||
if (needUserRows) {
|
||||
const userData = await getAgentNodeAdminUsers(nodeId);
|
||||
setUsers(userData.items);
|
||||
} else {
|
||||
setUsers([]);
|
||||
}
|
||||
const node = flattenTree(tree).find((n) => n.id === nodeId);
|
||||
const showDelegation =
|
||||
canManageNode &&
|
||||
node !== undefined &&
|
||||
!node.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === node.parent_id);
|
||||
if (showDelegation) {
|
||||
const grantData = await getAgentDelegationGrants(nodeId);
|
||||
setDelegationGrants(grantData.grants);
|
||||
} else {
|
||||
setDelegationGrants([]);
|
||||
}
|
||||
}, [canManageNode, canViewRoles, canViewUsers, isSuperAdmin, profile?.agent?.id, tree]);
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
if (!canViewAgents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canSwitchSite) {
|
||||
void getAdminIntegrationSites()
|
||||
.then((data) => {
|
||||
setSiteOptions(
|
||||
data.items.map((row) => ({ id: row.id, label: `${row.name} (${row.code})` })),
|
||||
);
|
||||
if (data.items.length > 0 && adminSiteId === null) {
|
||||
setAdminSiteId(data.items[0]?.id ?? null);
|
||||
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);
|
||||
}
|
||||
void getAdminUserPermissionCatalog().then(setCatalog).catch(() => setCatalog(null));
|
||||
}, [isSuperAdmin, profile?.agent?.admin_site_id]);
|
||||
}, [adminSiteId, canSwitchSite, canViewAgents, profile?.agent?.admin_site_id]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId === null && !isSuperAdmin && profile?.agent?.admin_site_id) {
|
||||
if (!canSwitchSite || siteOptions.length === 0) {
|
||||
setGlobalVisibleNodeCount(null);
|
||||
setGlobalBusinessAgentCount(null);
|
||||
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));
|
||||
})
|
||||
.catch(() => {
|
||||
setGlobalVisibleNodeCount(null);
|
||||
setGlobalBusinessAgentCount(null);
|
||||
});
|
||||
}, [canSwitchSite, siteOptions]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId === null && !canSwitchSite && profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
return;
|
||||
}
|
||||
if (adminSiteId !== null || !isSuperAdmin) {
|
||||
|
||||
if (adminSiteId !== null || !canSwitchSite) {
|
||||
void loadTree(adminSiteId);
|
||||
}
|
||||
}, [adminSiteId, isSuperAdmin, loadTree, profile?.agent?.admin_site_id]);
|
||||
}, [adminSiteId, canSwitchSite, loadTree, profile?.agent?.admin_site_id]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (selectedId !== null) {
|
||||
void loadDetail(selectedId).catch(() => {
|
||||
toast.error(tRef.current("loadFailed"));
|
||||
});
|
||||
}
|
||||
}, [selectedId, loadDetail, tRef]);
|
||||
|
||||
const openCreateChild = () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const openCreateChildForNode = (node: AgentNodeRow) => {
|
||||
setNodeDialogMode("create");
|
||||
setNodeCode("");
|
||||
setTargetParentId(node.id);
|
||||
setEditingNodeId(null);
|
||||
setNodeName("");
|
||||
setNodeStatus(1);
|
||||
setNodeUsername("");
|
||||
setNodePassword("");
|
||||
resetProfileForm("create");
|
||||
setNodeDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditNode = () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const openEditForNode = (node: AgentNodeRow) => {
|
||||
setNodeDialogMode("edit");
|
||||
setNodeCode(selected.code);
|
||||
setNodeName(selected.name);
|
||||
setNodeStatus(selected.status);
|
||||
setTargetParentId(node.parent_id);
|
||||
setEditingNodeId(node.id);
|
||||
setNodeName(node.name);
|
||||
setNodeStatus(node.status);
|
||||
setNodeUsername(node.username ?? "");
|
||||
setNodePassword("");
|
||||
resetProfileForm("edit");
|
||||
setNodeDialogOpen(true);
|
||||
if (canManageProfile) {
|
||||
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(p.settlement_cycle ?? "weekly");
|
||||
setProfileExtraRebate(Boolean(p.can_grant_extra_rebate));
|
||||
setProfileCanCreateChild(Boolean(p.can_create_child_agent));
|
||||
setProfileCanCreatePlayer(p.can_create_player !== false);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRowActions = (node: AgentNodeRow) => {
|
||||
const rowDeleteBlockedByChildren = (node.children?.length ?? 0) > 0;
|
||||
const rowDeleteBlockedBySelf = profile?.agent?.id === node.id;
|
||||
const rowCanDelete =
|
||||
canManageNode && !rowDeleteBlockedByChildren && !rowDeleteBlockedBySelf;
|
||||
|
||||
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: !canManageNode || !canCreateChildAgent,
|
||||
onClick: () => openCreateChildForNode(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);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const saveNode = async () => {
|
||||
if (!nodeName.trim() || (nodeDialogMode === "create" && !nodeCode.trim())) {
|
||||
toast.error(t("codeRequired"));
|
||||
if (!nodeName.trim() || !nodeUsername.trim()) {
|
||||
toast.error(t("codeRequired", { defaultValue: "请填写代理名称和登录名" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeDialogMode === "create") {
|
||||
if (targetParentId === null) {
|
||||
return;
|
||||
}
|
||||
if (!nodePassword.trim()) {
|
||||
toast.error(t("users.password", { defaultValue: "密码" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setNodeSaving(true);
|
||||
try {
|
||||
if (nodeDialogMode === "create" && selected) {
|
||||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
||||
await postAgentNode({
|
||||
parent_id: selected.id,
|
||||
code: nodeCode.trim(),
|
||||
parent_id: targetParentId,
|
||||
name: nodeName.trim(),
|
||||
username: nodeUsername.trim(),
|
||||
password: nodePassword,
|
||||
status: nodeStatus,
|
||||
...(canManageProfile ? profilePayload() : {}),
|
||||
});
|
||||
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
||||
} else if (selected) {
|
||||
await putAgentNode(selected.id, { name: nodeName.trim(), status: nodeStatus });
|
||||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||||
await putAgentNode(editingNodeId, {
|
||||
name: nodeName.trim(),
|
||||
username: nodeUsername.trim(),
|
||||
password: nodePassword.trim() || undefined,
|
||||
status: nodeStatus,
|
||||
});
|
||||
if (canManageProfile) {
|
||||
await putAgentNodeProfile(editingNodeId, profilePayload());
|
||||
}
|
||||
toast.success(t("updateSuccess", { name: nodeName.trim() }));
|
||||
}
|
||||
|
||||
setNodeDialogOpen(false);
|
||||
await loadTree(adminSiteId);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
@@ -499,629 +433,393 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const saveNewRole = async () => {
|
||||
if (!selected || !roleSlug.trim() || !roleName.trim()) {
|
||||
return;
|
||||
}
|
||||
setRoleSaving(true);
|
||||
try {
|
||||
await postAgentRole(selected.id, {
|
||||
slug: roleSlug.trim(),
|
||||
name: roleName.trim(),
|
||||
permission_slugs: rolePerms,
|
||||
});
|
||||
toast.success(t("roles.createSuccess", { name: roleName.trim() }));
|
||||
setRoleDialogOpen(false);
|
||||
await loadDetail(selected.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setRoleSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRolePermissions = async () => {
|
||||
if (permRoleId === null) {
|
||||
return;
|
||||
}
|
||||
setPermSaving(true);
|
||||
try {
|
||||
const result = await putAgentRolePermissions(permRoleId, draftPerms);
|
||||
setDraftPerms([...result.permission_slugs].sort());
|
||||
setRoles((prev) => prev.map((role) => (role.id === result.id ? result : role)));
|
||||
toast.success(t("roles.permissionSaveSuccess"));
|
||||
setPermDialogOpen(false);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setPermSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDelegation = async () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setDelegationSaving(true);
|
||||
try {
|
||||
const data = await putAgentDelegationGrants(selected.id, {
|
||||
grants: delegationGrants.map((g) => ({
|
||||
menu_action_id: g.menu_action_id,
|
||||
can_delegate: g.can_delegate,
|
||||
})),
|
||||
});
|
||||
setDelegationGrants(data.grants);
|
||||
toast.success(t("delegation.saveSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setDelegationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNewUser = async () => {
|
||||
if (!selected || !userUsername.trim() || !userPassword.trim()) {
|
||||
return;
|
||||
}
|
||||
setUserSaving(true);
|
||||
try {
|
||||
await postAgentAdminUser(selected.id, {
|
||||
username: userUsername.trim(),
|
||||
nickname: userNickname.trim() || userUsername.trim(),
|
||||
password: userPassword,
|
||||
role_ids: userRoleIds,
|
||||
});
|
||||
toast.success(t("users.createSuccess", { name: userNickname.trim() || userUsername.trim() }));
|
||||
setUserDialogOpen(false);
|
||||
await loadDetail(selected.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setUserSaving(false);
|
||||
}
|
||||
};
|
||||
if (!canViewAgents) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noAccess", { defaultValue: "您没有代理经营相关权限,请联系管理员开通。" })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && tree.length === 0) {
|
||||
return <AdminLoadingState label={t("treeTitle")} />;
|
||||
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConfirmDialog />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
{isSuperAdmin && siteOptions.length > 0 ? (
|
||||
<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={(v) => setAdminSiteId(Number(v))}
|
||||
onValueChange={(value) => setAdminSiteId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder={t("siteLabel")}>
|
||||
{adminSiteId !== null
|
||||
? siteOptions.find((opt) => opt.id === adminSiteId)?.label ?? adminSiteId
|
||||
: undefined}
|
||||
<SelectValue placeholder={t("siteLabel", { defaultValue: "站点" })}>
|
||||
{selectedSiteLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
{opt.label}
|
||||
{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="text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,280px)_1fr]">
|
||||
<AdminPageCard title={t("treeTitle")}>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={treeKeyword}
|
||||
onChange={(e) => setTreeKeyword(e.target.value)}
|
||||
className="pl-8"
|
||||
placeholder={t("treeSearch", { defaultValue: "搜索代理编码/名称" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setExpandedNodeIds(new Set(flatNodes.map((node) => node.id)))}
|
||||
>
|
||||
{t("expandAll", { defaultValue: "展开全部" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
selected ? setExpandedNodeIds(new Set([selected.id])) : setExpandedNodeIds(new Set())
|
||||
}
|
||||
>
|
||||
{t("collapseAll", { defaultValue: "收起全部" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="mt-3 h-[min(66vh,620px)] pr-2">
|
||||
<AgentTreeNodes
|
||||
nodes={filteredTree}
|
||||
depth={0}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedNodeIds}
|
||||
onToggleExpand={(nodeId) => {
|
||||
setExpandedNodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSelect={(node) => setSelectedId(node.id)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</AdminPageCard>
|
||||
|
||||
<AdminPageCard title={selected ? selected.name : t("detailTitle")}>
|
||||
{!selected ? (
|
||||
<p className="text-sm text-muted-foreground">{t("selectNode")}</p>
|
||||
) : (
|
||||
<Tabs defaultValue={defaultDetailTab}>
|
||||
<div className="mb-4 grid gap-3 rounded-xl border bg-muted/20 p-3 md:grid-cols-4">
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("status")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(selected.status)}>
|
||||
{selected.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "Enabled" })
|
||||
: t("common:status.disabled", { defaultValue: "Disabled" })}
|
||||
</AdminStatusBadge>
|
||||
{selected.is_root ? (
|
||||
<Badge variant="secondary">{t("isRoot", { defaultValue: "Root" })}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("childrenCount", { defaultValue: "直属下级" })}</p>
|
||||
<p className="text-lg font-semibold">{selectedChildrenCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("descendantsCount", { defaultValue: "全部下级" })}</p>
|
||||
<p className="text-lg font-semibold">{selectedDescendantCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("nodeCode", { defaultValue: "节点编码" })}</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{selected.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<TabsList>
|
||||
{canViewRoles ? <TabsTrigger value="roles">{t("tabs.roles")}</TabsTrigger> : null}
|
||||
{canViewUsers ? <TabsTrigger value="users">{t("tabs.users")}</TabsTrigger> : null}
|
||||
|
||||
</TabsList>
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={openEditNode}>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
{t("editNode")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={!canDeleteSelectedNode}
|
||||
title={
|
||||
canDeleteSelectedNode
|
||||
? undefined
|
||||
: deleteBlockReasons.join(";") ||
|
||||
t("deleteNodeBlockedHint", {
|
||||
defaultValue: "请先删除下级代理、角色与账号后再删除本节点",
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
if (!canDeleteSelectedNode) {
|
||||
return;
|
||||
}
|
||||
requestConfirm({
|
||||
title: t("deleteNode"),
|
||||
description: t("deleteNodeConfirm"),
|
||||
confirmLabel: t("deleteNode"),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: async () => {
|
||||
const deletedId = selected.id;
|
||||
const deletedName = selected.name;
|
||||
const parentId = selected.parent_id;
|
||||
await deleteAgentNode(deletedId);
|
||||
toast.success(t("deleteSuccess", { name: deletedName }));
|
||||
setSelectedId(parentId);
|
||||
await loadTree(adminSiteId);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
{t("deleteNode")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode ? (
|
||||
<Button type="button" size="sm" onClick={openCreateChild}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("createChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{canManageNode && !selected.is_root && deleteBlockReasons.length > 0 ? (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
{t("deleteNodeBlockedPrefix", { defaultValue: "暂不可删除:" })}
|
||||
{deleteBlockReasons.join(";")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canViewRoles ? (
|
||||
<TabsContent value="roles">
|
||||
<div className="mb-3 flex justify-end">
|
||||
{canManageRoles ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRolePerms([]);
|
||||
setRoleDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("roles.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("roles.slug")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("roles.userCount")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-[80px] shadow-[-1px_0_0_rgba(203,213,225,0.7)]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono text-xs">{role.slug}</TableCell>
|
||||
<TableCell>{role.name}</TableCell>
|
||||
<TableCell>{role.user_count}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageRoles && !role.is_read_only_template ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{(role.user_count ?? 0) > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("roles.inUse", {
|
||||
count: role.user_count ?? 0,
|
||||
defaultValue: "{{count}} 人使用中",
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "permissions",
|
||||
label: t("roles.permissions"),
|
||||
icon: KeyRound,
|
||||
onClick: () => {
|
||||
setPermRoleId(role.id);
|
||||
setDraftPerms([...role.permission_slugs]);
|
||||
setPermDialogOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("common:actions.delete", { defaultValue: "Delete" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: (role.user_count ?? 0) > 0,
|
||||
onClick: () => {
|
||||
requestConfirm({
|
||||
title: role.name,
|
||||
description: t("common:confirm.deleteDescription", {
|
||||
defaultValue: "This cannot be undone.",
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
await deleteAgentRole(role.id);
|
||||
toast.success(t("roles.deleteSuccess", { name: role.name }));
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : role.is_read_only_template ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("roles.readOnlyTemplate")}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canViewUsers ? (
|
||||
<TabsContent value="users">
|
||||
<div className="mb-3 flex justify-end">
|
||||
{canManageUsers ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUserUsername("");
|
||||
setUserNickname("");
|
||||
setUserPassword("");
|
||||
setUserRoleIds([]);
|
||||
setUserDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Users className="mr-1 size-3.5" />
|
||||
{t("users.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("users.username")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("users.roles")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-[80px] shadow-[-1px_0_0_rgba(203,213,225,0.7)]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.nickname}</TableCell>
|
||||
<TableCell className="text-xs">{user.roles.join(", ") || "—"}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageUsers ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: t("common:actions.delete", { defaultValue: "Delete" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
requestConfirm({
|
||||
title: user.username,
|
||||
description: t("users.deleteConfirm", {
|
||||
defaultValue: "删除后该管理员将无法登录,且不可恢复。",
|
||||
}),
|
||||
confirmLabel: t("common:actions.delete", {
|
||||
defaultValue: "Delete",
|
||||
}),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: async () => {
|
||||
await deleteAgentAdminUser(user.id);
|
||||
toast.success(
|
||||
t("users.deleteSuccess", { name: user.nickname }),
|
||||
);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
|
||||
</Tabs>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
<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);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</AdminPageCard>
|
||||
|
||||
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{nodeDialogMode === "create" ? t("createChild") : t("editNode")}
|
||||
{nodeDialogMode === "create"
|
||||
? t("createChild", { defaultValue: "添加下级代理" })
|
||||
: t("editNode", { defaultValue: "编辑代理" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{nodeDialogMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-code">{t("code")}</Label>
|
||||
<Input
|
||||
id="agent-code"
|
||||
value={nodeCode}
|
||||
onChange={(e) => setNodeCode(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name">{t("name")}</Label>
|
||||
<Label htmlFor="agent-name">{t("name", { defaultValue: "名称" })}</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={nodeName}
|
||||
placeholder={t("namePlaceholder")}
|
||||
onChange={(e) => setNodeName(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={nodeStatus === 1} onCheckedChange={(v) => setNodeStatus(v ? 1 : 0)} />
|
||||
<Label>{t("status")}</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={nodeSaving} onClick={() => void saveNode()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roles.create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("roles.slug")}</Label>
|
||||
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
<p>{t("roles.permissionSubsetHint")}</p>
|
||||
<p className="mt-2 font-medium text-foreground">{selectedRoleCountText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<AdminPermissionPackageSelector
|
||||
catalog={catalog}
|
||||
selectedSlugs={rolePerms}
|
||||
onChange={setRolePerms}
|
||||
selectableSlugs={assignablePermissionSlugs}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
helperText={t("roles.permissionSubsetHint")}
|
||||
summaryText={selectedRoleCountText}
|
||||
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={roleSaving} onClick={() => void saveNewRole()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roles.permissions")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
{selectedDraftCountText}
|
||||
</div>
|
||||
<AdminPermissionPackageSelector
|
||||
catalog={catalog}
|
||||
selectedSlugs={draftPerms}
|
||||
onChange={setDraftPerms}
|
||||
selectableSlugs={assignablePermissionSlugs}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
helperText={t("roles.permissionSubsetHint")}
|
||||
summaryText={selectedDraftCountText}
|
||||
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setPermDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={permSaving} onClick={() => void saveRolePermissions()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={userDialogOpen} onOpenChange={setUserDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("users.create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.username")}</Label>
|
||||
<Input value={userUsername} onChange={(e) => setUserUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input value={userNickname} onChange={(e) => setUserNickname(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.password")}</Label>
|
||||
<Label htmlFor="agent-username">{t("users.username", { defaultValue: "登录名" })}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={userPassword}
|
||||
onChange={(e) => setUserPassword(e.target.value)}
|
||||
id="agent-username"
|
||||
value={nodeUsername}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
onChange={(e) => setNodeUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{roles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.roles")}</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{roles.map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={userRoleIds.includes(role.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setUserRoleIds((prev) =>
|
||||
checked
|
||||
? [...prev, role.id]
|
||||
: prev.filter((id) => id !== role.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{role.name}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-password">
|
||||
{nodeDialogMode === "create"
|
||||
? t("users.password", { defaultValue: "密码" })
|
||||
: t("resetPassword", { defaultValue: "重置密码" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-password"
|
||||
type="password"
|
||||
value={nodePassword}
|
||||
onChange={(e) => setNodePassword(e.target.value)}
|
||||
placeholder={
|
||||
nodeDialogMode === "edit"
|
||||
? t("passwordOptionalHint")
|
||||
: t("passwordPlaceholder")
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={nodeStatus === 1} onCheckedChange={(value) => setNodeStatus(value ? 1 : 0)} />
|
||||
<Label>{t("status", { defaultValue: "状态" })}</Label>
|
||||
</div>
|
||||
|
||||
{canManageProfile ? (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("profile.section", { defaultValue: "占成与授信" })}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setUserDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={userSaving} onClick={() => void saveNewUser()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
<Button type="button" disabled={nodeSaving} onClick={() => void saveNode()}>
|
||||
{t("common:actions.save", { defaultValue: "保存" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
257
src/modules/agents/agents-players-panel.tsx
Normal file
257
src/modules/agents/agents-players-panel.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } 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 { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
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";
|
||||
|
||||
type AgentsPlayersPanelProps = {
|
||||
siteCode: string;
|
||||
/** 筛选直属玩家时的代理节点;null 表示当前登录代理或不过滤 */
|
||||
agentNodeId: number | null;
|
||||
};
|
||||
|
||||
export function AgentsPlayersPanel({
|
||||
siteCode,
|
||||
agentNodeId,
|
||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "players", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
|
||||
const canCreatePlayer =
|
||||
isSuperAdmin ||
|
||||
(boundAgent?.can_create_player !== false &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
||||
|
||||
const effectiveAgentId = useMemo(() => {
|
||||
if (agentNodeId !== null) {
|
||||
return agentNodeId;
|
||||
}
|
||||
return boundAgent?.id ?? null;
|
||||
}, [agentNodeId, boundAgent?.id]);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [items, setItems] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sitePlayerId, setSitePlayerId] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteCode.trim() === "") {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLastPage(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminPlayers({
|
||||
page,
|
||||
per_page: perPage,
|
||||
site_code: siteCode.trim(),
|
||||
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
setLastPage(Math.max(1, data.meta.last_page));
|
||||
} catch {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLastPage(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [effectiveAgentId, page, perPage, siteCode]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function savePlayer(): Promise<void> {
|
||||
if (siteCode.trim() === "" || sitePlayerId.trim() === "") {
|
||||
toast.error(t("players:sitePlayerIdRequired", { defaultValue: "请填写站点玩家 ID" }));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await postAdminPlayer({
|
||||
site_code: siteCode.trim(),
|
||||
site_player_id: sitePlayerId.trim(),
|
||||
username: username.trim() || null,
|
||||
nickname: nickname.trim() || null,
|
||||
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
|
||||
});
|
||||
toast.success(t("players:createSuccess", { name: sitePlayerId.trim() }));
|
||||
setDialogOpen(false);
|
||||
setSitePlayerId("");
|
||||
setUsername("");
|
||||
setNickname("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("players:createFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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" />
|
||||
{t("playersPanel.create", { defaultValue: "创建玩家" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<AdminLoadingState minHeight="6rem" />
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
{t("common:states.noData", { defaultValue: "暂无数据" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.site_player_id}</TableCell>
|
||||
<TableCell>{row.username ?? "—"}</TableCell>
|
||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(row.status)}>
|
||||
{row.status === 0
|
||||
? t("players:statusNormal", { defaultValue: "正常" })
|
||||
: String(row.status)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="agents-players-per-page"
|
||||
total={total}
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(value) => {
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("players:siteCode", { defaultValue: "站点" })}</Label>
|
||||
<Input value={siteCode} readOnly disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-site-id">
|
||||
{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-site-id"
|
||||
value={sitePlayerId}
|
||||
onChange={(e) => setSitePlayerId(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-username">
|
||||
{t("players:username", { defaultValue: "用户名" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-nickname">
|
||||
{t("players:nickname", { defaultValue: "昵称" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={saving} onClick={() => void savePlayer()}>
|
||||
{t("common:actions.save", { defaultValue: "保存" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/modules/agents/agents-subnav.tsx
Normal file
110
src/modules/agents/agents-subnav.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { 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 { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
const tabs: {
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
return pathname === href || pathname.startsWith(`${matchPrefix}/`) || pathname === matchPrefix;
|
||||
}
|
||||
|
||||
export function AgentsSubnav(): React.ReactElement {
|
||||
const { t } = useTranslation("agents");
|
||||
const pathname = usePathname();
|
||||
const profile = useAdminProfile();
|
||||
const perms = profile?.permissions;
|
||||
|
||||
const visibleTabs = useMemo(
|
||||
() =>
|
||||
tabs.filter(
|
||||
(tab) =>
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(perms, [...tab.requiredAny]),
|
||||
),
|
||||
[perms, profile?.is_super_admin],
|
||||
);
|
||||
|
||||
if (visibleTabs.length === 0) {
|
||||
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);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ const HUB_CARDS: HubCard[] = [
|
||||
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
|
||||
},
|
||||
{
|
||||
href: "/admin/config/integration-sites",
|
||||
href: "/admin/agents/sites",
|
||||
titleKey: "hub.integrationTitle",
|
||||
descKey: "hub.integrationDesc",
|
||||
requiredAny: PRD_INTEGRATION_ACCESS_ANY,
|
||||
|
||||
@@ -669,6 +669,7 @@ export function OddsConfigDocScreen({
|
||||
className="h-9 w-full font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={oddsMultiplierLabel(row.odds_value)}
|
||||
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateOddsForScope(scope, {
|
||||
odds_value: parseOddsMultiplierInput(e.target.value),
|
||||
@@ -697,6 +698,7 @@ export function OddsConfigDocScreen({
|
||||
className="h-9 w-full font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={rebatePercentUi}
|
||||
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -631,6 +631,7 @@ export function PlayConfigDocScreen() {
|
||||
className="mx-auto h-8 w-16 font-mono tabular-nums text-center"
|
||||
value={row.display_order}
|
||||
disabled={saving}
|
||||
placeholder={t("play.placeholders.displayOrder", { ns: "config" })}
|
||||
onChange={(e) => {
|
||||
const n = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isFinite(n)) {
|
||||
@@ -652,6 +653,7 @@ export function PlayConfigDocScreen() {
|
||||
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
||||
placeholder={t("play.placeholders.minBetAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateConfigRow(row.play_code, {
|
||||
min_bet_amount:
|
||||
@@ -673,6 +675,7 @@ export function PlayConfigDocScreen() {
|
||||
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
||||
placeholder={t("play.placeholders.maxBetAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateConfigRow(row.play_code, {
|
||||
max_bet_amount:
|
||||
|
||||
@@ -556,6 +556,7 @@ export function RebateConfigDocScreen({
|
||||
className="font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={p2}
|
||||
placeholder={t("rebate.placeholders.d2", { ns: "config" })}
|
||||
onChange={(e) => setP2(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -572,6 +573,7 @@ export function RebateConfigDocScreen({
|
||||
className="font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={p3}
|
||||
placeholder={t("rebate.placeholders.d3", { ns: "config" })}
|
||||
onChange={(e) => setP3(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -588,6 +590,7 @@ export function RebateConfigDocScreen({
|
||||
className="font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={p4}
|
||||
placeholder={t("rebate.placeholders.d4", { ns: "config" })}
|
||||
onChange={(e) => setP4(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -471,6 +471,7 @@ export function RiskCapDocScreen() {
|
||||
className="w-[220px] font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={defaultCapStr}
|
||||
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
|
||||
onChange={(e) => setDefaultCapStr(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -525,6 +526,7 @@ export function RiskCapDocScreen() {
|
||||
maxLength={4}
|
||||
disabled={saving}
|
||||
value={r.normalized_number}
|
||||
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
@@ -543,6 +545,7 @@ export function RiskCapDocScreen() {
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
cap_amount:
|
||||
|
||||
@@ -149,11 +149,17 @@ export function RiskCapRuntimePanel() {
|
||||
<Link href={`${riskBase}/pools`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskPools", { ns: "draws" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/hot`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskHot", { ns: "draws" })}
|
||||
<Link
|
||||
href={`${riskBase}/pools?filter=high_risk`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("filterHighRisk", { ns: "risk" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/sold-out`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskSoldOut", { ns: "draws" })}
|
||||
<Link
|
||||
href={`${riskBase}/pools?filter=sold_out`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("filterSoldOut", { ns: "risk" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/occupancy`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskLockLogs", { ns: "draws" })}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function DrawCreateDialog({
|
||||
<Label htmlFor="draw-create-draw-no">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="draw-create-draw-no"
|
||||
placeholder="20260526-008"
|
||||
placeholder={t("createDraw.drawNoPlaceholder")}
|
||||
value={form.drawNo}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, drawNo: e.target.value }))}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
@@ -15,25 +15,18 @@ import {
|
||||
postAdminRunDrawRng,
|
||||
} from "@/api/admin-draws";
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { 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 { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
drawResultSourceLabel,
|
||||
drawStatusLabel,
|
||||
hallPreviewDiffersFromDbStatus,
|
||||
} from "./draw-display";
|
||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
import {
|
||||
PRD_DRAW_REOPEN_MANAGE,
|
||||
@@ -42,12 +35,31 @@ import {
|
||||
PRD_PAYOUT_REVIEW,
|
||||
} from "./draw-prd";
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
type ScheduleStep = {
|
||||
key: string;
|
||||
label: string;
|
||||
at: string | null | undefined;
|
||||
};
|
||||
|
||||
function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[10rem_1fr] sm:items-start">
|
||||
<Label className="text-muted-foreground">{label}</Label>
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
<ol className="grid gap-3 sm:grid-cols-3">
|
||||
{steps.map((step, index) => (
|
||||
<li
|
||||
key={step.key}
|
||||
className={cn(
|
||||
"relative rounded-lg border bg-muted/20 px-3 py-2.5",
|
||||
index < steps.length - 1 &&
|
||||
"sm:after:absolute sm:after:top-1/2 sm:after:left-full sm:after:h-px sm:after:w-3 sm:after:-translate-y-1/2 sm:after:bg-border",
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">{step.label}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +74,6 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminDrawShowData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -105,6 +116,99 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
const scheduleSteps = useMemo((): ScheduleStep[] => {
|
||||
if (!data) return [];
|
||||
const steps: ScheduleStep[] = [
|
||||
{ key: "start", label: t("startTime"), at: data.start_time },
|
||||
{ key: "close", label: t("closeTime"), at: data.close_time },
|
||||
{ key: "draw", label: t("plannedDraw"), at: data.draw_time },
|
||||
];
|
||||
if (data.cooling_end_time) {
|
||||
steps.push({
|
||||
key: "cooling",
|
||||
label: t("coolingEndTime"),
|
||||
at: data.cooling_end_time,
|
||||
});
|
||||
}
|
||||
return steps;
|
||||
}, [data, t]);
|
||||
|
||||
const availableActions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
type ActionDef = {
|
||||
key: string;
|
||||
label: string;
|
||||
variant: "outline" | "destructive";
|
||||
enabled: boolean;
|
||||
onConfirm: () => Promise<void>;
|
||||
confirmTitle: string;
|
||||
confirmDescription: string;
|
||||
confirmVariant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
const defs: ActionDef[] = [];
|
||||
|
||||
if (canManageDraw) {
|
||||
defs.push(
|
||||
{
|
||||
key: "manualClose",
|
||||
label: t("manualClose"),
|
||||
variant: "outline",
|
||||
enabled: ["pending", "open"].includes(data.status),
|
||||
onConfirm: () => postAdminManualCloseDraw(idNum),
|
||||
confirmTitle: t("confirm.manualCloseTitle"),
|
||||
confirmDescription: t("confirm.manualCloseDescription"),
|
||||
},
|
||||
{
|
||||
key: "cancel",
|
||||
label: t("cancelBeforeDraw"),
|
||||
variant: "outline",
|
||||
enabled: ["pending", "open", "closing", "closed"].includes(data.status),
|
||||
onConfirm: () => postAdminCancelDraw(idNum),
|
||||
confirmTitle: t("confirm.cancelDrawTitle"),
|
||||
confirmDescription: t("confirm.cancelDrawDescription"),
|
||||
},
|
||||
{
|
||||
key: "rng",
|
||||
label: t("rngAutoGenerate"),
|
||||
variant: "outline",
|
||||
enabled: data.status === "closed",
|
||||
onConfirm: () => postAdminRunDrawRng(idNum),
|
||||
confirmTitle: t("confirm.rngDrawTitle"),
|
||||
confirmDescription: t("confirm.rngDrawDescription"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (canReopenDraw) {
|
||||
defs.push({
|
||||
key: "reopen",
|
||||
label: t("cooldownReopen"),
|
||||
variant: "destructive",
|
||||
enabled: data.status === "cooldown",
|
||||
onConfirm: () => postAdminReopenDraw(idNum),
|
||||
confirmTitle: t("confirm.reopenTitle"),
|
||||
confirmDescription: t("confirm.reopenDescription"),
|
||||
confirmVariant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
if (canRunSettlement) {
|
||||
defs.push({
|
||||
key: "settlement",
|
||||
label: t("runSettlement"),
|
||||
variant: "outline",
|
||||
enabled: data.status === "settling",
|
||||
onConfirm: () => postAdminRunDrawSettlement(idNum),
|
||||
confirmTitle: t("confirm.runSettlementTitle"),
|
||||
confirmDescription: t("confirm.runSettlementDescription"),
|
||||
});
|
||||
}
|
||||
|
||||
return defs.filter((d) => d.enabled);
|
||||
}, [canManageDraw, canReopenDraw, canRunSettlement, data, idNum, t]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
@@ -113,164 +217,122 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
|
||||
}
|
||||
|
||||
const batch = data.result_batch_counts;
|
||||
const hasResultActivity = batch.total > 0 || batch.pending_review > 0 || batch.published > 0;
|
||||
const showActions =
|
||||
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 pb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{data.draw_no}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("drawDetail")}</p>
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="font-mono text-xl tracking-tight">{data.draw_no}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("detailSubtitle", {
|
||||
date: data.business_date,
|
||||
seq: data.sequence_no,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DrawStatusBadge
|
||||
status={data.status}
|
||||
label={drawStatusLabel(data.status, t)}
|
||||
/>
|
||||
{hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? (
|
||||
<p className="flex flex-wrap items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("hallPreviewStatusLabel")}</span>
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">{t("hallPreviewStatusLabel")}</span>
|
||||
<DrawStatusBadge
|
||||
status={data.hall_preview_status}
|
||||
label={drawStatusLabel(data.hall_preview_status, t)}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 p-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t("businessDate")}>{data.business_date}</Field>
|
||||
<Field label={t("sequenceNo")}>{data.sequence_no}</Field>
|
||||
<Field label={t("startTime")}>{formatDt(data.start_time)}</Field>
|
||||
<Field label={t("closeTime")}>{formatDt(data.close_time)}</Field>
|
||||
<Field label={t("plannedDraw")}>{formatDt(data.draw_time)}</Field>
|
||||
<Field label={t("coolingEndTime")}>{formatDt(data.cooling_end_time)}</Field>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t("resultSource")}>{drawResultSourceLabel(data.result_source, t)}</Field>
|
||||
<Field label={t("currentResultVersion")}>{data.current_result_version}</Field>
|
||||
<Field label={t("settleVersion")}>{data.settle_version}</Field>
|
||||
<Field label={t("isReopened")}>{data.is_reopened ? t("yes") : t("no")}</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-muted/20 p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("batchStats")}</p>
|
||||
<div className="mt-3 grid gap-3 text-sm">
|
||||
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2">
|
||||
<span>{t("batchTotal")}</span>
|
||||
<span className="font-semibold">{data.result_batch_counts.total}</span>
|
||||
<CardContent className="space-y-6 border-t pt-6">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
|
||||
<ScheduleTimeline steps={scheduleSteps} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<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 })}
|
||||
</span>
|
||||
)}
|
||||
{batch.published > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
className="rounded-md bg-emerald-500/15 px-2.5 py-1 font-medium text-emerald-800 dark:text-emerald-200"
|
||||
>
|
||||
{t("batchSummaryPublished", { count: batch.published })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
|
||||
{t("batchSummaryPublished", { count: 0 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2 text-amber-600 dark:text-amber-400">
|
||||
<span>{t("pendingReview")}</span>
|
||||
<span className="font-semibold">{data.result_batch_counts.pending_review}</span>
|
||||
) : (
|
||||
<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>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showActions ? (
|
||||
<section className="space-y-3 border-t pt-6">
|
||||
<h3 className="text-sm font-medium">{t("drawActions")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={action.variant}
|
||||
disabled={acting !== null}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: action.confirmTitle,
|
||||
description: action.confirmDescription,
|
||||
confirmVariant: action.confirmVariant,
|
||||
onConfirm: () => runAction(action.label, action.onConfirm),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === action.label ? t("processing") : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2 text-emerald-600 dark:text-emerald-400">
|
||||
<span>{t("published")}</span>
|
||||
<span className="font-semibold">{data.result_batch_counts.published}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/finance`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "mt-4 w-full")}
|
||||
>
|
||||
{t("viewFinance")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{(canManageDraw || canReopenDraw || canRunSettlement) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("drawActions")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.manualCloseTitle"),
|
||||
description: t("confirm.manualCloseDescription"),
|
||||
onConfirm: () => runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("manualClose") ? t("processing") : t("manualClose")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.cancelDrawTitle"),
|
||||
description: t("confirm.cancelDrawDescription"),
|
||||
onConfirm: () => runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("cancelDraw") ? t("processing") : t("cancelBeforeDraw")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.rngDrawTitle"),
|
||||
description: t("confirm.rngDrawDescription"),
|
||||
onConfirm: () => runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
|
||||
</Button>
|
||||
{canReopenDraw ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={acting !== null || data.status !== "cooldown"}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.reopenTitle"),
|
||||
description: t("confirm.reopenDescription"),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: () => runAction(t("reopen"), () => postAdminReopenDraw(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("reopen") ? t("processing") : t("cooldownReopen")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canRunSettlement || acting !== null || data.status !== "settling"}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.runSettlementTitle"),
|
||||
description: t("confirm.runSettlementDescription"),
|
||||
onConfirm: () => runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -128,6 +128,7 @@ export function DrawEditDialog({
|
||||
<Input
|
||||
id="draw-edit-draw-no"
|
||||
value={drawNo}
|
||||
placeholder={t("editDraw.drawNoPlaceholder")}
|
||||
onChange={(e) => setDrawNo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,24 @@ const segments = [
|
||||
{ suffix: "/finance", key: "finance", label: "subnav.finance" },
|
||||
{ suffix: "/review", key: "review", label: "subnav.review" },
|
||||
{ suffix: "/risk/occupancy", key: "riskLockLogs", label: "subnav.riskLockLogs" },
|
||||
{ suffix: "/risk/hot", key: "riskHot", label: "subnav.riskHot" },
|
||||
{ suffix: "/risk/sold-out", key: "riskSoldOut", label: "subnav.riskSoldOut" },
|
||||
{ suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" },
|
||||
] as const;
|
||||
|
||||
function isRiskPoolsTabActive(pathname: string, base: string): boolean {
|
||||
const riskPrefix = `${base}/risk/`;
|
||||
if (!pathname.startsWith(riskPrefix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rest = pathname.slice(riskPrefix.length);
|
||||
return (
|
||||
rest === "pools"
|
||||
|| rest.startsWith("pools/")
|
||||
|| rest === "hot"
|
||||
|| rest === "sold-out"
|
||||
);
|
||||
}
|
||||
|
||||
function isReviewTabActive(pathname: string, base: string): boolean {
|
||||
const reviewPrefix = `${base}/review`;
|
||||
const publishPrefix = `${base}/publish`;
|
||||
@@ -42,7 +55,9 @@ export function DrawSubnav({ drawId }: { drawId: string }) {
|
||||
? pathname === base || pathname === `${base}/`
|
||||
: suffix === "/review"
|
||||
? isReviewTabActive(pathname, base)
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
: key === "riskPools"
|
||||
? isRiskPoolsTabActive(pathname, base)
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -139,7 +139,14 @@ function formToPayload(
|
||||
return base;
|
||||
}
|
||||
|
||||
export function IntegrationSitesConsole() {
|
||||
type IntegrationSitesConsoleProps = {
|
||||
/** 代理线路内站点列表:仅超管可新建站点,普通账号走「开通线路」。 */
|
||||
restrictCreateToSuperAdmin?: boolean;
|
||||
};
|
||||
|
||||
export function IntegrationSitesConsole({
|
||||
restrictCreateToSuperAdmin = false,
|
||||
}: IntegrationSitesConsoleProps = {}) {
|
||||
const { t } = useTranslation("config");
|
||||
const tRef = useTranslationRef("config");
|
||||
const profile = useAdminProfile();
|
||||
@@ -147,6 +154,9 @@ export function IntegrationSitesConsole() {
|
||||
profile?.permissions,
|
||||
getAdminPageBundle("integration-sites", "manage"),
|
||||
);
|
||||
const canCreate =
|
||||
canManage &&
|
||||
(!restrictCreateToSuperAdmin || profile?.is_super_admin === true);
|
||||
|
||||
const [items, setItems] = useState<AdminIntegrationSiteRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -346,7 +356,7 @@ export function IntegrationSitesConsole() {
|
||||
title={t("integrationSites.title")}
|
||||
description={t("integrationSites.description")}
|
||||
actions={
|
||||
canManage ? (
|
||||
canCreate ? (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("integrationSites.create")}
|
||||
</Button>
|
||||
@@ -445,7 +455,7 @@ export function IntegrationSitesConsole() {
|
||||
value={form.code}
|
||||
disabled={mode === "edit"}
|
||||
onChange={(e) => updateForm("code", e.target.value)}
|
||||
placeholder="partner-a"
|
||||
placeholder={t("integrationSites.placeholders.code")}
|
||||
/>
|
||||
{mode === "edit" ? (
|
||||
<p className="text-xs text-muted-foreground">{t("integrationSites.codeImmutable")}</p>
|
||||
@@ -456,6 +466,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="is-name"
|
||||
value={form.name}
|
||||
placeholder={t("integrationSites.placeholders.name")}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -465,6 +476,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="is-currency"
|
||||
value={form.currency_code}
|
||||
placeholder={t("integrationSites.placeholders.currency")}
|
||||
onChange={(e) => updateForm("currency_code", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -486,6 +498,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="is-wallet-url"
|
||||
value={form.wallet_api_url}
|
||||
placeholder={t("integrationSites.placeholders.walletApiUrl")}
|
||||
onChange={(e) => updateForm("wallet_api_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -494,6 +507,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="is-h5"
|
||||
value={form.lottery_h5_base_url}
|
||||
placeholder={t("integrationSites.placeholders.lotteryH5BaseUrl")}
|
||||
onChange={(e) => updateForm("lottery_h5_base_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -504,7 +518,7 @@ export function IntegrationSitesConsole() {
|
||||
rows={3}
|
||||
value={form.iframe_allowed_origins}
|
||||
onChange={(e) => updateForm("iframe_allowed_origins", e.target.value)}
|
||||
placeholder="https://www.example.com"
|
||||
placeholder={t("integrationSites.placeholders.iframeOrigins")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -513,6 +527,7 @@ export function IntegrationSitesConsole() {
|
||||
id="is-notes"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
placeholder={t("integrationSites.placeholders.notes")}
|
||||
onChange={(e) => updateForm("notes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -581,7 +596,7 @@ export function IntegrationSitesConsole() {
|
||||
id="ct-player"
|
||||
value={connectivityPlayerId}
|
||||
onChange={(e) => setConnectivityPlayerId(e.target.value)}
|
||||
placeholder="10001"
|
||||
placeholder={t("integrationSites.placeholders.connectivityPlayerId")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -589,6 +604,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="ct-currency"
|
||||
value={connectivityCurrency}
|
||||
placeholder={t("integrationSites.placeholders.currency")}
|
||||
onChange={(e) => setConnectivityCurrency(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -318,6 +318,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`adj-amt-${p.id}`}
|
||||
className="font-mono"
|
||||
value={adj.amount}
|
||||
placeholder={t("adjustmentAmountPlaceholder")}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -327,6 +328,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`adj-reason-${p.id}`}
|
||||
rows={1}
|
||||
value={adj.reason}
|
||||
placeholder={t("adjustmentReasonPlaceholder")}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -361,6 +363,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`th-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.trigger_threshold}
|
||||
placeholder={t("triggerThresholdPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -370,6 +373,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`min-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.min_bet_amount}
|
||||
placeholder={t("minBetAmountPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -379,6 +383,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`pr-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.payout_rate}
|
||||
placeholder={t("payoutRatePlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -388,6 +393,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`gap-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.force_trigger_draw_gap}
|
||||
placeholder={t("forceTriggerGapPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -397,6 +403,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`cr-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.contribution_rate}
|
||||
placeholder={t("contributionRatePlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -406,7 +413,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`combo-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.combo_trigger_play_codes}
|
||||
placeholder="straight,ibox"
|
||||
placeholder={t("comboTriggerPlaysPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
9
src/modules/players/invalid-player-id.tsx
Normal file
9
src/modules/players/invalid-player-id.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function InvalidPlayerId(): React.ReactElement {
|
||||
const { t } = useTranslation("players");
|
||||
|
||||
return <p className="text-destructive text-sm">{t("invalidPlayerId")}</p>;
|
||||
}
|
||||
562
src/modules/players/player-detail-console.tsx
Normal file
562
src/modules/players/player-detail-console.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
|
||||
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
|
||||
|
||||
function playerStatusLabel(status: number, t: (key: string) => string): string {
|
||||
if (status === 0) return t("statusNormal");
|
||||
if (status === 1) return t("statusFrozen");
|
||||
if (status === 2) return t("statusBanned");
|
||||
return String(status);
|
||||
}
|
||||
|
||||
function ticketStatusText(status: string, t: (key: string, opts?: { ns?: string }) => string): string {
|
||||
const key = `statusOptions.${status}`;
|
||||
const translated = t(key, { ns: "tickets" });
|
||||
return translated === key ? status : translated;
|
||||
}
|
||||
|
||||
function walletStatusLabel(status: string, t: (key: string, opts?: { ns?: string }) => string): string {
|
||||
switch (status) {
|
||||
case "processing":
|
||||
return t("statusProcessing", { ns: "wallet" });
|
||||
case "success":
|
||||
return t("statusSuccess", { ns: "wallet" });
|
||||
case "failed":
|
||||
return t("statusFailed", { ns: "wallet" });
|
||||
case "pending_reconcile":
|
||||
return t("statusPendingReconcile", { ns: "wallet" });
|
||||
case "reversed":
|
||||
return t("statusReversed", { ns: "wallet" });
|
||||
case "manually_processed":
|
||||
return t("statusCaseClosed", { ns: "wallet" });
|
||||
case "posted":
|
||||
return t("statusPosted", { ns: "wallet" });
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function PlayerStatusBadge({ status, t }: { status: number; t: (key: string) => string }) {
|
||||
return (
|
||||
<AdminStatusBadge status={String(status)} tone={resolvePlayerStatusTone(status)}>
|
||||
{playerStatusLabel(status, t)}
|
||||
</AdminStatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function playerDisplayName(row: AdminPlayerRow): string {
|
||||
return row.nickname?.trim() || row.username?.trim() || row.site_player_id;
|
||||
}
|
||||
|
||||
function ProfileField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[7.5rem_1fr] sm:items-start">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
const { t } = useTranslation(["players", "tickets", "wallet", "common"]);
|
||||
const tRef = useTranslationRef(["players", "tickets", "wallet", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
useAdminCurrencyCatalog();
|
||||
|
||||
const [player, setPlayer] = useState<AdminPlayerRow | null>(null);
|
||||
const [playerLoading, setPlayerLoading] = useState(true);
|
||||
const [playerErr, setPlayerErr] = useState<string | null>(null);
|
||||
|
||||
const [ticketPage, setTicketPage] = useState(1);
|
||||
const [ticketPerPage, setTicketPerPage] = useState(10);
|
||||
const [tickets, setTickets] = useState<AdminPlayerTicketItemRow[]>([]);
|
||||
const [ticketTotal, setTicketTotal] = useState(0);
|
||||
const [ticketLastPage, setTicketLastPage] = useState(1);
|
||||
const [ticketsLoading, setTicketsLoading] = useState(false);
|
||||
|
||||
const [txnPage, setTxnPage] = useState(1);
|
||||
const [txnPerPage, setTxnPerPage] = useState(10);
|
||||
const [txns, setTxns] = useState<AdminWalletTxnItem[]>([]);
|
||||
const [txnTotal, setTxnTotal] = useState(0);
|
||||
const [txnLastPage, setTxnLastPage] = useState(1);
|
||||
const [txnsLoading, setTxnsLoading] = useState(false);
|
||||
|
||||
const [transferPage, setTransferPage] = useState(1);
|
||||
const [transferPerPage, setTransferPerPage] = useState(10);
|
||||
const [transfers, setTransfers] = useState<AdminTransferOrderItem[]>([]);
|
||||
const [transferTotal, setTransferTotal] = useState(0);
|
||||
const [transferLastPage, setTransferLastPage] = useState(1);
|
||||
const [transfersLoading, setTransfersLoading] = useState(false);
|
||||
|
||||
const loadPlayer = useCallback(async () => {
|
||||
setPlayerLoading(true);
|
||||
setPlayerErr(null);
|
||||
try {
|
||||
setPlayer(await getAdminPlayer(playerId));
|
||||
} catch (e) {
|
||||
setPlayer(null);
|
||||
setPlayerErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setPlayerLoading(false);
|
||||
}
|
||||
}, [playerId]);
|
||||
|
||||
const loadTickets = useCallback(async () => {
|
||||
setTicketsLoading(true);
|
||||
try {
|
||||
const d = await getAdminPlayerTicketItems(playerId, {
|
||||
page: ticketPage,
|
||||
per_page: ticketPerPage,
|
||||
});
|
||||
setTickets(d.items);
|
||||
setTicketTotal(d.total);
|
||||
setTicketLastPage(Math.max(1, d.last_page));
|
||||
} catch {
|
||||
setTickets([]);
|
||||
setTicketTotal(0);
|
||||
setTicketLastPage(1);
|
||||
} finally {
|
||||
setTicketsLoading(false);
|
||||
}
|
||||
}, [playerId, ticketPage, ticketPerPage]);
|
||||
|
||||
const loadTxns = useCallback(async () => {
|
||||
setTxnsLoading(true);
|
||||
try {
|
||||
const d = await getAdminWalletTransactions({
|
||||
player_id: playerId,
|
||||
page: txnPage,
|
||||
per_page: txnPerPage,
|
||||
});
|
||||
setTxns(d.items);
|
||||
setTxnTotal(d.total);
|
||||
setTxnLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
|
||||
} catch {
|
||||
setTxns([]);
|
||||
setTxnTotal(0);
|
||||
setTxnLastPage(1);
|
||||
} finally {
|
||||
setTxnsLoading(false);
|
||||
}
|
||||
}, [playerId, txnPage, txnPerPage]);
|
||||
|
||||
const loadTransfers = useCallback(async () => {
|
||||
setTransfersLoading(true);
|
||||
try {
|
||||
const d = await getAdminTransferOrders({
|
||||
player_id: playerId,
|
||||
page: transferPage,
|
||||
per_page: transferPerPage,
|
||||
});
|
||||
setTransfers(d.items);
|
||||
setTransferTotal(d.total);
|
||||
setTransferLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
|
||||
} catch {
|
||||
setTransfers([]);
|
||||
setTransferTotal(0);
|
||||
setTransferLastPage(1);
|
||||
} finally {
|
||||
setTransfersLoading(false);
|
||||
}
|
||||
}, [playerId, transferPage, transferPerPage]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void loadPlayer();
|
||||
}, [loadPlayer]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!player) return;
|
||||
void loadTickets();
|
||||
}, [player, loadTickets]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!player) return;
|
||||
void loadTxns();
|
||||
}, [player, loadTxns]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!player) return;
|
||||
void loadTransfers();
|
||||
}, [player, loadTransfers]);
|
||||
|
||||
if (playerLoading && !player) {
|
||||
return <AdminLoadingState minHeight="8rem" className="py-8" />;
|
||||
}
|
||||
|
||||
if (playerErr || !player) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/players" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1")}>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
<p className="text-sm text-destructive">{playerErr ?? t("states.noData", { ns: "common" })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/admin/players"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "-ml-2 gap-1")}
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{playerDisplayName(player)}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("detailSubtitle", {
|
||||
site: player.site_code,
|
||||
sitePlayerId: player.site_player_id,
|
||||
playerId: player.id,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PlayerStatusBadge status={player.status} t={t} />
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="gap-4">
|
||||
<TabsList variant="line" className="w-full justify-start border-b rounded-none bg-transparent p-0">
|
||||
<TabsTrigger value="overview" className="rounded-none px-3">
|
||||
{t("tabOverview")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tickets" className="rounded-none px-3">
|
||||
{t("tabTickets")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="wallet" className="rounded-none px-3">
|
||||
{t("tabWalletTxns")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transfers" className="rounded-none px-3">
|
||||
{t("tabTransferOrders")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("profileSection")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<dl className="space-y-3">
|
||||
<ProfileField label={t("site")}>{player.site_code}</ProfileField>
|
||||
<ProfileField label={t("sitePlayerId")}>
|
||||
<span className="font-mono">{player.site_player_id}</span>
|
||||
</ProfileField>
|
||||
<ProfileField label="ID">
|
||||
<span className="font-mono">{player.id}</span>
|
||||
</ProfileField>
|
||||
<ProfileField label={t("username")}>{player.username ?? "—"}</ProfileField>
|
||||
<ProfileField label={t("nickname")}>{player.nickname ?? "—"}</ProfileField>
|
||||
</dl>
|
||||
<dl className="space-y-3">
|
||||
<ProfileField label={t("currency")}>{player.default_currency}</ProfileField>
|
||||
<ProfileField label={t("status")}>
|
||||
<PlayerStatusBadge status={player.status} t={t} />
|
||||
</ProfileField>
|
||||
<ProfileField label={t("lastLogin")}>
|
||||
{player.last_login_at ? formatDt(player.last_login_at) : "—"}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("createdAt")}>
|
||||
{formatDt(player.created_at)}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("agent")}>
|
||||
{player.agent_name ?? player.agent_code ?? "—"}
|
||||
{player.agent_code && player.agent_name ? (
|
||||
<span className="ml-1 font-mono text-xs text-muted-foreground">
|
||||
({player.agent_code})
|
||||
</span>
|
||||
) : null}
|
||||
</ProfileField>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("walletsSection")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{player.wallets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{player.wallets.map((w: AdminPlayerWalletRow) => (
|
||||
<div
|
||||
key={`${w.wallet_type}-${w.currency_code}`}
|
||||
className="rounded-lg border bg-muted/20 px-3 py-2.5"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{w.wallet_type} · {w.currency_code}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{t("balance")}{" "}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{formatAdminMinorUnits(w.balance, w.currency_code)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("available")}{" "}
|
||||
<span className="tabular-nums">
|
||||
{formatAdminMinorUnits(w.available_balance, w.currency_code)}
|
||||
</span>
|
||||
{w.frozen_balance > 0 ? (
|
||||
<>
|
||||
{" · "}
|
||||
{t("frozen", { defaultValue: "冻结" })}{" "}
|
||||
<span className="tabular-nums">
|
||||
{formatAdminMinorUnits(w.frozen_balance, w.currency_code)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tickets" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("status", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ticketsLoading && tickets.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={7} />
|
||||
) : null}
|
||||
{tickets.map((row) => (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.actual_deduct_amount_formatted}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{ticketStatusText(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.placed_at ? formatDt(row.placed_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!ticketsLoading && tickets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-tickets"
|
||||
total={ticketTotal}
|
||||
page={ticketPage}
|
||||
lastPage={ticketLastPage}
|
||||
perPage={ticketPerPage}
|
||||
loading={ticketsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setTicketPerPage(n);
|
||||
setTicketPage(1);
|
||||
}}
|
||||
onPageChange={setTicketPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="wallet" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabWalletTxns")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("txnNo", { ns: "wallet" })}</TableHead>
|
||||
<TableHead>{t("bizType", { ns: "wallet" })}</TableHead>
|
||||
<TableHead className="text-center">{t("txnAmount")}</TableHead>
|
||||
<TableHead className="text-center">{t("balanceAfterTxn")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("createdAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{txnsLoading && txns.length === 0 ? <AdminTableLoadingRow colSpan={6} /> : null}
|
||||
{txns.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
||||
<TableCell className="text-xs">{row.biz_type}</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount, player.default_currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.balance_after, player.default_currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{walletStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!txnsLoading && txns.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-txns"
|
||||
total={txnTotal}
|
||||
page={txnPage}
|
||||
lastPage={txnLastPage}
|
||||
perPage={txnPerPage}
|
||||
loading={txnsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setTxnPerPage(n);
|
||||
setTxnPage(1);
|
||||
}}
|
||||
onPageChange={setTxnPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transfers" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabTransferOrders")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("localTransferNo", { ns: "wallet" })}</TableHead>
|
||||
<TableHead>{t("direction", { ns: "wallet" })}</TableHead>
|
||||
<TableHead className="text-center">{t("amount", { ns: "wallet" })}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("requestTime", { ns: "wallet" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transfersLoading && transfers.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={5} />
|
||||
) : null}
|
||||
{transfers.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.transfer_no}</TableCell>
|
||||
<TableCell className="text-xs">{row.direction}</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount, row.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{walletStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!transfersLoading && transfers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-transfers"
|
||||
total={transferTotal}
|
||||
page={transferPage}
|
||||
lastPage={transferLastPage}
|
||||
perPage={transferPerPage}
|
||||
loading={transfersLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setTransferPerPage(n);
|
||||
setTransferPage(1);
|
||||
}}
|
||||
onPageChange={setTransferPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
@@ -11,6 +11,7 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAgentTree } from "@/api/admin-agents";
|
||||
import {
|
||||
deleteAdminPlayer,
|
||||
getAdminPlayers,
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
postAdminPlayerUnfreeze,
|
||||
putAdminPlayer,
|
||||
} from "@/api/admin-player";
|
||||
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 { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
@@ -90,6 +94,9 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const exportLabels = useExportLabels("players");
|
||||
const profile = useAdminProfile();
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -118,6 +125,9 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [formNickname, setFormNickname] = useState("");
|
||||
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
|
||||
const [formStatus, setFormStatus] = useState(0);
|
||||
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
|
||||
const [createAgentLoading, setCreateAgentLoading] = useState(false);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminPlayerRow | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
@@ -164,7 +174,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
function openCreateAccount(): void {
|
||||
setAccountMode("create");
|
||||
setEditingAccountId(null);
|
||||
setFormSiteCode("");
|
||||
const agentSite = boundAgent?.site_code?.trim() ?? "";
|
||||
const defaultSite =
|
||||
agentSite !== ""
|
||||
? agentSite
|
||||
: siteOptions[0]?.code ?? "";
|
||||
setFormSiteCode(defaultSite);
|
||||
setFormAgentNodeId(boundAgent?.id);
|
||||
setFormSitePlayerId("");
|
||||
setFormUsername("");
|
||||
setFormNickname("");
|
||||
@@ -173,6 +189,52 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!accountOpen || accountMode !== "create" || !isSuperAdmin) {
|
||||
return;
|
||||
}
|
||||
const siteCode = formSiteCode.trim();
|
||||
const site = siteOptions.find((s) => s.code === siteCode);
|
||||
if (!site) {
|
||||
setCreateAgentOptions([]);
|
||||
setFormAgentNodeId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setCreateAgentLoading(true);
|
||||
getAgentTree(site.id)
|
||||
.then((data) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const flat = flattenAgentTree(data.tree);
|
||||
setCreateAgentOptions(flat);
|
||||
const root = flat.find((o) => o.depth === 0) ?? flat[0];
|
||||
setFormAgentNodeId((prev) => {
|
||||
if (prev !== undefined && flat.some((o) => o.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return root?.id;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCreateAgentOptions([]);
|
||||
setFormAgentNodeId(undefined);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setCreateAgentLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accountOpen, accountMode, formSiteCode, isSuperAdmin, siteOptions]);
|
||||
|
||||
function openEditAccount(row: AdminPlayerRow): void {
|
||||
setAccountMode("edit");
|
||||
setEditingAccountId(row.id);
|
||||
@@ -194,6 +256,10 @@ export function PlayersConsole(): React.ReactElement {
|
||||
|
||||
async function submitAccount(): Promise<void> {
|
||||
if (accountMode === "create") {
|
||||
if (!isSuperAdmin && !boundAgent) {
|
||||
toast.error(t("createAgentRequired"));
|
||||
return;
|
||||
}
|
||||
if (formSiteCode.trim() === "") {
|
||||
toast.error(t("siteCodeRequired"));
|
||||
return;
|
||||
@@ -202,6 +268,10 @@ export function PlayersConsole(): React.ReactElement {
|
||||
toast.error(t("sitePlayerIdRequired"));
|
||||
return;
|
||||
}
|
||||
if (isSuperAdmin && (formAgentNodeId === undefined || formAgentNodeId <= 0)) {
|
||||
toast.error(t("createAgentRequired"));
|
||||
return;
|
||||
}
|
||||
setAccountSaving(true);
|
||||
try {
|
||||
const created = await postAdminPlayer({
|
||||
@@ -211,6 +281,9 @@ export function PlayersConsole(): React.ReactElement {
|
||||
nickname: formNickname.trim() || null,
|
||||
default_currency: formDefaultCurrency,
|
||||
status: formStatus,
|
||||
...(isSuperAdmin && formAgentNodeId
|
||||
? { agent_node_id: formAgentNodeId }
|
||||
: {}),
|
||||
});
|
||||
setItems((prev) => [created, ...prev]);
|
||||
setTotal((t) => t + 1);
|
||||
@@ -468,37 +541,33 @@ export function PlayersConsole(): React.ReactElement {
|
||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManagePlayers ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: t("edit"),
|
||||
icon: Pencil,
|
||||
onClick: () => openEditAccount(row),
|
||||
},
|
||||
{
|
||||
key: "tickets",
|
||||
label: t("viewTickets", { defaultValue: "查看注单" }),
|
||||
href: `/admin/tickets?player_id=${row.id}`,
|
||||
},
|
||||
{
|
||||
key: "wallet",
|
||||
label: t("viewWallet", { defaultValue: "查看钱包流水" }),
|
||||
href: `/admin/wallet/transactions?player_id=${row.id}`,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("delete"),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => setDeleteTarget(row),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("viewDetail"),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.id),
|
||||
},
|
||||
...(canManagePlayers
|
||||
? [
|
||||
{
|
||||
key: "edit",
|
||||
label: t("edit"),
|
||||
icon: Pencil,
|
||||
onClick: () => openEditAccount(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("delete"),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => setDeleteTarget(row),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -536,13 +605,73 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-site-code">{t("siteCode")}</Label>
|
||||
<Input
|
||||
id="player-site-code"
|
||||
value={formSiteCode}
|
||||
placeholder={t("siteCodePlaceholder")}
|
||||
onChange={(e) => setFormSiteCode(e.target.value)}
|
||||
/>
|
||||
{boundAgent && boundAgent.site_code ? (
|
||||
<Input id="player-site-code" value={boundAgent.site_code} disabled readOnly />
|
||||
) : canChooseSite && siteOptions.length > 0 ? (
|
||||
<Select
|
||||
value={formSiteCode}
|
||||
onValueChange={(code) => {
|
||||
setFormSiteCode(code);
|
||||
setFormAgentNodeId(undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="player-site-code">
|
||||
<SelectValue placeholder={t("siteCodePlaceholder")}>
|
||||
{siteOptions.find((s) => s.code === formSiteCode)?.name ?? formSiteCode}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={site.code}>
|
||||
{site.name} ({site.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id="player-site-code"
|
||||
value={formSiteCode}
|
||||
placeholder={t("siteCodePlaceholder")}
|
||||
onChange={(e) => setFormSiteCode(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{boundAgent ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("createAgentAutoHint", {
|
||||
name: boundAgent.name,
|
||||
code: boundAgent.code,
|
||||
})}
|
||||
</p>
|
||||
) : isSuperAdmin ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-agent-node">{t("createAgentNode")}</Label>
|
||||
<Select
|
||||
value={
|
||||
formAgentNodeId != null && formAgentNodeId > 0
|
||||
? String(formAgentNodeId)
|
||||
: ""
|
||||
}
|
||||
onValueChange={(v) => setFormAgentNodeId(Number(v))}
|
||||
disabled={createAgentLoading || createAgentOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger id="player-agent-node">
|
||||
<SelectValue placeholder={t("createAgentNodePlaceholder")}>
|
||||
{createAgentOptions.find((o) => o.id === formAgentNodeId)?.label ??
|
||||
t("createAgentNodePlaceholder")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{createAgentOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-site-id">{t("sitePlayerIdLabel")}</Label>
|
||||
<Input
|
||||
@@ -591,7 +720,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="player-status">
|
||||
<SelectValue />
|
||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||
@@ -612,7 +741,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="player-edit-status">
|
||||
<SelectValue />
|
||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||
|
||||
@@ -111,7 +111,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={draftNumber}
|
||||
className="w-full sm:w-32"
|
||||
className="h-8 w-full font-mono sm:w-32"
|
||||
onChange={(e) => setDraftNumber(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
@@ -127,7 +127,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
if (v) setDraftAction(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="w-full sm:w-40">
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="h-8 w-full sm:w-40">
|
||||
<SelectValue>{riskActionFilterLabel(draftAction, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -177,21 +177,23 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminRiskLockLogRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
|
||||
<TableCell className="font-mono text-sm font-medium">
|
||||
{row.normalized_number}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{riskActionTypeLabel(row.action_type, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{riskSourceReasonLabel(row.source_reason, t)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.ticket_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm">{playCodeLabel(row.play_code)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -63,25 +63,41 @@ function riskSortLabel(
|
||||
return option ? t(option.label) : value;
|
||||
}
|
||||
|
||||
type RiskFilter = "all" | "sold_out" | "high_risk";
|
||||
export type RiskPoolListFilter = "all" | "sold_out" | "high_risk";
|
||||
|
||||
type RiskPoolsConsoleProps = {
|
||||
drawId: number;
|
||||
/** @deprecated 优先使用 titleKey */
|
||||
title?: string;
|
||||
titleKey?: RiskPoolsPageTitleKey;
|
||||
soldOutOnly: boolean;
|
||||
defaultSort: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
|
||||
/** @deprecated 使用 initialFilter */
|
||||
soldOutOnly?: boolean;
|
||||
initialFilter?: RiskPoolListFilter;
|
||||
defaultSort?: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
|
||||
allowSortChange?: boolean;
|
||||
};
|
||||
|
||||
function resolveInitialFilter(
|
||||
initialFilter: RiskPoolListFilter | undefined,
|
||||
soldOutOnly: boolean | undefined,
|
||||
): RiskPoolListFilter {
|
||||
if (initialFilter) {
|
||||
return initialFilter;
|
||||
}
|
||||
if (soldOutOnly) {
|
||||
return "sold_out";
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
export function RiskPoolsConsole({
|
||||
drawId,
|
||||
title,
|
||||
titleKey,
|
||||
soldOutOnly,
|
||||
defaultSort,
|
||||
allowSortChange = false,
|
||||
initialFilter: initialFilterProp,
|
||||
defaultSort = "number_asc",
|
||||
allowSortChange = true,
|
||||
}: RiskPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
@@ -91,11 +107,12 @@ export function RiskPoolsConsole({
|
||||
PRD_RISK_MANAGE,
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
]);
|
||||
const initialFilter = resolveInitialFilter(initialFilterProp, soldOutOnly);
|
||||
const pageTitle = titleKey ? t(titleKey) : (title ?? t("poolsTitle"));
|
||||
const exportLabels = useExportLabels("riskPools");
|
||||
useAdminCurrencyCatalog();
|
||||
const [sort, setSort] = useState(defaultSort);
|
||||
const [filter, setFilter] = useState<RiskFilter>(soldOutOnly ? "sold_out" : "all");
|
||||
const [filter, setFilter] = useState<RiskPoolListFilter>(initialFilter);
|
||||
const [number, setNumber] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
@@ -162,19 +179,22 @@ export function RiskPoolsConsole({
|
||||
return (
|
||||
<>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header space-y-3">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-number" className="sm:w-16 sm:shrink-0">
|
||||
<Label htmlFor="risk-pool-number" className="sm:w-20 sm:shrink-0">
|
||||
{t("searchNumber")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-pool-number"
|
||||
inputMode="numeric"
|
||||
value={number}
|
||||
maxLength={4}
|
||||
placeholder={t("searchNumberPlaceholder")}
|
||||
className="h-8 w-full font-mono sm:w-52"
|
||||
className="h-8 w-full font-mono sm:w-32"
|
||||
onChange={(event) => {
|
||||
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
|
||||
setPage(1);
|
||||
@@ -182,31 +202,35 @@ export function RiskPoolsConsole({
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-16 sm:shrink-0">{t("riskFilter")}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
["all", t("filterAll")],
|
||||
["sold_out", t("filterSoldOut")],
|
||||
["high_risk", t("filterHighRisk")],
|
||||
].map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filter === value ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setFilter(value as RiskFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Label className="sm:w-20 sm:shrink-0">{t("riskFilter")}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={filter}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return;
|
||||
setFilter(v as RiskPoolListFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-pool-filter" size="sm" className="h-8 w-full sm:w-40">
|
||||
<SelectValue>
|
||||
{filter === "all"
|
||||
? t("filterAll")
|
||||
: filter === "sold_out"
|
||||
? t("filterSoldOut")
|
||||
: t("filterHighRisk")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filterAll")}</SelectItem>
|
||||
<SelectItem value="sold_out">{t("filterSoldOut")}</SelectItem>
|
||||
<SelectItem value="high_risk">{t("filterHighRisk")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{allowSortChange ? (
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-sort" className="sm:w-10 sm:shrink-0">
|
||||
<Label htmlFor="risk-pool-sort" className="sm:w-20 sm:shrink-0">
|
||||
{t("sort")}
|
||||
</Label>
|
||||
<Select
|
||||
@@ -218,7 +242,7 @@ export function RiskPoolsConsole({
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-pool-sort" size="sm" className="w-full sm:w-52">
|
||||
<SelectTrigger id="risk-pool-sort" size="sm" className="h-8 w-full sm:w-44">
|
||||
<SelectValue>{riskSortLabel(sort, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -239,8 +263,7 @@ export function RiskPoolsConsole({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
@@ -253,7 +276,7 @@ export function RiskPoolsConsole({
|
||||
<TableHead className="text-center">{t("remainingAmount")}</TableHead>
|
||||
<TableHead className="text-center">{t("usageRatio")}</TableHead>
|
||||
<TableHead>{t("poolStatus")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 text-center">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -301,7 +324,7 @@ export function RiskPoolsConsole({
|
||||
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<TableCell className="sticky right-0 z-10 text-center">
|
||||
<AdminRowActionsMenu
|
||||
busy={acting}
|
||||
actions={[
|
||||
@@ -345,7 +368,7 @@ export function RiskPoolsConsole({
|
||||
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId={`risk-pools-${drawId}-${soldOutOnly ? "so" : "all"}`}
|
||||
selectId={`risk-pools-${drawId}-${filter}`}
|
||||
total={data.meta.total}
|
||||
page={data.meta.current_page}
|
||||
lastPage={data.meta.last_page}
|
||||
|
||||
@@ -9,11 +9,19 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const segments = [
|
||||
{ suffix: "/occupancy", key: "occupancy", label: "subnavOccupancy" },
|
||||
{ suffix: "/hot", key: "hot", label: "subnavHot" },
|
||||
{ suffix: "/sold-out", key: "sold-out", label: "subnavSoldOut" },
|
||||
{ suffix: "/pools", key: "pools", label: "subnavPools" },
|
||||
] as const;
|
||||
|
||||
function isPoolsTabActive(pathname: string, base: string): boolean {
|
||||
const poolsPrefix = `${base}/pools`;
|
||||
return (
|
||||
pathname === poolsPrefix
|
||||
|| pathname.startsWith(`${poolsPrefix}/`)
|
||||
|| pathname === `${base}/hot`
|
||||
|| pathname === `${base}/sold-out`
|
||||
);
|
||||
}
|
||||
|
||||
export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation("risk");
|
||||
const pathname = usePathname();
|
||||
@@ -24,8 +32,7 @@ export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
{segments.map(({ suffix, key, label }) => {
|
||||
const href = `${base}${suffix}`;
|
||||
const active =
|
||||
pathname === href ||
|
||||
(key === "pools" && pathname?.startsWith(`${base}/pools`));
|
||||
key === "pools" ? isPoolsTabActive(pathname, base) : pathname === href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -315,6 +315,7 @@ export function CurrencySettingsPanel() {
|
||||
<Input
|
||||
id="currency-code"
|
||||
value={form.code}
|
||||
placeholder={t("currencies.form.codePlaceholder", { ns: "config" })}
|
||||
onChange={(e) => updateForm("code", e.target.value.toUpperCase())}
|
||||
disabled={saving || mode === "edit"}
|
||||
/>
|
||||
@@ -325,6 +326,7 @@ export function CurrencySettingsPanel() {
|
||||
<Input
|
||||
id="currency-name"
|
||||
value={form.name}
|
||||
placeholder={t("currencies.form.namePlaceholder", { ns: "config" })}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
@@ -339,6 +341,7 @@ export function CurrencySettingsPanel() {
|
||||
max="12"
|
||||
step="1"
|
||||
value={form.decimal_places}
|
||||
placeholder={t("currencies.form.decimalsPlaceholder", { ns: "config" })}
|
||||
onChange={(e) => updateForm("decimal_places", e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
|
||||
@@ -91,6 +91,7 @@ export function CurrencyFormatSettingsPanel() {
|
||||
max="12"
|
||||
step="1"
|
||||
value={draft.currencyDisplayDecimals}
|
||||
placeholder={t("system.placeholders.currencyDisplayDecimals", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyDisplayDecimals", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -102,6 +103,7 @@ export function CurrencyFormatSettingsPanel() {
|
||||
<Input
|
||||
id="currency-decimal-separator"
|
||||
value={draft.currencyDecimalSeparator}
|
||||
placeholder={t("system.placeholders.currencyDecimalSeparator", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyDecimalSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
@@ -114,6 +116,7 @@ export function CurrencyFormatSettingsPanel() {
|
||||
<Input
|
||||
id="currency-thousands-separator"
|
||||
value={draft.currencyThousandsSeparator}
|
||||
placeholder={t("system.placeholders.currencyThousandsSeparator", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyThousandsSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
|
||||
@@ -129,6 +129,7 @@ export function DrawSettingsPanel() {
|
||||
<Input
|
||||
id="default-currency"
|
||||
value={draft.defaultCurrency}
|
||||
placeholder={t("system.placeholders.defaultCurrency", { ns: "config" })}
|
||||
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
|
||||
disabled={loading || saving}
|
||||
maxLength={16}
|
||||
@@ -145,6 +146,7 @@ export function DrawSettingsPanel() {
|
||||
max="1440"
|
||||
step="1"
|
||||
value={draft.drawIntervalMinutes}
|
||||
placeholder={t("system.placeholders.drawIntervalMinutes", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -159,6 +161,7 @@ export function DrawSettingsPanel() {
|
||||
min="10"
|
||||
step="1"
|
||||
value={draft.drawBettingWindowSeconds}
|
||||
placeholder={t("system.placeholders.drawBettingWindowSeconds", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -173,6 +176,7 @@ export function DrawSettingsPanel() {
|
||||
min="5"
|
||||
step="1"
|
||||
value={draft.drawCloseBeforeDrawSeconds}
|
||||
placeholder={t("system.placeholders.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -187,6 +191,7 @@ export function DrawSettingsPanel() {
|
||||
min="1"
|
||||
step="1"
|
||||
value={draft.drawBufferDrawsAhead}
|
||||
placeholder={t("system.placeholders.drawBufferDrawsAhead", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -201,6 +206,7 @@ export function DrawSettingsPanel() {
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
|
||||
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
|
||||
79
src/modules/settlement/agent-bills-console.tsx
Normal file
79
src/modules/settlement/agent-bills-console.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"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;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -36,9 +36,10 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, Eye } from "lucide-react";
|
||||
|
||||
/** 与玩家端、注项表 status 字段对齐(不含无效的 success) */
|
||||
const TICKET_STATUS_OPTIONS = [
|
||||
@@ -95,6 +96,25 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
||||
return t("statusSelectedCount", { count: statuses.length });
|
||||
}
|
||||
|
||||
function TicketFilterField({
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<Label htmlFor={id} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const tRef = useTranslationRef(["tickets", "common"]);
|
||||
@@ -184,52 +204,55 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||
<Label htmlFor="pt-player" className="sm:shrink-0">
|
||||
{t("playerId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="pt-player"
|
||||
className="font-mono"
|
||||
placeholder={t("playerIdPlaceholder")}
|
||||
value={draft.playerQuery}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, playerQuery: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="pt-draw" className="sm:shrink-0">
|
||||
{t("drawNoOptional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="pt-draw"
|
||||
className="font-mono text-sm sm:w-44"
|
||||
placeholder={t("drawNoPlaceholder")}
|
||||
value={draft.drawNo}
|
||||
onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||
<Label htmlFor="pt-number" className="sm:shrink-0">
|
||||
{t("numberKeyword")}
|
||||
</Label>
|
||||
<Input
|
||||
id="pt-number"
|
||||
className="font-mono text-sm"
|
||||
placeholder={t("numberKeywordPlaceholder")}
|
||||
value={draft.numberKeyword}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, numberKeyword: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="pt-date-range" className="sm:shrink-0">
|
||||
{t("placedDateRange")}
|
||||
</Label>
|
||||
<div className="min-w-0 w-full sm:w-56">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<TicketFilterField id="pt-player" label={t("playerId")}>
|
||||
<Input
|
||||
id="pt-player"
|
||||
className="h-8 w-full font-mono"
|
||||
placeholder={t("playerIdPlaceholder")}
|
||||
value={draft.playerQuery}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, playerQuery: e.target.value }))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-draw" label={t("drawNoOptional")}>
|
||||
<Input
|
||||
id="pt-draw"
|
||||
className="h-8 w-full font-mono"
|
||||
placeholder={t("drawNoPlaceholder")}
|
||||
value={draft.drawNo}
|
||||
onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-number" label={t("numberKeyword")}>
|
||||
<Input
|
||||
id="pt-number"
|
||||
className="h-8 w-full font-mono"
|
||||
placeholder={t("numberKeywordPlaceholder")}
|
||||
value={draft.numberKeyword}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, numberKeyword: e.target.value }))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-date-range" label={t("placedDateRange")}>
|
||||
<AdminDateRangeField
|
||||
id="pt-date-range"
|
||||
from={draft.startDate}
|
||||
@@ -242,50 +265,50 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
|
||||
<TicketFilterField id="pt-status" label={t("statusFilterLabel")}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
id="pt-status"
|
||||
title={t("statusHint")}
|
||||
className="inline-flex h-8 w-full min-w-0 items-center justify-between rounded-md border border-border bg-card px-3 text-left text-sm font-normal shadow-sm outline-none transition-all hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:min-w-[12rem] sm:max-w-xs"
|
||||
>
|
||||
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{TICKET_STATUS_OPTIONS.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={draft.statuses.includes(status)}
|
||||
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
|
||||
>
|
||||
{ticketStatusText(status, t)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TicketFilterField>
|
||||
<div className="admin-list-actions w-full sm:ml-auto sm:w-auto">
|
||||
<AdminTableExportButton
|
||||
tableId="tickets-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("refreshCurrentPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="pt-status" className="sm:shrink-0">
|
||||
{t("statusFilterLabel")}
|
||||
</Label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
id="pt-status"
|
||||
title={t("statusHint")}
|
||||
className="inline-flex h-8 w-full min-w-0 items-center justify-between rounded-md border border-border bg-card px-3 text-left text-sm font-normal shadow-sm outline-none transition-all hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:w-44"
|
||||
>
|
||||
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{TICKET_STATUS_OPTIONS.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={draft.statuses.includes(status)}
|
||||
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
|
||||
>
|
||||
{ticketStatusText(status, t)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="tickets-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("refreshCurrentPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (
|
||||
@@ -372,13 +395,18 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view-player",
|
||||
label: t("viewPlayer", { defaultValue: "查看玩家" }),
|
||||
href: `/admin/players?keyword=${encodeURIComponent(String(row.player_id ?? ""))}&site_code=${encodeURIComponent(String(row.site_code ?? ""))}${row.agent_node_id ? `&agent_node_id=${row.agent_node_id}` : ""}`,
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
row.player_id
|
||||
? [
|
||||
{
|
||||
key: "view-player",
|
||||
label: t("viewDetail", { ns: "players" }),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.player_id),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_WALLET_PLAYER_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
@@ -18,7 +17,6 @@ const RECONCILE_PERMS = [
|
||||
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
|
||||
{ href: "/admin/wallet/transactions", label: "subnavTransactions", requiredAny: RECONCILE_PERMS },
|
||||
{ href: "/admin/wallet/transfer-orders", label: "subnavTransferOrders", requiredAny: RECONCILE_PERMS },
|
||||
{ href: "/admin/wallet/player", label: "subnavPlayerWallet", requiredAny: PRD_WALLET_PLAYER_ACCESS_ANY },
|
||||
];
|
||||
|
||||
export function WalletSubnav(): React.ReactElement {
|
||||
|
||||
Reference in New Issue
Block a user