feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics
- 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复
- 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset)
- 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
2026-06-26 14:39:22 +08:00
parent 7d075ceb62
commit 0d984b00f0
126 changed files with 7423 additions and 7084 deletions

View File

@@ -155,10 +155,10 @@ const translations = {
"lineProvision.siteRequired": "Select an integration site",
"lineProvision.noUnboundSite": "No sites without a level-1 agent",
"lineProvision.openIntegrationSites": "Go to integration sites",
"lineProvision.passwordHint": "At least 8 characters",
"lineProvision.passwordHint": "At least 6 characters",
"playersPanel.siteCode": "Line site",
"playersPanel.passwordHint": "At least 8 characters",
"playersPanel.passwordMinLength": "Initial password must be at least 8 characters",
"playersPanel.passwordHint": "At least 6 characters",
"playersPanel.passwordMinLength": "Initial password must be at least 6 characters",
"playersPanel.creditLimitInvalid": "Credit limit must be an integer ≥ 0",
"playersPanel.creditLimitExceeded": "Credit limit cannot exceed this agents available grant",
"playersPanel.rebateRateInvalid": "Rebate rate must be between 0 and 100%",

View File

@@ -1,24 +1,5 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { AgentsDirectoryConsole } from "@/modules/agents/agents-directory-console";
import {
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
PRD_AGENTS_ACCESS_ANY,
} from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = buildPageMetadata("agents", "listTitle");
export default function AgentsListPage() {
return (
<ModuleScaffold embedded>
<AdminPermissionGate
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
>
<AgentsDirectoryConsole />
</AdminPermissionGate>
</ModuleScaffold>
);
}
export default function AgentsListRedirectPage() {
redirect("/admin/agents?view=list");
}

View File

@@ -1,6 +1,6 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { AgentsConsole } from "@/modules/agents/agents-console";
import { AgentsManagementScreen } from "@/modules/agents/agents-management-screen";
import {
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
PRD_AGENTS_ACCESS_ANY,
@@ -16,8 +16,8 @@ export default function AgentsPage() {
<AdminPermissionGate
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
>
<AgentsConsole />
<AgentsManagementScreen />
</AdminPermissionGate>
</ModuleScaffold>
);
}
}

View File

@@ -15,4 +15,4 @@ export default function AdminAuditLogsPage() {
</AdminPermissionGate>
</ModuleScaffold>
);
}
}

View File

@@ -1,5 +1,5 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { DrawSubnav } from "@/modules/draws/draw-subnav";
import { DrawDetailShell } from "@/modules/draws/draw-detail-shell";
export default async function AdminDrawSegmentLayout(props: {
children: React.ReactNode;
@@ -9,8 +9,7 @@ export default async function AdminDrawSegmentLayout(props: {
return (
<ModuleScaffold>
<DrawSubnav drawId={drawId} />
{props.children}
<DrawDetailShell drawId={drawId}>{props.children}</DrawDetailShell>
</ModuleScaffold>
);
}

View File

@@ -1,17 +1,33 @@
import { notFound, redirect } from "next/navigation";
import { buildPageMetadata } from "@/lib/page-metadata";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import type { Metadata } from "next";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { PRD_REPORTS_PAGE_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import { ReportsConsole } from "@/modules/reports/reports-console";
import type { ReportCategory } from "@/modules/reports/reports-definitions";
export const metadata: Metadata = buildPageMetadata("reports", "title");
const VALID_CATEGORIES = new Set<ReportCategory>(["profit", "wallet", "risk", "audit"]);
export default async function AdminReportsCategoryPage({
params,
}: {
params: Promise<{ category: string }>;
}) {
const { category } = await params;
if (!["profit", "wallet", "risk", "audit"].includes(category)) {
if (!VALID_CATEGORIES.has(category as ReportCategory)) {
notFound();
}
redirect("/admin/reports");
}
return (
<AdminPermissionGate requiredAny={PRD_REPORTS_PAGE_ACCESS_ANY}>
<Suspense fallback={<AdminLoadingState minHeight="12rem" />}>
<ReportsConsole initialCategory={category as ReportCategory} />
</Suspense>
</AdminPermissionGate>
);
}

View File

@@ -1,15 +1,5 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
import { ReportsConsole } from "@/modules/reports/reports-console";
import { Suspense } from "react";
import { redirect } from "next/navigation";
export default function AdminReportsPage() {
return (
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
<Suspense fallback={<AdminLoadingState minHeight="12rem" />}>
<ReportsConsole />
</Suspense>
</AdminPermissionGate>
);
}
redirect("/admin/reports/profit");
}

View File

@@ -1,14 +1,12 @@
import type { ReactNode } from "react";
import { WalletScopeHint } from "@/modules/wallet/wallet-scope-hint";
import { WalletSubnav } from "@/modules/wallet/wallet-subnav";
export default function AdminWalletLayout({ children }: { children: ReactNode }) {
return (
<div className="mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
<div className="sticky top-14 z-20 space-y-2 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="sticky top-14 z-20 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<WalletSubnav />
<WalletScopeHint />
</div>
{children}
</div>

View File

@@ -168,6 +168,24 @@
@apply overflow-x-auto rounded-2xl border border-border/80 bg-card shadow-sm;
}
/* Flat table inside an existing card — same white surface, no nested panel or blue header tint */
.admin-table-inset {
@apply overflow-x-auto rounded-xl border border-border/80;
}
.admin-table-inset [data-slot="table-header"] {
@apply bg-card;
}
.admin-table-inset [data-slot="table-head"][class*="sticky"],
.admin-table-inset [data-slot="table-cell"][class*="sticky"] {
background-color: var(--card);
}
.admin-table-inset [data-slot="table-row"]:hover > [data-slot="table-cell"][class*="sticky"] {
@apply bg-muted/20;
}
/* Sticky columns need an opaque background so scrolled cells/headers do not show through */
[data-slot="table-head"][class*="sticky"] {
@apply bg-muted;

View File

@@ -1,20 +1,17 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { cn } from "@/lib/utils";
import { ADMIN_PERMISSION_PACKAGES } from "@/lib/admin-permission-packages";
import {
applyProfileLevel,
permissionProfileForGroup,
resolveProfileLevel,
type PermissionAccessLevel,
type PermissionGroupProfile,
} from "@/lib/admin-permission-profiles";
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
type PackageSelectorProps = {
@@ -22,10 +19,7 @@ type PackageSelectorProps = {
selectedSlugs: string[];
onChange: (next: string[]) => void;
resolveGroupLabel: (key: string, fallback: string) => string;
resolvePackageLabel: (key: string, fallback: string) => string;
selectableSlugs?: string[] | null;
helperText?: string;
summaryText?: string;
isSuperAdmin?: boolean;
emptyText: string;
heightClassName?: string;
};
@@ -33,24 +27,8 @@ type PackageSelectorProps = {
type RenderGroup = {
key: string;
label: string;
packages: Array<{ key: string; label: string; slugs: string[] }>;
};
const PACKAGE_LEVEL_ORDER: Record<string, number> = {
view: 10,
node_view: 10,
role_view: 11,
user_view: 12,
review: 20,
export: 20,
manage: 30,
node_manage: 30,
role_manage: 31,
user_manage: 32,
config: 30,
control: 30,
reopen: 32,
special: 40,
profile: PermissionGroupProfile;
levels: Array<{ key: PermissionAccessLevel; label: string; slugs: string[] }>;
};
export function AdminPermissionPackageSelector({
@@ -58,197 +36,101 @@ export function AdminPermissionPackageSelector({
selectedSlugs,
onChange,
resolveGroupLabel,
resolvePackageLabel,
selectableSlugs = null,
helperText,
summaryText,
isSuperAdmin = false,
emptyText,
heightClassName = "h-[52vh]",
}: PackageSelectorProps): React.ReactElement {
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]);
const { t } = useTranslation("adminUsers");
const catalogSlugSet = useMemo(
() => new Set((catalog?.permissions ?? []).map((permission) => permission.slug)),
[catalog],
);
const allowedSet = useMemo(
() => (selectableSlugs ? new Set(selectableSlugs) : null),
[selectableSlugs],
);
const groups = useMemo<RenderGroup[]>(() => {
const defs = catalog?.permission_menu_groups ?? [];
const out: RenderGroup[] = [];
for (const group of defs) {
const bundles = ADMIN_PERMISSION_PACKAGES[group.key] ?? [];
if (bundles.length === 0) {
const profile = permissionProfileForGroup(group.key);
if (profile === undefined) {
continue;
}
const renderedPackages = bundles
.map((bundle) => {
const slugs = bundle.slugs.filter((slug) => {
if (!catalogSlugSet.has(slug)) {
return false;
}
if (allowedSet && !allowedSet.has(slug)) {
return false;
}
return true;
});
return {
key: bundle.key,
label: resolvePackageLabel(bundle.key, bundle.label),
slugs,
};
})
.filter((bundle) => bundle.slugs.length > 0);
if (renderedPackages.length === 0) {
if (profile.platformOnly && !isSuperAdmin) {
continue;
}
const levels = profile.levels
.map((level) => ({
key: level.key,
label: t(level.labelKey),
slugs: level.slugs.filter((slug) => catalogSlugSet.has(slug)),
}))
.filter((level) => level.key === "none" || level.slugs.length > 0);
if (levels.length <= 1) {
continue;
}
out.push({
key: group.key,
label: resolveGroupLabel(group.key, group.label),
packages: renderedPackages,
profile,
levels,
});
}
return out;
}, [allowedSet, catalog, catalogSlugSet, resolveGroupLabel, resolvePackageLabel]);
}, [catalog, catalogSlugSet, isSuperAdmin, resolveGroupLabel, t]);
const bundleCount = useMemo(
() => groups.reduce((sum, group) => sum + group.packages.length, 0),
[groups],
);
if (groups.length === 0 || bundleCount === 0) {
if (groups.length === 0) {
return (
<div className="rounded-xl border border-dashed p-4">
<AdminNoResourceState compact className="py-4" />
<div className="rounded-lg border border-dashed p-4">
<AdminNoResourceState compact className="py-4" message={emptyText} />
</div>
);
}
const toggleBundle = (group: RenderGroup, bundleKey: string, slugs: string[], checked: boolean) => {
const next = new Set(selectedSet);
const currentLevel = PACKAGE_LEVEL_ORDER[bundleKey] ?? 10;
if (checked) {
const implied = group.packages
.filter((item) => (PACKAGE_LEVEL_ORDER[item.key] ?? 10) <= currentLevel)
.flatMap((item) => item.slugs);
for (const slug of implied.length > 0 ? implied : slugs) {
next.add(slug);
}
for (const item of group.packages) {
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
for (const slug of item.slugs) {
next.delete(slug);
}
}
}
} else {
for (const slug of slugs) {
next.delete(slug);
}
if (bundleKey === "manage" || bundleKey === "reopen") {
for (const item of group.packages) {
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
for (const slug of item.slugs) {
next.delete(slug);
}
}
}
}
}
onChange(Array.from(next).sort());
};
const toggleGroup = (group: RenderGroup, checked: boolean) => {
const next = new Set(selectedSet);
if (checked) {
const viewBundle =
group.packages.find((item) => item.key === "view") ?? group.packages[0];
for (const slug of viewBundle?.slugs ?? []) {
next.add(slug);
}
} else {
for (const slug of group.packages.flatMap((item) => item.slugs)) {
next.delete(slug);
}
}
onChange(Array.from(next).sort());
const setGroupLevel = (group: RenderGroup, levelKey: PermissionAccessLevel) => {
onChange(applyProfileLevel(group.profile, levelKey, selectedSlugs));
};
return (
<div className="space-y-3">
{helperText || summaryText ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<span>{helperText}</span>
<span>{summaryText}</span>
</div>
) : null}
<div className={cn("relative w-full overflow-auto rounded-xl border border-border/60 bg-card", heightClassName)}>
<table className="w-full min-w-full border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-md border-b border-border/50">
<tr>
<th style={{ textAlign: "left" }} className="w-[180px] h-11 px-4 font-semibold text-foreground"></th>
<th style={{ textAlign: "left" }} className="h-11 px-4 font-semibold text-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{groups.map((group) => {
const groupAllSlugs = group.packages.flatMap((p) => p.slugs);
const groupSelectedCount = groupAllSlugs.filter((slug) => selectedSet.has(slug)).length;
const groupChecked = groupSelectedCount === groupAllSlugs.length && groupAllSlugs.length > 0;
<div className={cn("overflow-y-auto rounded-lg border bg-card", heightClassName)}>
<ul className="divide-y divide-border/60">
{groups.map((group) => {
const activeLevel = resolveProfileLevel(group.profile, selectedSlugs);
return (
<tr key={group.key} className="hover:bg-muted/10 transition-colors">
<td style={{ textAlign: "left" }} className="align-top py-4 pl-4">
<label
style={{ display: "flex", justifyContent: "flex-start" }}
className="cursor-pointer items-center gap-2 font-medium w-full"
return (
<li
key={group.key}
className="flex flex-col gap-2.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<span className="min-w-[7rem] shrink-0 text-sm font-medium text-foreground">{group.label}</span>
<div className="flex flex-wrap gap-1.5">
{group.levels.map((level) => {
const active = activeLevel === level.key;
return (
<button
key={`${group.key}.${level.key}`}
type="button"
aria-pressed={active}
className={cn(
"rounded-md border px-2.5 py-1 text-xs transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border/70 bg-background text-foreground hover:bg-muted/50",
)}
onClick={() => setGroupLevel(group, level.key)}
>
<Checkbox
checked={groupChecked}
onCheckedChange={(value) =>
toggleGroup(group, value === true)
}
/>
<span>{group.label}</span>
</label>
</td>
<td style={{ textAlign: "left" }} className="py-4 pl-4">
<div
style={{ display: "flex", justifyContent: "flex-start" }}
className="flex-wrap gap-3 w-full"
>
{group.packages.map((bundle) => {
const checked = bundle.slugs.every((slug) => selectedSet.has(slug));
return (
<label
key={`${group.key}.${bundle.key}`}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted/50",
checked && "border-primary/40 bg-primary/5",
)}
>
<Checkbox
checked={checked}
onCheckedChange={(value) =>
toggleBundle(group, bundle.key, bundle.slugs, value === true)
}
/>
<span>{bundle.label}</span>
</label>
);
})}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{level.label}
</button>
);
})}
</div>
</li>
);
})}
</ul>
</div>
);
}
}

View File

@@ -118,7 +118,7 @@ export function LoginForm() {
const passwordIssue = validateAdminPassword(password);
if (passwordIssue === "too_short") {
toast.error(
t("passwordMinLength", { defaultValue: "密码至少需要 8 个字符" }),
t("passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }),
);
return;
}

View File

@@ -11,7 +11,7 @@
"noRoles": "No roles available yet. Wait for the list to finish loading and try again.",
"password": "Password",
"passwordOptional": "Password (optional)",
"passwordPlaceholderCreate": "At least 8 characters",
"passwordPlaceholderCreate": "at least 6 characters",
"passwordPlaceholderEdit": "Leave empty to keep unchanged",
"rolesDescription": "After creation, adjust per-site role bindings in Assign Roles.",
"rolesRequired": "Roles (at least one)",
@@ -56,9 +56,9 @@
"listTitle": "Admin user list",
"loadFailed": "Failed to load admin list",
"modelGuide": "Accounts bind roles only. Maintain functional permissions in Role Management.",
"newPasswordMin": "New password must be at least 8 characters",
"newPasswordMin": "New password must be at least 6 characters",
"nicknameRequired": "Enter a nickname",
"passwordMin": "Password must be at least 8 characters",
"passwordMin": "Password must be at least 6 characters",
"permissionDialog": {
"rolePermissionCount": "Contains {{count}} functional permissions",
"rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management. Save roles per site.",
@@ -179,9 +179,18 @@
"roleListTitle": "Role Management",
"roleLoadFailed": "Failed to load role list",
"rolePermissionDialog": {
"packageHint": "Checking a module row on the left grants View only. Select Manage separately for data entry, close, draw, and other admin actions.",
"title": "Role Permissions"
"title": "Role permissions",
"titleWithName": "{{name}}"
},
"permissionAccess": {
"none": "No access",
"view": "View only",
"review": "Can review",
"export": "Can export",
"manage": "Can manage",
"special": "Includes privileged actions"
},
"rolePermissionSaveFailed": "Failed to save role permissions",
"rolePermissionSaveSuccess": "Role permissions updated",
"roleRequired": "Select at least one role",
@@ -190,6 +199,7 @@
"actions": "Actions",
"name": "Role",
"permissions": "Permission count",
"enabledAreas": "Enabled modules",
"slug": "Role Code",
"status": "Status",
"type": "Type",

View File

@@ -56,7 +56,7 @@
"noUnboundSite": "No sites without a level-1 agent",
"openIntegrationSites": "Go to integration sites",
"password": "Initial password",
"passwordHint": "At least 8 characters",
"passwordHint": "at least 6 characters",
"secretsOnce": "Integration secrets are shown once — save them now",
"siteCode": "Integration site",
"siteCodePlaceholder": "Select site",
@@ -82,7 +82,8 @@
"downlineEmptyShort": "No direct downline yet.",
"downlineEmptyTitle": "No direct downline yet",
"editAccount": "Account & status",
"editAgent": "Edit agent",
"editAgent": "Edit",
"parentMeta": "Parent {{name}}",
"editCurrent": "Edit this agent",
"expand": "Expand",
"kicker": "Credit share · Agent tree",
@@ -112,6 +113,8 @@
"tabDownline": "Downline",
"tabOverview": "Overview",
"tabPlayers": "Players",
"tabProfileShort": "Share & credit",
"openInTree": "Open in tree",
"createDirectPlayer": "Create direct player",
"createDownline": "Create child agent",
"editDownline": "Edit agent",
@@ -125,6 +128,11 @@
},
"listFlatHint": "All operating agents in a flat list. Use row actions to add a child under a specific agent.",
"listSearch": "Search name / code / login",
"viewModes": {
"label": "View",
"tree": "Tree",
"list": "List"
},
"listTitle": "Agents",
"loadFailed": "Failed to load agent tree",
"modelGuide": "Agent layer controls data scope and delegation ceiling. Account permissions are assigned through roles.",
@@ -134,9 +142,9 @@
"noAccess": "You do not have permission to manage agents. Contact an administrator.",
"pageGuide": "Manage the agent tree, agent roles, agent accounts, and delegation ceilings here. Platform accounts and platform roles stay in platform governance pages.",
"parentAgent": "Parent",
"passwordMinLength": "Password must be at least 8 characters",
"passwordOptionalHint": "Leave empty to keep unchanged, or enter an 8-character password",
"passwordPlaceholder": "Enter an 8-character password",
"passwordMinLength": "Password must be at least 6 characters",
"passwordOptionalHint": "Leave empty to keep unchanged, or enter a 6-character password",
"passwordPlaceholder": "Enter a 6-character password",
"passwordRequired": "Password is required",
"path": "Path",
"playersPanel": {
@@ -157,8 +165,8 @@
"initialPassword": "Initial password",
"loginRequired": "Enter login username and initial password",
"loginUsername": "Login username",
"passwordHint": "At least 8 characters",
"passwordMinLength": "Initial password must be at least 8 characters",
"passwordHint": "at least 6 characters",
"passwordMinLength": "Initial password must be at least 6 characters",
"playerRef": "Player ref",
"rebateInherited": "Inherit agent default rebate",
"rebateRate": "Rebate rate (%)",
@@ -168,7 +176,11 @@
"riskTagsPlaceholder": "Comma-separated",
"scopedTo": "Direct players: {{agent}}",
"siteCode": "Line site",
"usernameNickname": "Username / nickname"
"usernameNickname": "Username / nickname",
"searchPh": "Search player account / ID",
"manageSettlement": "Process bill",
"noPendingBillsGoCenter": "No pending bills for this player. Opened settlement center.",
"billingLoadFailed": "Failed to load bills"
},
"profile": {
"availableCredit": "Available to grant {{amount}}",

View File

@@ -1,20 +1,37 @@
{
"title": "Audit Logs",
"moduleCode": "Module code",
"actionCode": "Action code",
"operatorType": "Operator type",
"operatorIdPlaceholder": "Enter operator ID",
"exactMatch": "Exact match",
"operatorTypePlaceholder": "For example admin / system",
"operator": "Operator",
"module": "Module",
"action": "Action",
"target": "Target",
"title": "Activity Log",
"filterModule": "Business area",
"filterModuleAll": "All areas",
"filterOperatorType": "Actor type",
"filterOperatorTypeAll": "All",
"operatorIdPlaceholder": "Optional operator ID",
"operator": "Actor",
"summary": "What happened",
"target": "Related to",
"time": "Time",
"empty": "No data",
"empty": "No records",
"moduleOptions": {
"agent": "Agents",
"system": "System admin",
"settings": "Settings",
"integration": "Integration sites",
"player_manage": "Player management",
"player_service": "Player service",
"risk_cap": "Risk caps",
"odds": "Odds",
"play_config": "Play config",
"settlement": "Settlement",
"report_jobs": "Report export",
"reconcile_jobs": "Reconcile jobs",
"draw": "Draws",
"wallet": "Wallet",
"reconcile": "Reconcile",
"jackpot": "Jackpot",
"dashboard": "Dashboard"
},
"operatorTypes": {
"admin": "Admin",
"admin": "Admin user",
"player": "Player",
"system": "System"
}
}
}

View File

@@ -19,6 +19,7 @@
"captchaLoadFailed": "Failed to load captcha. Check the API or network.",
"apiBaseMissingToast": "API proxy is not enabled: check LOTTERY_API_UPSTREAM points to Laravel",
"captchaRequired": "Refresh the captcha first",
"passwordMinLength": "Password must be at least 6 characters",
"welcome": "Welcome, {{name}}",
"networkFailed": "Network request failed",
"loginFailed": "Login failed"

View File

@@ -51,7 +51,8 @@
},
"validation": {
"required": "{{field}} is required",
"passwordMismatch": "Passwords do not match"
"passwordMismatch": "Passwords do not match",
"passwordMinLength": "Password must be at least 6 characters"
},
"aria": {
"expand": "Expand",
@@ -156,7 +157,7 @@
"nav": {
"home": "Home",
"dashboard": "Dashboard",
"admin_users": "Admin Users",
"admin_users": "Platform accounts",
"admin_roles": "Role Management",
"players": "Players",
"currencies": "Currencies",
@@ -171,21 +172,20 @@
"settlement": "Settlement",
"jackpot": "Jackpot",
"reconcile": "Reconcile",
"tickets": "Ticket list",
"tickets": "Tickets",
"audit": "Audit Logs",
"settings": "Settings",
"account": "Account settings",
"integration": "Integration",
"agents": "Agent lines",
"agent_list": "Agent list",
"settlement_center": "Settlement center",
"agents": "Agent management",
"settlement_center": "Credit settlement",
"config": "Operations config"
},
"sidebar": {
"workspace": "Workspace",
"group": {
"overview": "Overview",
"agent": "Agent organization",
"agent": "Agents",
"operations": "Operations",
"finance": "Finance & reports",
"rules": "Rules & parameters",

View File

@@ -133,7 +133,7 @@
},
"form": {
"adminNicknameRequired": "Site admin nickname is required",
"adminPasswordRequired": "Initial site admin password must be at least 8 characters",
"adminPasswordRequired": "Initial site admin password must be at least 6 characters",
"adminUsernameRequired": "Site admin username is required",
"codeRequired": "site_code is required",
"required": "Site name is required"
@@ -147,7 +147,7 @@
"placeholders": {
"adminEmail": "Enter email",
"adminNickname": "Enter account nickname",
"adminPassword": "At least 8 characters",
"adminPassword": "at least 6 characters",
"adminUsername": "Enter admin username",
"code": "Enter site identifier, for example partner-a",
"connectivityPlayerId": "Enter player ID, for example 10001",
@@ -527,10 +527,12 @@
}
},
"system": {
"pageTitle": "System settings",
"confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.",
"confirmSaveCurrencyFormatTitle": "Save currency display format?",
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
"confirmSaveDrawDescription": "This updates draw review, schedule timing, and cooldown in this block only.",
"confirmSaveDrawDescription": "This updates manual draw review and cooldown duration only.",
"confirmSaveDrawTitle": "Save draw parameters?",
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed.",
"confirmSaveFrontendTitle": "Save front-end display settings?",
@@ -591,12 +593,33 @@
"saveSettlementSuccess": "Settlement automation saved",
"saveSuccess": "System settings saved",
"sections": {
"deployment": "Deployment (read-only)",
"deploymentDescription": "Default currency and draw schedule are set in server .env; this block shows effective values.",
"currencyFormat": "Currency display format",
"currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).",
"draw": "Draw schedule and review",
"drawDescription": "Controls draw timing, close window, manual review, and cooldown. Only changed fields in this block are submitted.",
"draw": "Draw review",
"drawDescription": "Manual review after RNG and post-publish cooldown.",
"settlement": "Settlement automation",
"settlementDescription": "Controls whether tick auto-runs settlement, approval, and payout. Only changed fields in this block are submitted."
"settlementDescription": "In order: auto settlement → auto approve → auto payout. When off, handle manually in the relevant module."
},
"deployment": {
"footer": "Restart PHP or refresh config cache after changes. Contact ops if unsure.",
"fields": {
"default_currency": "Default currency",
"draw_timezone": "Draw timezone",
"draw_interval_minutes": "Draw interval (minutes)",
"draw_betting_window_seconds": "Betting window (seconds)",
"draw_close_before_draw_seconds": "Close before draw (seconds)",
"draw_buffer_draws_ahead": "Pre-generated future draws"
},
"hints": {
"default_currency": "Default for new players when currency is unspecified",
"draw_timezone": "Timezone for draw schedule and display",
"draw_interval_minutes": "Minutes between consecutive draw times",
"draw_betting_window_seconds": "How long betting stays open each draw",
"draw_close_before_draw_seconds": "Seconds before draw time to stop accepting bets",
"draw_buffer_draws_ahead": "How many future draws the scheduler pre-creates"
}
},
"states": {
"disabled": "Disabled",
@@ -630,19 +653,17 @@
"count": "{{count}} items",
"current": "Current",
"delete": "Delete",
"deleteConfirmDescription": "Version ID {{id}} (version_no {{version}}) will be permanently deleted. Active versions cannot be deleted.",
"deleteConfirmDescription": "Version v{{version}} will be deleted. This cannot be undone.",
"deleteConfirmTitle": "Delete this version?",
"effectiveAt": "Effective at: {{value}}",
"empty": "No version records yet.",
"loading": "Loading…",
"moreActions": "More actions for v{{version}}",
"noneSelected": "No version selected",
"noneSelected": "Select version",
"note": "Note: {{value}}",
"rollback": "Rollback",
"selected": "Selected",
"sheetDescription": "Choose a version to view on this page. Drafts are editable, while active and archived versions are read-only.",
"sheetTitle": "Switch configuration version",
"switch": "Switch version",
"sheetTitle": "Versions",
"view": "View"
},
"versionToolbar": {

View File

@@ -70,8 +70,8 @@
"batchSummaryPending": "{{count}} pending",
"batchSummaryPublished": "{{count}} published",
"noResultBatchesYet": "No result batches yet.",
"reviewQueueHint": "After results are generated, continue in Review & publish.",
"goToReviewTab": "Review & publish",
"reviewQueueHint": "",
"goToReviewTab": "Publish",
"businessDate": "Business date",
"sequenceNo": "Sequence no.",
"plannedDraw": "Planned draw",
@@ -96,12 +96,12 @@
"published": "Published",
"viewFinance": "View draw finance",
"drawActions": "Draw actions",
"drawActionsDesc": "Manual close / cancel / RNG / reopen / settlement all call backend APIs directly.",
"drawActionsDesc": "",
"manualClose": "Manual close",
"cancelDraw": "Cancel draw",
"cancelBeforeDraw": "Cancel before draw",
"rngDraw": "RNG draw",
"rngAutoGenerate": "RNG auto generate",
"rngAutoGenerate": "Auto draw",
"reopen": "Reopen",
"cooldownReopen": "Reopen in cooldown",
"runSettlement": "Run settlement",
@@ -114,8 +114,8 @@
"actualBet": "Actual bet deducted",
"currentPayout": "Current payout total",
"grossProfit": "Approx. gross profit",
"settlementBatchList": "Settlement batch list (filter by draw)",
"relatedSettlementBatches": "Related settlement batches",
"settlementBatchList": "Settlement records",
"relatedSettlementBatches": "Settlement batches",
"noSettlementBatches": "No settlement batch records.",
"ticketCount": "Tickets",
"winCount": "Wins",
@@ -124,7 +124,7 @@
"resultsTitle": "Results",
"reviewAndPublish": "Review / publish",
"viewReviewQueue": "View review queue",
"noPublishedBatch": "No published batches.",
"noPublishedBatch": "None",
"version": "Version v{{version}}",
"sourceType": "Source {{source}}",
"manualEntry": "Manual",
@@ -135,28 +135,28 @@
"tail3": "Last 3",
"tail2": "Last 2",
"headTail": "Head/Tail",
"manualResultEntry": "Manual result entry",
"currentStatusAndDraft": "Current status {{status}}. Saving creates a pending batch and does not publish it.",
"currentStatusLabel": "Current status",
"currentStatusDraftHint": "Saving creates a pending batch and does not publish it.",
"manualResultEntry": "Enter numbers",
"currentStatusAndDraft": "",
"currentStatusLabel": "Status",
"currentStatusDraftHint": "",
"hallPreviewStatusLabel": "Hall preview",
"enter23Numbers": "Please enter all 23 groups of 4 digits",
"draftSaved": "Draft v{{version}} saved, waiting to be published",
"draftSaved": "Saved v{{version}}",
"saveFailed": "Failed to save",
"fillRandomNumbers": "Fill randomly",
"clear": "Clear",
"saveDraft": "Save draft",
"saving": "Saving…",
"pendingBatches": "Pending batches",
"noPendingBatches": "There are no batches pending review.",
"batchId": "Batch ID",
"numberCount": "Number count",
"reviewAndPublishAction": "Review and publish",
"discardPendingBatch": "Delete draft",
"pendingBatches": "Pending publish",
"noPendingBatches": "None",
"batchId": "Batch",
"numberCount": "Numbers",
"reviewAndPublishAction": "Publish",
"discardPendingBatch": "Delete",
"discardingPendingBatch": "Deleting…",
"discardPendingBatchSuccess": "Pending batch removed. You can re-enter numbers or run RNG.",
"discardPendingBatchFailed": "Delete failed",
"publishReadOnlyHint": "This page is read-only for verification. To change numbers, delete this batch and save a new draft under manual entry.",
"publishReadOnlyHint": "",
"noPublishPermission": "No publish permission",
"batchNotFound": "Batch not found",
"batchNotFoundDesc": "Return to the review list and confirm the batch ID.",
@@ -164,8 +164,8 @@
"publishTitle": "Publish",
"cannotPublish": "Cannot publish",
"cannotPublishDesc": "Current batch status is '{{status}}'.",
"checkBeforePublish": "Check the numbers before publishing",
"checkBeforePublishDesc": "Publish only after confirming the numbers.",
"checkBeforePublish": "",
"checkBeforePublishDesc": "",
"publishedView": "View published result",
"confirmPublish": "Confirm publish",
"submitting": "Submitting…",
@@ -173,10 +173,10 @@
"publishFailed": "Publish failed",
"sourceTypeFull": "Source: {{source}} · Items: {{count}}/23 · RNG hash: {{hash}}",
"subnav": {
"status": "Draw status",
"status": "Overview",
"results": "Results",
"finance": "Draw finance",
"review": "Review & publish",
"finance": "Finance",
"review": "Publish",
"riskOccupancy": "Risk occupancy",
"riskLockLogs": "Lock logs",
"riskHot": "Hot numbers",

View File

@@ -1,7 +1,10 @@
{
"title": "Reconcile",
"createTitle": "Run reconcile scan",
"createHint": "Scans transfer orders in the selected period, compares lottery wallet ledgers, and checks main-site idempotent records when the wallet API is configured.",
"workflowHint": "Run dated reconcile scans here. For live unresolved exceptions, use the link above (opens Wallet → Transfer orders with abnormal-only filter).",
"shortcutAbnormalTransfers": "Live abnormal transfers",
"viewOnlyHint": "View-only: historical reconcile jobs.",
"createTitle": "Run scan",
"createHint": "",
"reconcileType": "Reconcile type",
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
"dateRange": "Reconcile date range",
@@ -12,13 +15,18 @@
"periodRequired": "Enter both reconcile start and end dates",
"periodOrderInvalid": "End time must be later than or equal to start time",
"confirmCreateTitle": "Start reconcile scan?",
"confirmCreateDescription": "Scan transfer orders in the selected date range{{playerHint}} and generate discrepancy items.",
"confirmCreateDescription": "Scan transfer orders in the selected period{{playerHint}}.",
"confirmCreatePlayer": " for the selected player",
"confirmCreateAllPlayers": " for all players",
"createSuccess": "Scan finished: {{count}} issue(s) found",
"createSuccessEmpty": "Scan finished: no issues found",
"createFailed": "Scan failed",
"noCreatePermission": "Current account cannot start reconcile scans.",
"filterOpen": "Open only",
"filterAll": "All items",
"filterCount": "{{shown}} on page / {{total}} total",
"filterOpenEmpty": "No open items on this page. Try All items or another page.",
"playerEmpty": "No matching players",
"jobsTitle": "Reconcile jobs",
"refresh": "Refresh",
"jobNo": "Job no.",
@@ -31,13 +39,22 @@
"finishedAt": "Finished at",
"createdAt": "Created at",
"operate": "Action",
"viewDetails": "View discrepancy details",
"viewDetails": "View details",
"hideDetails": "Hide",
"closeDetails": "Close",
"detailsTitle": "Discrepancy details",
"detailsEmpty": "No items",
"itemStatusColumn": "Status",
"itemIssueColumn": "Issue type",
"itemHandlingColumn": "Handling",
"itemScanFindingColumn": "Scan finding",
"itemCurrentStatusColumn": "Current status",
"itemScanFindingResolvedHint": "Recorded at scan; now handled",
"transferNo": "Transfer no.",
"walletTxnNo": "Lottery wallet txn",
"mainSiteRef": "Main-site ref",
"mainSiteCheck": "Main-site check",
"differenceAmount": "Difference (minor)",
"differenceAmount": "Difference",
"itemResult": "Check result",
"processingStatus": "Processing status",
"actions": "Actions",

View File

@@ -1,6 +1,12 @@
{
"title": "Reports",
"subtitle": "Centralized operational, finance, risk, and audit reports with unified export filters.",
"subtitle": "",
"profitScopeHint": "Ticket-level totals; not credit settlement periods",
"shortcuts": {
"wallet": "Transfer orders",
"risk": "Risk center",
"audit": "Audit logs"
},
"pageGuide": "P&L and risk by draw, player, and play type; export permission enables async jobs.",
"exportPanel": "Export setup",
"chooseReport": "Choose a report to export",
@@ -86,8 +92,12 @@
},
"preview": {
"title": "Preview",
"subtitle": "Results appear below. Export as CSV or Excel.",
"empty": "No data. Adjust filters and try again.",
"subtitle": "",
"empty": "No data",
"sections": {
"settlementBatches": "Settlement batches",
"lockLogs": "Lock logs"
},
"exportableRows": "rows exportable",
"summaryScopeHint": "Except for the total record count, the stat cards above summarize the current preview page. Use full CSV/Excel export for full-range numbers.",
"scope": {

View File

@@ -38,6 +38,7 @@
"unsettledTickets": "Unsettled tickets",
"openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.",
"viewDetail": "View details",
"processBills": "Process bills",
"close": "Close",
"closeNow": "Close now",
"hasOpen": "Period {{range}} is open. Close it before opening a new one.",
@@ -66,6 +67,7 @@
"periods": "Periods",
"bills": "Bills",
"operations": "Payments & adjustments",
"aria": "Period views",
"ledger": "Account ledger",
"creditLedger": "Credit ledger",
"playerBills": "Player bills",
@@ -213,7 +215,8 @@
"actions": {
"detail": "Detail",
"viewBill": "View bill",
"billDetail": "Bill detail"
"billDetail": "Bill detail",
"billDetailWithId": "Bill #{{id}}"
},
"billDisplay": {
"settlementFlow": "Who pays whom",
@@ -250,6 +253,7 @@
"unpaidAwaitingPayment": "Record offline payment",
"fullySettled": "Fully settled this period",
"confirmHint": "Confirm the bill before recording payment.",
"advancedActions": "Adjust / bad debt",
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
"rebateAllocationsHint": "How rebate is allocated across agent tiers.",
@@ -282,6 +286,12 @@
},
"billsPanel": {
"intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.",
"confirmOneTitle": "Confirm bill #{{id}}?",
"confirmOneDesc": "After confirm, record offline payment.",
"confirmOneBtn": "Confirm",
"confirmedOne": "Confirmed",
"confirmFailed": "Confirm failed",
"payBtn": "Pay",
"hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down.",
"quickFilter": {
"title": "Which settlement layer do you want to review",

View File

@@ -3,9 +3,7 @@
"subnavLabel": "Wallet sub pages",
"subnavTransactions": "Main-site wallet txns",
"subnavTransferOrders": "Main-site transfers",
"scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line period settlement, see",
"scopeHintSettlementLink": "Settlement center",
"scopeHintSettlement": "Settlement center",
"ledgerChannel": "Ledger",
"ledgerCredit": "Credit ledger",
"ledgerWallet": "Wallet txn",
@@ -43,6 +41,9 @@
"options": "Options",
"abnormalOnly": "Abnormal only",
"abnormalOnlyPending": "Abnormal only (pending reconcile)",
"abnormalFilterActive": "Showing transfer orders flagged by reconciliation (not wallet transactions). Uncheck “Abnormal only” below to see all.",
"txnAbnormalFilterActive": "Showing only pending-reconcile transactions. Status filter is ignored while this is on (same as abnormal=1).",
"txnDeepLinkActive": "Filters were applied from the link. Reset clears URL parameters.",
"search": "Search",
"resetFilters": "Reset filters",
"refreshCurrentPage": "Refresh current page",

View File

@@ -11,7 +11,7 @@
"noRoles": "अहिले भूमिका डाटा छैन। सूची लोड भएपछि फेरि प्रयास गर्नुहोस्।",
"password": "पासवर्ड",
"passwordOptional": "पासवर्ड (वैकल्पिक)",
"passwordPlaceholderCreate": "कम्तीमा 8 वर्ण",
"passwordPlaceholderCreate": "कम्तीमा 6 वर्ण",
"passwordPlaceholderEdit": "परिवर्तन नगर्न खाली छोड्नुहोस्",
"rolesDescription": "सिर्जना भएपछि \"भूमिका तोक्नुहोस्\" मा गएर भूमिका बाइन्डिङ थप समायोजन गर्न सकिन्छ।",
"rolesRequired": "भूमिका (पूर्वनिर्धारित साइट, कम्तीमा एक)",
@@ -46,9 +46,9 @@
"listTitle": "प्रशासक सूची",
"loadFailed": "प्रशासक सूची लोड असफल भयो",
"modelGuide": "खाता तहमा भूमिका मात्र बाँधिन्छ; कार्य अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
"newPasswordMin": "नयाँ पासवर्ड कम्तीमा 8 वर्ण हुनुपर्छ",
"newPasswordMin": "नयाँ पासवर्ड कम्तीमा 6 वर्ण हुनुपर्छ",
"nicknameRequired": "उपनाम लेख्नुहोस्",
"passwordMin": "पासवर्ड कम्तीमा 8 वर्ण हुनुपर्छ",
"passwordMin": "पासवर्ड कम्तीमा 6 वर्ण हुनुपर्छ",
"permissionDialog": {
"rolePermissionCount": "{{count}} वटा कार्य अनुमति समावेश",
"rolesDescription": "यहाँ प्रशासकलाई भूमिका मात्र जोडिन्छ। विस्तृत अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
@@ -168,8 +168,18 @@
"roleListTitle": "भूमिका व्यवस्थापन",
"roleLoadFailed": "भूमिका सूची लोड असफल भयो",
"rolePermissionDialog": {
"title": "भूमिका अनुमति"
"title": "भूमिका अनुमति",
"titleWithName": "{{name}}"
},
"permissionAccess": {
"none": "चाहिँदैन",
"view": "हेर्न मात्र",
"review": "समीक्षा",
"export": "निर्यात",
"manage": "व्यवस्थापन",
"special": "विशेष अधिकार"
},
"rolePermissionSaveFailed": "भूमिका अनुमति सुरक्षित गर्न असफल",
"rolePermissionSaveSuccess": "भूमिका अनुमति अद्यावधिक भयो",
"roleRequired": "कम्तीमा एउटा भूमिका छान्नुहोस्",
@@ -178,7 +188,8 @@
"actions": "कार्य",
"name": "भूमिका",
"permissions": "अनुमति संख्या",
"slug": "角色 कोड",
"enabledAreas": "खुला मोड्युल",
"slug": "भूमिका कोड",
"status": "स्थिति",
"type": "प्रकार",
"users": "सम्बन्धित प्रयोगकर्ता"

View File

@@ -100,6 +100,11 @@
},
"listFlatHint": "सबै सञ्चालन एजेन्ट सूचीमा; अधीनस्थ थप्न पङ्क्ति मेनु प्रयोग गर्नुहोस्।",
"listSearch": "नाम / कोड / लगइन खोज्नुहोस्",
"viewModes": {
"label": "दृश्य",
"tree": "रूख",
"list": "सूची"
},
"listTitle": "एजेन्ट सूची",
"loadFailed": "Failed to load agent tree",
"modelGuide": "एजेन्ट तहले डाटा स्कोप र delegation ceiling नियन्त्रण गर्छ; खाताको अनुमति भूमिका मार्फत बाँडिन्छ।",
@@ -109,9 +114,9 @@
"noAccess": "एजेन्ट सञ्चालन अनुमति छैन। प्रशासकलाई सम्पर्क गर्नुहोस्।",
"pageGuide": "यहाँ एजेन्ट ट्री, एजेन्ट भूमिका, एजेन्ट खाता र delegation ceiling व्यवस्थापन गरिन्छ। प्लेटफर्म खाता र प्लेटफर्म भूमिका अलग पृष्ठमा राखिन्छ।",
"parentAgent": "माथिल्लो",
"passwordMinLength": "Password must be at least 8 characters",
"passwordOptionalHint": "परिवर्तन नगर्ने भए खाली छोड्नुहोस्, परिवर्तन गर्न -अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
"passwordPlaceholder": "-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
"passwordMinLength": "Password must be at least 6 characters",
"passwordOptionalHint": "परिवर्तन नगर्ने भए खाली छोड्नुहोस्, परिवर्तन गर्न कम्तीमा ६-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
"passwordPlaceholder": "कम्तीमा ६-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
"passwordRequired": "Password is required",
"path": "Path",
"playersPanel": {

View File

@@ -1,20 +1,37 @@
{
"title": "अडिट लग",
"moduleCode": "मोड्युल कोड",
"actionCode": "कार्य कोड",
"operatorType": "अपरेटर प्रकार",
"operatorIdPlaceholder": "अपरेटर ID प्रविष्ट गर्नुहोस्",
"exactMatch": "ठ्याक्कै मिलान",
"operatorTypePlaceholder": "जस्तै admin / system",
"title": "सञ्चालन रेकर्ड",
"filterModule": "व्यवसाय प्रकार",
"filterModuleAll": "सबै",
"filterOperatorType": "अपरेटर प्रकार",
"filterOperatorTypeAll": "सबै",
"operatorIdPlaceholder": "अपरेटर ID थाहा भए मात्र",
"operator": "अपरेटर",
"module": "मोड्युल",
"action": "कार्य",
"target": "लक्ष्य",
"summary": "के भयो",
"target": "सम्बन्धित",
"time": "समय",
"empty": "डाटा छैन",
"empty": "रेकर्ड छैन",
"moduleOptions": {
"agent": "एजेन्ट",
"system": "प्रणाली",
"settings": "सेटिङ",
"integration": "इन्टिग्रेसन",
"player_manage": "खेलाडी व्यवस्थापन",
"player_service": "खेलाडी सेवा",
"risk_cap": "जोखिम सीमा",
"odds": "बाधा",
"play_config": "खेल कन्फिग",
"settlement": "बन्दोबस्त",
"report_jobs": "रिपोर्ट",
"reconcile_jobs": "मिलान",
"draw": "ड्र",
"wallet": "वालेट",
"reconcile": "मिलान",
"jackpot": "ज्याकपट",
"dashboard": "ड्यासबोर्ड"
},
"operatorTypes": {
"admin": "प्रशासक",
"player": "खेलाडी",
"system": "प्रणाली"
}
}
}

View File

@@ -19,6 +19,7 @@
"captchaLoadFailed": "क्याप्चा लोड गर्न सकिएन। API वा नेटवर्क जाँच गर्नुहोस्।",
"apiBaseMissingToast": "API proxy सक्षम छैन: LOTTERY_API_UPSTREAM Laravel तर्फ छ कि छैन जाँच गर्नुहोस्",
"captchaRequired": "पहिले क्याप्चा रिफ्रेस गर्नुहोस्",
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
"welcome": "स्वागत छ, {{name}}",
"networkFailed": "नेटवर्क अनुरोध असफल भयो",
"loginFailed": "लगइन असफल भयो"

View File

@@ -165,9 +165,8 @@
"nav": {
"account": "खाता सेटिङ",
"admin_roles": "भूमिका व्यवस्थापन",
"admin_users": "प्रशासक सूची",
"agent_list": "एजेन्ट सूची",
"agents": "एजेन्ट लाइन",
"admin_users": "प्लेटफर्म खाता",
"agents": "एजेन्ट व्यवस्थापन",
"audit": "अडिट लग",
"config": "सञ्चालन कन्फिगरेसन",
"currencies": "मुद्रा व्यवस्थापन",
@@ -186,8 +185,8 @@
"rules_plays": "खेल नियम",
"settings": "सेटिङ",
"settlement": "सेटलमेन्ट",
"settlement_center": "सेटलमेन्ट केन्द्र",
"tickets": "टिकट सूची",
"settlement_center": "क्रेडिट सेटलमेन्ट",
"tickets": "टिकट",
"wallet": "वालेट"
},
"pagination": {
@@ -217,9 +216,9 @@
"securitySettingsDesc": "लगइन पासवर्ड परिवर्तन गर्नुहोस्। नपरिवर्तन गर्दा खाली छोड्नुहोस्।",
"sidebar": {
"group": {
"agent": "एजेन्ट संगठन",
"agent": "एजेन्ट",
"finance": "वित्त र रिपोर्ट",
"operations": "दैनिक सञ्चालन",
"operations": "सञ्चालन",
"overview": "सारांश",
"platform": "प्लेटफर्म",
"rules": "नियम र प्यारामिटर"
@@ -257,6 +256,7 @@
},
"validation": {
"passwordMismatch": "पासवर्ड मिलेन",
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
"required": "{{field}} अनिवार्य छ"
}
}

View File

@@ -518,6 +518,7 @@
}
},
"system": {
"pageTitle": "प्रणाली सेटिङ",
"confirmSaveCurrencyFormatDescription": "यसले दशमलव स्थान र विभाजक अद्यावधिक गर्छ।",
"confirmSaveCurrencyFormatTitle": "मुद्रा प्रदर्शन ढाँचा बचत गर्ने?",
"confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।",
@@ -582,12 +583,33 @@
"saveSettlementSuccess": "सेटलमेन्ट स्वचालन बचत भयो",
"saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो",
"sections": {
"deployment": "डिप्लोयमेन्ट (पढ्न मात्र)",
"deploymentDescription": "पूर्वनिर्धारित मुद्रा र ड्र तालिका सर्भर .env मा सेट हुन्छ; यहाँ प्रभावी मान देखिन्छ।",
"currencyFormat": "मुद्रा प्रदर्शन ढाँचा",
"currencyFormatDescription": "साइटभरि रकमका दशमलव र विभाजक (मुद्रा मास्टर डाटाबाट अलग)।",
"draw": "ड्र तालिका र समीक्षा",
"drawDescription": "ड्र समय, बन्द सञ्झ्याल, म्यानुअल समीक्षा र कूलडाउन नियन्त्रण। यो ब्लकमा परिवर्तित फिल्ड मात्र पेश हुन्छ।",
"draw": "ड्र समीक्षा",
"drawDescription": "RNG पछि म्यानुअल समीक्षा र प्रकाशनपछि कूलडाउन।",
"settlement": "सेटलमेन्ट स्वचालन",
"settlementDescription": "टिक स्वतः सेटलमेन्ट, अनुमोदन भुक्तानी चलाउने नियन्त्रण। यो ब्लकमा परिवर्तित फिल्ड मात्र पेश हुन्छ।"
"settlementDescription": "क्रम: स्वतः सेटलमेन्ट → स्वतः अनुमोदन → स्वतः भुक्तानी। बन्द हुँदा सम्बन्धित मोड्युलमा म्यानुअल गर्नुहोस्।"
},
"deployment": {
"footer": "परिवर्तनपछि PHP पुनः सुरु वा config cache refresh गर्नुहोस्।",
"fields": {
"default_currency": "पूर्वनिर्धारित मुद्रा",
"draw_timezone": "ड्रअ समय क्षेत्र",
"draw_interval_minutes": "ड्रअ अन्तराल (मिनेट)",
"draw_betting_window_seconds": "बेटिङ विन्डो (सेकेन्ड)",
"draw_close_before_draw_seconds": "ड्रअ अघि बन्द (सेकेन्ड)",
"draw_buffer_draws_ahead": "अग्रिम सिर्जना गरिने ड्रअ"
},
"hints": {
"default_currency": "नयाँ खेलाडीको पूर्वनिर्धारित मुद्रा",
"draw_timezone": "ड्रअ तालिका र प्रदर्शनको समय क्षेत्र",
"draw_interval_minutes": "लगातार ड्रअ बीचको मिनेट",
"draw_betting_window_seconds": "प्रति ड्रअ बेटिङ खुला रहने समय",
"draw_close_before_draw_seconds": "ड्रअ अघि बेट बन्द हुने सेकेन्ड",
"draw_buffer_draws_ahead": "अग्रिम सिर्जना गरिने भविष्य ड्रअ संख्या"
}
},
"states": {
"disabled": "बन्द",

View File

@@ -6,10 +6,10 @@
"loadFailed": "加载管理员列表失败",
"roleLoadFailed": "加载角色列表失败",
"nicknameRequired": "请填写昵称",
"newPasswordMin": "新密码至少 8 位",
"newPasswordMin": "新密码至少 6 位",
"roleRequired": "请至少选择一个角色",
"usernameRequired": "请填写登录账号",
"passwordMin": "密码至少 8 位",
"passwordMin": "密码至少 6 位",
"createSuccess": "已创建管理员 {{name}}",
"updateSuccess": "已更新 {{name}}",
"saveAccountFailed": "保存账号失败",
@@ -69,6 +69,7 @@
"status": "状态",
"users": "关联用户",
"permissions": "权限数",
"enabledAreas": "开放模块",
"actions": "操作"
},
"roleActions": {
@@ -101,8 +102,17 @@
},
"rolePermissionDialog": {
"title": "角色权限",
"packageHint": "勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。"
"titleWithName": "{{name}}"
},
"permissionAccess": {
"none": "不需要",
"view": "仅查看",
"review": "可审核",
"export": "可导出",
"manage": "可管理",
"special": "含特权操作"
},
"roleDialog": {
"createTitle": "新增角色",
"editTitle": "编辑角色",
@@ -128,7 +138,7 @@
"emailPlaceholder": "留空则不填",
"password": "密码",
"passwordOptional": "密码(可选)",
"passwordPlaceholderCreate": "至少 8 位",
"passwordPlaceholderCreate": "至少 6 位",
"passwordPlaceholderEdit": "不修改请留空",
"site": "绑定站点",
"sitePlaceholder": "选择该账号可访问的数据站点",
@@ -159,7 +169,7 @@
"permissionGroups": {
"all": "全部权限",
"dashboard": "仪表盘",
"admin_users": "管理列表",
"admin_users": "平台账号",
"admin_roles": "角色管理",
"agents": "代理管理",
"players": "玩家列表",

View File

@@ -20,6 +20,7 @@
"overviewDownlineCount": "{{count}} 个",
"downlineEmptyTitle": "暂无直属下级",
"tabProfile": "占成与授信",
"tabProfileShort": "占成授信",
"tabProfileReadOnly": "占成与授信(只读)",
"profileReadOnlyHint": "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
"selfAgentOverviewHint": "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
@@ -42,7 +43,8 @@
"downlineCount": "下级数"
},
"editAccount": "账号与状态",
"editAgent": "编辑代理",
"editAgent": "编辑",
"parentMeta": "上级 {{name}}",
"editCurrent": "编辑本代理",
"saveProfile": "保存占成与授信",
"profileTabHint": "占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
@@ -56,7 +58,13 @@
"sidebarShareRate": "占成 {{rate}}%",
"sidebarAvailableCredit": "可下发 {{amount}}",
"expand": "展开",
"collapse": "收起"
"collapse": "收起",
"openInTree": "在树中打开"
},
"viewModes": {
"label": "视图",
"tree": "树形",
"list": "列表"
},
"listTitle": "代理列表",
"listSearch": "搜索代理名称 / 编码 / 登录名",
@@ -132,7 +140,7 @@
"usernameRequired": "请填写登录名",
"usernameInvalidCharset": "登录名只能使用字母、数字、点(.)、下划线和连字符",
"passwordRequired": "请填写密码",
"passwordMinLength": "密码至少 8 位",
"passwordMinLength": "密码至少 6 位",
"bindAccountPasswordRequired": "该代理尚未绑定登录账号,请填写初始密码以创建账号",
"bindAccountHint": "该代理尚无登录账号,保存时将自动创建并绑定。",
"modelGuide": "代理层负责数据范围Scope与授权上限Ceiling账号权限请通过角色分配。",
@@ -328,7 +336,7 @@
"name": "一级代理名称",
"username": "后台登录账号",
"password": "初始密码",
"passwordHint": "至少 8 位",
"passwordHint": "至少 6 位",
"submit": "创建一级代理",
"success": "一级代理已创建",
"link": "创建一级代理"
@@ -343,8 +351,8 @@
"loginRequired": "请填写登录账号与初始密码",
"loginUsername": "登录账号",
"initialPassword": "初始密码",
"passwordHint": "至少 8 位",
"passwordMinLength": "初始密码至少 8 位",
"passwordHint": "至少 6 位",
"passwordMinLength": "初始密码至少 6 位",
"externalIdOptional": "外部 ID可选",
"externalIdHint": "留空则系统自动生成",
"creditLimit": "授信额度",
@@ -363,7 +371,11 @@
"playerRef": "玩家标识",
"usernameNickname": "用户名 / 昵称",
"creditLimitAvailable": "授信 / 可用",
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端 /login 登录"
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端 /login 登录",
"searchPh": "搜索玩家账号 / 标识",
"manageSettlement": "处理账单",
"noPendingBillsGoCenter": "该玩家暂无待处理账单,已打开结算中心。",
"billingLoadFailed": "加载账单失败"
},
"delegation": {
"title": "下放权限上限",
@@ -406,6 +418,6 @@
"deleteSuccess": "已删除账号 {{name}}"
},
"usernamePlaceholder": "请输入登录名",
"passwordPlaceholder": "请输入8位数密码",
"passwordOptionalHint": "留空则不修改,修改请输入8位数密码"
"passwordPlaceholder": "请输入至少6位密码",
"passwordOptionalHint": "留空则不修改,修改请输入至少6位密码"
}

View File

@@ -1,20 +1,37 @@
{
"title": "审计日志",
"moduleCode": "模块",
"actionCode": "动作",
"operatorType": "操作类型",
"operatorIdPlaceholder": "请输入操作人 ID",
"exactMatch": "请输入完整名称",
"operatorTypePlaceholder": "如管理员、系统",
"operator": "操作",
"module": "模块",
"action": "动作",
"target": "目标",
"title": "操作记录",
"filterModule": "业务类型",
"filterModuleAll": "全部业务",
"filterOperatorType": "操作类型",
"filterOperatorTypeAll": "全部",
"operatorIdPlaceholder": "知道操作人编号时可填写",
"operator": "操作人",
"summary": "操作说明",
"target": "涉及对象",
"time": "时间",
"empty": "无数据",
"empty": "暂无记录",
"moduleOptions": {
"agent": "代理管理",
"system": "系统管理",
"settings": "系统设置",
"integration": "接入站点",
"player_manage": "玩家管理",
"player_service": "玩家服务",
"risk_cap": "限额规则",
"odds": "赔率配置",
"play_config": "玩法配置",
"settlement": "派彩结算",
"report_jobs": "报表导出",
"reconcile_jobs": "对账任务",
"draw": "开奖期号",
"wallet": "钱包资金",
"reconcile": "对账核对",
"jackpot": "奖池",
"dashboard": "工作台"
},
"operatorTypes": {
"admin": "管理员",
"admin": "后台管理员",
"player": "玩家",
"system": "系统"
"system": "系统自动"
}
}
}

View File

@@ -19,6 +19,7 @@
"captchaLoadFailed": "无法获取验证码,请检查接口或网络",
"apiBaseMissingToast": "API 代理未启用:请检查 LOTTERY_API_UPSTREAM 是否指向 Laravel",
"captchaRequired": "请先刷新验证码",
"passwordMinLength": "密码至少需要 6 个字符",
"welcome": "欢迎,{{name}}",
"networkFailed": "网络请求失败",
"loginFailed": "登录失败"

View File

@@ -51,7 +51,8 @@
},
"validation": {
"required": "请填写{{field}}",
"passwordMismatch": "两次输入的密码不一致"
"passwordMismatch": "两次输入的密码不一致",
"passwordMinLength": "密码至少需要 6 个字符"
},
"aria": {
"expand": "展开",
@@ -156,7 +157,7 @@
"nav": {
"home": "首页",
"dashboard": "仪表盘",
"admin_users": "管理列表",
"admin_users": "平台账号",
"admin_roles": "角色管理",
"players": "玩家列表",
"currencies": "币种管理",
@@ -171,25 +172,24 @@
"settlement": "结算",
"jackpot": "奖池",
"reconcile": "对账",
"tickets": "注单列表",
"tickets": "注单",
"audit": "审计日志",
"settings": "系统设置",
"account": "账号设置",
"integration": "接入配置",
"agents": "代理线路",
"agent_list": "代理列表",
"settlement_center": "结算中心",
"agents": "代理管理",
"settlement_center": "信用结算",
"config": "运营配置"
},
"sidebar": {
"workspace": "工作台",
"group": {
"overview": "总览",
"agent": "代理组织",
"operations": "日常运营",
"agent": "代理",
"operations": "运营",
"finance": "资金与报表",
"rules": "规则与参数",
"platform": "平台管理"
"platform": "平台"
}
},
"auth": {

View File

@@ -17,7 +17,7 @@
"rulesPlaysTitle": "投注规则",
"rulesOddsTitle": "赔率与基础回水",
"rulesOddsDescription": "赔率矩阵与基础回水在同一页维护,共用赔率版本线。",
"rulesOddsDescriptionShort": "左侧选玩法,右侧修改赔率与基础回水;代理/玩家回水需在此基础上叠加,修改后记得保存草稿并发布。",
"rulesOddsDescriptionShort": "",
"riskCapTitle": "限额版本"
},
"hub": {
@@ -96,7 +96,7 @@
"codeRequired": "请填写 site_code",
"adminUsernameRequired": "请填写站点后台登录名",
"adminNicknameRequired": "请填写站点后台账号昵称",
"adminPasswordRequired": "请填写至少 8 位的站点后台初始密码"
"adminPasswordRequired": "请填写至少 6 位的站点后台初始密码"
},
"columns": {
"code": "site_code",
@@ -135,7 +135,7 @@
"name": "请输入站点名称",
"adminUsername": "请输入后台登录名",
"adminNickname": "请输入账号昵称",
"adminPassword": "至少 8 位",
"adminPassword": "至少 6 位",
"adminEmail": "请输入邮箱",
"currency": "请输入币种代码,如 NPR",
"walletApiUrl": "请输入钱包接口地址",
@@ -153,11 +153,9 @@
"archived": "已归档"
},
"versionSwitcher": {
"sheetTitle": "切换配置版本",
"sheetDescription": "选择一条版本在本页查看;草稿可编辑,生效中与已归档为只读。",
"sheetTitle": "版本",
"loading": "加载中…",
"noneSelected": "选择版本",
"switch": "切换版本",
"noneSelected": "选择版本",
"empty": "暂无版本记录。",
"count": "{{count}} 条",
"effectiveAt": "生效时间:{{value}}",
@@ -169,7 +167,7 @@
"rollback": "回滚",
"delete": "删除",
"deleteConfirmTitle": "确认删除版本?",
"deleteConfirmDescription": "将永久删除版本 ID {{id}}version_no {{version}})。生效中的版本不可删除。"
"deleteConfirmDescription": "将删除版本 v{{version}},此操作不可恢复。"
},
"versionToolbar": {
"draftEditing": "正在编辑草稿,保存并发布后生效"
@@ -221,6 +219,7 @@
"confirmSaveDescription": "将更新转入/转出单笔限额,立即影响玩家钱包转账。"
},
"system": {
"pageTitle": "系统设置",
"title": "开奖与结算运行参数",
"runtimeTitle": "全局运行参数",
"runtimeIntro1": "这里放不属于玩法版本、赔率版本、风控版本的全局系统参数。它们会直接影响钱包转账、任务开关或系统运行策略。",
@@ -234,12 +233,33 @@
"saveSettlementSuccess": "结算自动化参数已保存",
"saveFrontendSuccess": "前端展示配置已保存",
"sections": {
"draw": "开奖节奏与审核",
"drawDescription": "控制期号节奏、封盘与开奖后人工审核、冷静期。仅保存本区块内修改过的项。",
"deployment": "部署参数(只读)",
"deploymentDescription": "默认币种、期号节奏等由运维在服务器 .env 配置,此处展示当前生效值。",
"draw": "开奖审核",
"drawDescription": "控制开奖后是否必须人工审核,以及结果发布后的冷静期。",
"currencyFormat": "金额显示格式",
"currencyFormatDescription": "全站金额展示的小数位与分隔符,与币种主数据无关。",
"settlement": "结算自动化",
"settlementDescription": "控制 tick 是否自动结算、审核与派彩。修改后只提交本区块变更项。"
"settlementDescription": "按顺序:自动结算 → 自动审核批次 → 自动派彩。关闭后需到对应模块手工处理。"
},
"deployment": {
"footer": "修改后需重启 PHP 应用或刷新配置缓存方生效;如有疑问请联系运维。",
"fields": {
"default_currency": "默认币种",
"draw_timezone": "期号时区",
"draw_interval_minutes": "开奖间隔(分钟)",
"draw_betting_window_seconds": "下注窗口(秒)",
"draw_close_before_draw_seconds": "封盘提前(秒)",
"draw_buffer_draws_ahead": "预生成未来期数"
},
"hints": {
"default_currency": "新玩家建档、未指定币种时的默认值",
"draw_timezone": "期号计划与展示使用的时区",
"draw_interval_minutes": "相邻两期开奖时间的间隔",
"draw_betting_window_seconds": "每期允许下注的时长",
"draw_close_before_draw_seconds": "开奖前停止收单的提前秒数",
"draw_buffer_draws_ahead": "调度提前生成的未开奖期数"
}
},
"saveFailed": "系统设置保存失败",
"unsavedChanges": "有未保存的更改",
@@ -291,7 +311,7 @@
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
"confirmSaveDrawTitle": "确认保存开奖参数?",
"confirmSaveDrawDescription": "将更新开奖审核、期号节奏与冷静期等本区块字段。",
"confirmSaveDrawDescription": "将更新开奖人工审核开关与冷静期时长。",
"confirmSaveCurrencyFormatTitle": "确认保存金额显示格式?",
"confirmSaveCurrencyFormatDescription": "将更新小数位与千分位/小数分隔符。",
"confirmSaveSettlementTitle": "确认保存结算自动化?",
@@ -367,15 +387,15 @@
"publishFailed": "发布失败",
"publishDialog": {
"title": "确认发布玩法配置?",
"description": "新配置将影响后续下注;已下注注单仍按各自快照结算。",
"description": "发布后对新注单生效。",
"confirm": "确认发布"
},
"batchSwitchConfirmTitle": "确认批量{{action}}",
"batchSwitchConfirmDescription": "将{{action}}「{{group}}」下 {{count}} 个玩法,并写入当前草稿。",
"batchSwitchConfirmDescription": "将{{action}}「{{group}}」下 {{count}} 个玩法。",
"batchSwitchEnable": "开启",
"batchSwitchDisable": "关闭",
"toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}",
"toggleConfirmDescription": "将写入当前草稿;保存并发布后才会影响玩家端。",
"toggleConfirmDescription": "将写入当前草稿。",
"batchPartialEnabled": "{{enabledCount}}/{{total}} 已开启(未全开,打开开关将全部开启)",
"toggleEnable": "开启",
"toggleDisable": "关闭",
@@ -513,8 +533,8 @@
"loadingDetails": "正在加载详情…",
"multiplier": "倍数 x{{value}} · {{currency}}",
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
"rebateRate": "基础回水比例 (%)",
"rebateRateHint": "这里维护的是平台基础回水,会把 rebate_rate 写入该玩法下所有奖级范围;代理/玩家回水需在此基础上叠加。",
"rebateRate": "基础回水 (%)",
"rebateRateHint": "",
"placeholders": {
"multiplier": "请输入赔率倍数",
"rebateRate": "请输入基础回水比例"
@@ -532,7 +552,7 @@
},
"publishDialog": {
"title": "确认发布赔率版本?",
"description": "新赔率会立即影响后续新注单;已成功下注的历史注单仍按各自保存的赔率快照结算。",
"description": "发布后对新注单生效。",
"confirm": "确认发布",
"columns": {
"prizeScope": "奖级范围",
@@ -608,14 +628,13 @@
"readOnlyHint": "当前为只读,请先创建草稿。",
"readOnly": "只读",
"defaultCap": {
"title": "默认封顶",
"description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。",
"fieldLabel": "封顶金额(主币单位)"
"title": "默认限额",
"fieldLabel": "限额金额"
},
"specialCaps": {
"title": "特殊封顶",
"description": "不选期号时表示全局号码限额;选择期号后表示该期单独限额。"
"title": "号码限额"
},
"scope": {
"global": "全局号码",
"drawId": "期号 ID{{id}}"
@@ -641,6 +660,7 @@
"noDetailRows": "暂无明细行。",
"table": {
"scope": "作用范围",
"draw": "期号",
"number": "号码",
"capAmount": "封顶金额",
"used": "已占用",
@@ -668,7 +688,8 @@
},
"actions": {
"update": "更新",
"addSpecialCap": "+ 新增特殊封顶",
"addNumberCap": "添加号码限额",
"addSpecialCap": "新增",
"filterPresets": "筛选预设…",
"exportCsv": "导出 CSV",
"close": "关闭"

View File

@@ -67,11 +67,11 @@
"scheduleTitle": "时间安排",
"resultBatchesTitle": "开奖批次",
"batchSummaryTotal": "共 {{count}} 批",
"batchSummaryPending": "待 {{count}}",
"batchSummaryPending": "待发布 {{count}}",
"batchSummaryPublished": "已发 {{count}}",
"noResultBatchesYet": "尚无开奖批次。",
"reviewQueueHint": "结果生成后,可前往审核与发布处理。",
"goToReviewTab": "去审核与发布",
"reviewQueueHint": "",
"goToReviewTab": "开奖发布",
"businessDate": "业务日",
"sequenceNo": "流水序号",
"plannedDraw": "计划开奖",
@@ -96,12 +96,12 @@
"published": "已发布",
"viewFinance": "查看期号收支",
"drawActions": "期号操作",
"drawActionsDesc": "手动封盘 / 取消 / RNG / 重开 / 触发结算均直接调用后台接口。",
"drawActionsDesc": "",
"manualClose": "手动封盘",
"cancelDraw": "取消期号",
"cancelBeforeDraw": "未开奖前取消",
"rngDraw": "RNG开奖",
"rngAutoGenerate": "RNG 自动生成",
"rngAutoGenerate": "自动开奖",
"reopen": "重开",
"cooldownReopen": "冷静期重开",
"runSettlement": "触发结算",
@@ -114,8 +114,8 @@
"actualBet": "当期实扣投注",
"currentPayout": "当期派彩合计",
"grossProfit": "近似毛损益",
"settlementBatchList": "结算批次列表(按期号筛选)",
"relatedSettlementBatches": "本关联期结算批次",
"settlementBatchList": "结算记录",
"relatedSettlementBatches": "结算批次",
"noSettlementBatches": "暂无结算批次记录。",
"ticketCount": "票数",
"winCount": "中奖数",
@@ -124,7 +124,7 @@
"resultsTitle": "开奖结果",
"reviewAndPublish": "去审核 / 发布",
"viewReviewQueue": "查看审核队列",
"noPublishedBatch": "暂无已发布批次。",
"noPublishedBatch": "暂无",
"version": "版本 v{{version}}",
"sourceType": "生成方式 {{source}}",
"manualEntry": "人工录入",
@@ -135,28 +135,28 @@
"tail3": "尾3",
"tail2": "尾2",
"headTail": "头/尾",
"manualResultEntry": "人工录入开奖结果",
"currentStatusAndDraft": "当前状态 {{status}} · 保存后生成待确认批次,不会直接发布",
"currentStatusLabel": "当前状态",
"currentStatusDraftHint": "保存后生成待确认批次,不会直接发布",
"manualResultEntry": "录入号码",
"currentStatusAndDraft": "",
"currentStatusLabel": "状态",
"currentStatusDraftHint": "",
"hallPreviewStatusLabel": "大厅预览",
"enter23Numbers": "请完整输入 23 组 4 位数字",
"draftSaved": "已保存草稿 v{{version}},等待确认发布",
"draftSaved": "已保存 v{{version}}",
"saveFailed": "保存失败",
"fillRandomNumbers": "随机填满",
"clear": "清空",
"saveDraft": "保存草稿",
"saving": "保存中…",
"pendingBatches": "待确认批次",
"noPendingBatches": "当前没有待审核批次。",
"batchId": "批次 ID",
"pendingBatches": "待发布",
"noPendingBatches": "暂无",
"batchId": "批次",
"numberCount": "号码条数",
"reviewAndPublishAction": "核对并发布",
"discardPendingBatch": "删除草稿",
"reviewAndPublishAction": "发布",
"discardPendingBatch": "删除",
"discardingPendingBatch": "删除中…",
"discardPendingBatchSuccess": "已删除待确认批次,可重新录入或 RNG",
"discardPendingBatchFailed": "删除失败",
"publishReadOnlyHint": "发布页仅用于核对;若要改号请先删除本批次,回到上方「人工录入」重新保存。",
"publishReadOnlyHint": "",
"noPublishPermission": "无发布权限",
"batchNotFound": "未找到批次",
"batchNotFoundDesc": "请返回审核列表确认 batch id。",
@@ -173,10 +173,10 @@
"publishFailed": "发布失败",
"sourceTypeFull": "生成方式:{{source}} · 号码条数:{{count}}/23 · RNG 摘要:{{hash}}",
"subnav": {
"status": "期号状态",
"status": "概览",
"results": "开奖结果",
"finance": "期号收支",
"review": "审核与发布",
"finance": "收支",
"review": "开奖发布",
"riskOccupancy": "风控占用",
"riskLockLogs": "占用流水",
"riskHot": "热门号码",

View File

@@ -1,14 +1,9 @@
{
"title": "奖池",
"configTitle": "奖池配置",
"pageDescription": "维护各币种奖池参数,下方可查询蓄水与派彩流水。",
"poolsSectionDescription": "蓄水比例、爆池阈值、开关与手动爆池。",
"rulesTitle": "规则说明",
"rulesJoin": "只有提交成功且满足最低参与下注额的注项,才会按蓄水比例进入奖池。",
"rulesBurst": "奖池会在达到爆池阈值、达到强制爆池间隔,或命中指定组合触发玩法时释放。",
"rulesManual": "手动爆池仅限超管兜底使用,可填写后台期号数字 ID 或期号编码。",
"recordsSectionTitle": "蓄水与派彩流水",
"recordsSectionDescription": "按条件筛选派彩记录与蓄水明细,只读查询。",
"configTitle": "奖池",
"pageTabs": "奖池",
"tabConfig": "配置",
"tabRecords": "流水",
"loadFailed": "加载失败",
"saveSuccess": "已保存",
"saveFailed": "保存失败",
@@ -19,7 +14,6 @@
"displayBalance": "展示余额 {{amount}}",
"currentAmount": "当前池余额(主币单位)",
"balanceAdjustmentTitle": "余额调整",
"balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。",
"adjustmentDirection": "方向",
"adjustmentIncrease": "增加",
"adjustmentDecrease": "减少",
@@ -35,31 +29,25 @@
"confirmAdjustmentTitle": "确认提交奖池余额调整?",
"confirmAdjustmentDescription": "将写入调整流水并更新当前池余额,请确认金额与原因无误。",
"recentAdjustments": "最近调整记录",
"contributionRate": "蓄水比例 (%)",
"contributionRatePlaceholder": "如 2 表示 2%",
"triggerThreshold": "爆池阈值(主币单位)",
"triggerThresholdPlaceholder": "请输入触发阈值",
"payoutRate": "爆池派彩比例 (%)",
"payoutRatePlaceholder": "如 5 表示 5%",
"forceTriggerGap": "强制爆池间隔(已结算期数)",
"forceTriggerGapPlaceholder": "请输入强制触发间隔期数",
"minBetAmount": "最低参与下注额(主币单位)",
"minBetAmountPlaceholder": "请输入最低下注金额",
"comboTriggerPlays": "组合触发玩法(逗号分隔)",
"comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox",
"contributionRate": "蓄水比例 %",
"triggerThreshold": "爆池阈值",
"payoutRate": "派彩比例 %",
"forceTriggerGap": "强制爆池间隔(期)",
"minBetAmount": "最低下注额",
"comboTriggerPlays": "组合触发玩法",
"comboTriggerPlaysPlaceholder": "straight,ibox",
"status": "开关",
"disabled": "关闭",
"enabled": "开启",
"saving": "保存中…",
"save": "保存",
"confirmSavePoolTitle": "确认保存奖池配置?",
"confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含余额);余额请使用「余额调整」。",
"manualBurstDrawId": "手动爆池期号ID 或编码)",
"manualBurstHint": "仅超级管理员可在紧急情况下触发;可填写后台期号数字 ID 或期号编码。须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。",
"confirmSavePoolDescription": "将更新奖池参数(不含余额)。",
"manualBurstDrawId": "爆池期号",
"manualBurstConfirmTitle": "确认手动爆池?",
"manualBurstConfirmDescription": "对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot并扣减奖池余额。此操作不可自动撤销。",
"manualBurstConfirmDescription": "确认对期号 {{drawId}} 执行手动爆池?",
"processing": "处理中…",
"manualBurst": "手动触发爆池(仅超管)",
"manualBurst": "手动爆池",
"manualBurstConfirm": "确认爆池",
"cancel": "取消",
"filter": "筛选",
@@ -72,7 +60,7 @@
"title": "奖池记录",
"description": "派彩记录与奖池蓄水流水"
},
"poolsSectionTitle": "各币种奖池参数",
"payoutLoadFailed": "派彩记录加载失败",
"contributionLoadFailed": "蓄水记录加载失败",
"trigger": "触发",

View File

@@ -1,7 +1,10 @@
{
"title": "对账",
"createTitle": "发起对账扫描",
"createHint": "将扫描所选日期内的转账单,比对彩票钱包流水,并在已配置主站 API 时核对主站幂等记录。",
"workflowHint": "本页按日期扫描并留存对账任务。处理当前仍未解决的异常转账单,请点右上角入口(打开钱包 → 转账单,并自动勾选「仅异常单」)。",
"shortcutAbnormalTransfers": "当前异常转账单",
"viewOnlyHint": "当前账号仅可查看历史对账任务。",
"createTitle": "发起扫描",
"createHint": "",
"reconcileType": "对账类型",
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
"dateRange": "对账日期范围",
@@ -12,13 +15,18 @@
"periodRequired": "请填写对账日期范围(开始与结束)",
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
"confirmCreateTitle": "确认发起对账扫描?",
"confirmCreateDescription": "扫描所选日期范围{{playerHint}}内的转账单,并自动生成差异明细。",
"confirmCreateDescription": "扫描所选日期{{playerHint}}内的转账单。",
"confirmCreatePlayer": "内指定玩家",
"confirmCreateAllPlayers": "内全部玩家",
"createSuccess": "扫描完成,发现 {{count}} 条异常",
"createSuccessEmpty": "扫描完成,未发现异常",
"createFailed": "扫描失败",
"noCreatePermission": "当前账号无发起对账扫描权限。",
"filterOpen": "仅待处理",
"filterAll": "全部明细",
"filterCount": "本页 {{shown}} / 共 {{total}} 条",
"filterOpenEmpty": "本页无待处理明细,可切换「全部明细」或翻页。",
"playerEmpty": "无匹配玩家",
"jobsTitle": "对账任务",
"refresh": "刷新",
"jobNo": "任务号",
@@ -31,16 +39,25 @@
"finishedAt": "完成时间",
"createdAt": "创建时间",
"operate": "操作",
"viewDetails": "查看差异明细",
"viewDetails": "查看明细",
"hideDetails": "收起",
"closeDetails": "关闭明细",
"detailsTitle": "差异明细",
"detailsEmpty": "暂无明细",
"itemStatusColumn": "状态",
"itemIssueColumn": "异常类型",
"itemHandlingColumn": "处理进度",
"itemScanFindingColumn": "扫描发现",
"itemCurrentStatusColumn": "当前状态",
"itemScanFindingResolvedHint": "扫描时记录,现已处理",
"transferNo": "转账单号",
"walletTxnNo": "彩票钱包流水号",
"mainSiteRef": "主站流水号",
"mainSiteCheck": "主站核对",
"differenceAmount": "差额(分)",
"differenceAmount": "差额",
"itemResult": "检查结果",
"processingStatus": "处理状态",
"actions": "处理",
"actions": "操作",
"openTransferOrder": "查看转账单",
"openWalletTxn": "查看钱包流水",
"detectedAt": "发现时间",

View File

@@ -1,6 +1,12 @@
{
"title": "报表中心",
"subtitle": "集中查看运营、资金、风控与审计报表,统一按维度筛选后导出。",
"subtitle": "",
"profitScopeHint": "注单口径,非信用账期结算",
"shortcuts": {
"wallet": "转账单列表",
"risk": "风控中心",
"audit": "审计日志"
},
"pageGuide": "",
"exportPanel": "导出设置",
"chooseReport": "选择要导出的报表",
@@ -86,8 +92,12 @@
},
"preview": {
"title": "数据预览",
"subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。",
"empty": "暂无数据,请调整筛选条件后重试。",
"subtitle": "",
"empty": "暂无数据",
"sections": {
"settlementBatches": "结算批次",
"lockLogs": "占用日志"
},
"exportableRows": "行可导出",
"summaryScopeHint": "上方统计卡除“记录数”外,默认按当前预览页汇总;需要全量口径请使用“导出 CSV/Excel全量”。",
"scope": {

View File

@@ -38,6 +38,7 @@
"unsettledTickets": "未结算注单",
"openReportHint": "进行中账期:占成/输赢来自账期内流水;账单数在关账后更新。",
"viewDetail": "查看详情",
"processBills": "处理账单",
"close": "关账",
"closeNow": "立即关账",
"hasOpen": "已有进行中账期 {{range}},须先关账才能开新期。",
@@ -51,6 +52,7 @@
"periods": "账期",
"bills": "账单",
"operations": "收付与调账",
"aria": "账期视图",
"ledger": "账务流水",
"creditLedger": "信用流水",
"playerBills": "玩家账单",
@@ -198,7 +200,8 @@
"actions": {
"detail": "详情",
"viewBill": "查看账单",
"billDetail": "账单详情"
"billDetail": "账单详情",
"billDetailWithId": "账单 #{{id}}"
},
"billDisplay": {
"settlementFlow": "谁付谁",
@@ -235,6 +238,7 @@
"unpaidAwaitingPayment": "请登记线下收付",
"fullySettled": "本期已结清",
"confirmHint": "确认后才可以登记收款或付款。",
"advancedActions": "调账 / 坏账",
"recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}}",
"recordPayoutTo": "登记付款({{payer}} 付给 {{payee}}",
"rebateAllocationsHint": "各层级代理对回水的承担明细。",
@@ -321,7 +325,13 @@
"hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。",
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。",
"intro": "关账后生成的占成账单。可按类型或状态筛选,详情内确认或登记收付。"
"intro": "关账后生成的占成账单。可按类型或状态筛选,详情内确认或登记收付。",
"confirmOneTitle": "确认账单 #{{id}}",
"confirmOneDesc": "确认后进入待收付,可登记线下收付。",
"confirmOneBtn": "确认",
"confirmedOne": "已确认",
"confirmFailed": "确认失败",
"payBtn": "收付"
},
"panels": {
"workbench": {

View File

@@ -3,9 +3,7 @@
"subnavLabel": "钱包子页",
"subnavTransactions": "主站钱包流水",
"subnavTransferOrders": "主站转账单",
"scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的账期结账请查看",
"scopeHintSettlementLink": "结算中心",
"scopeHintSettlement": "结算中心",
"ledgerChannel": "账本",
"ledgerCredit": "信用流水",
"ledgerWallet": "钱包流水",
@@ -43,6 +41,9 @@
"options": "选项",
"abnormalOnly": "仅异常单",
"abnormalOnlyPending": "仅异常(待对账)",
"abnormalFilterActive": "当前仅显示对账标记的异常转账单(非钱包流水)。可在下方取消勾选「仅异常单」查看全部。",
"txnAbnormalFilterActive": "当前仅显示状态为「待对账」的流水。勾选后状态下拉无效,与接口 abnormal=1 一致。",
"txnDeepLinkActive": "已按链接带入筛选条件并自动查询。重置可清空 URL 参数。",
"search": "搜索",
"resetFilters": "重置筛选",
"refreshCurrentPage": "刷新当前页",

View File

@@ -1,8 +1,10 @@
/** 后台/彩票端登录账号:字母、数字、点、下划线、连字符。 */
export const ADMIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
export const ADMIN_PASSWORD_MIN_LENGTH = 8;
export const NATIVE_PLAYER_PASSWORD_MIN_LENGTH = 6;
/** 全项目统一:后台账号、代理账号、彩票端玩家密码最短长度 */
export const PASSWORD_MIN_LENGTH = 6;
export const ADMIN_PASSWORD_MIN_LENGTH = PASSWORD_MIN_LENGTH;
export const NATIVE_PLAYER_PASSWORD_MIN_LENGTH = PASSWORD_MIN_LENGTH;
export type AccountValidationIssue = "empty" | "invalid_charset";
export type PasswordValidationIssue = "empty" | "too_short";

View File

@@ -23,16 +23,19 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
settings: "settings",
integration: "integration",
agents: "agents",
agent_list: "agent_list",
config: "config",
};
const LEGACY_NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
agent_list: "agents",
};
export function adminNavLabel(
segment: string,
t: TFunction,
apiLabel?: string | null,
): string {
const key = NAV_SEGMENT_I18N_KEYS[segment];
const key = NAV_SEGMENT_I18N_KEYS[segment] ?? LEGACY_NAV_SEGMENT_I18N_KEYS[segment];
if (key) {
return t(`nav.${key}`, { ns: "common" });
}

View File

@@ -12,11 +12,14 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
"/admin/settlement-batches": { ns: "settlement", key: "batchList" },
"/admin/reconcile": { ns: "reconcile", key: "title" },
"/admin/reports": { ns: "reports", key: "title" },
"/admin/reports/profit": { ns: "reports", key: "title" },
"/admin/reports/wallet": { ns: "reports", key: "title" },
"/admin/reports/risk": { ns: "reports", key: "title" },
"/admin/reports/audit": { ns: "reports", key: "title" },
"/admin/audit-logs": { ns: "audit", key: "title" },
"/admin/admin-users": { ns: "adminUsers", key: "title" },
"/admin/admin-roles": { ns: "adminRoles", key: "title" },
"/admin/agents": { ns: "agents", key: "title" },
"/admin/agents/list": { ns: "agents", key: "listTitle" },
"/admin/agents/provision": { ns: "agents", key: "subnav.provision" },
"/admin/agents/sites": { ns: "config", key: "integrationSites.title" },
"/admin/settlement-center": { ns: "settlementCenter", key: "title" },

View File

@@ -0,0 +1,313 @@
export type PermissionAccessLevel = "none" | "view" | "review" | "export" | "manage" | "special";
export type PermissionGroupProfile = {
key: string;
/** 仅平台超管在角色配置里可见(接入规则、全局 RBAC 等) */
platformOnly?: boolean;
levels: Array<{
key: PermissionAccessLevel;
labelKey: string;
slugs: readonly string[];
}>;
};
/** 客户配角色时用的「能做什么」档位,底层仍映射到 prd.* slug。 */
export const ADMIN_PERMISSION_GROUP_PROFILES: PermissionGroupProfile[] = [
{
key: "dashboard",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.dashboard.view"] },
],
},
{
key: "agents",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{
key: "view",
labelKey: "permissionAccess.view",
slugs: ["prd.agent.view", "prd.agent.role.view", "prd.agent.user.view"],
},
{
key: "manage",
labelKey: "permissionAccess.manage",
slugs: [
"prd.agent.view",
"prd.agent.manage",
"prd.agent.role.view",
"prd.agent.role.manage",
"prd.agent.user.view",
"prd.agent.user.manage",
],
},
],
},
{
key: "players",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{
key: "view",
labelKey: "permissionAccess.view",
slugs: ["prd.users.view_finance", "prd.users.view_cs"],
},
{
key: "manage",
labelKey: "permissionAccess.manage",
slugs: ["prd.users.view_finance", "prd.users.view_cs", "prd.users.manage", "prd.player_freeze.manage"],
},
],
},
{
key: "draws",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.draw_result.view"] },
{
key: "manage",
labelKey: "permissionAccess.manage",
slugs: ["prd.draw_result.view", "prd.draw_result.manage", "prd.draw_reopen.manage"],
},
],
},
{
key: "tickets",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.tickets.view"] },
],
},
{
key: "settlement",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{
key: "view",
labelKey: "permissionAccess.view",
slugs: ["prd.payout.view", "prd.settlement.agent.view"],
},
{
key: "review",
labelKey: "permissionAccess.review",
slugs: ["prd.payout.view", "prd.payout.review", "prd.settlement.agent.view"],
},
{
key: "manage",
labelKey: "permissionAccess.manage",
slugs: [
"prd.payout.view",
"prd.payout.review",
"prd.payout.manage",
"prd.settlement.agent.view",
"prd.settlement.agent.manage",
],
},
],
},
{
key: "wallet",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{
key: "view",
labelKey: "permissionAccess.view",
slugs: ["prd.wallet_reconcile.view", "prd.wallet_reconcile.view_cs", "prd.users.view_finance"],
},
{
key: "manage",
labelKey: "permissionAccess.manage",
slugs: [
"prd.wallet_reconcile.view",
"prd.wallet_reconcile.view_cs",
"prd.users.view_finance",
"prd.wallet_reconcile.manage",
"prd.wallet_adjust.manage",
],
},
],
},
{
key: "reconcile",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{
key: "view",
labelKey: "permissionAccess.view",
slugs: ["prd.wallet_reconcile.view", "prd.wallet_reconcile.view_cs"],
},
{ key: "manage", labelKey: "permissionAccess.manage", slugs: ["prd.wallet_reconcile.view", "prd.wallet_reconcile.view_cs", "prd.wallet_reconcile.manage"] },
],
},
{
key: "reports",
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.report.view"] },
{
key: "export",
labelKey: "permissionAccess.export",
slugs: ["prd.report.view", "prd.report.export"],
},
],
},
{
key: "admin_users",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "manage", labelKey: "permissionAccess.manage", slugs: ["prd.admin_user.manage"] },
],
},
{
key: "admin_roles",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "manage", labelKey: "permissionAccess.manage", slugs: ["prd.admin_role.manage"] },
],
},
{
key: "integration",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.integration.view"] },
{ key: "manage", labelKey: "permissionAccess.manage", slugs: ["prd.integration.view", "prd.integration.manage"] },
],
},
{
key: "rules_plays",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.odds.view"] },
{
key: "manage",
labelKey: "permissionAccess.manage",
slugs: ["prd.odds.view", "prd.play_switch.manage", "prd.odds.manage"],
},
],
},
{
key: "rules_odds",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.rebate.view"] },
{
key: "manage",
labelKey: "permissionAccess.manage",
slugs: ["prd.rebate.view", "prd.rebate.manage", "prd.odds.manage"],
},
],
},
{
key: "risk_cap",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.risk_cap.view"] },
{ key: "manage", labelKey: "permissionAccess.manage", slugs: ["prd.risk_cap.view", "prd.risk_cap.manage"] },
],
},
{
key: "risk",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.risk.view"] },
{ key: "manage", labelKey: "permissionAccess.manage", slugs: ["prd.risk.view", "prd.risk.manage"] },
],
},
{
key: "jackpot",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.jackpot.view"] },
{
key: "manage",
labelKey: "permissionAccess.manage",
slugs: ["prd.jackpot.view", "prd.jackpot.manage"],
},
{
key: "special",
labelKey: "permissionAccess.special",
slugs: ["prd.jackpot.view", "prd.jackpot.manage", "prd.jackpot.manual_burst"],
},
],
},
{
key: "currencies",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "manage", labelKey: "permissionAccess.manage", slugs: ["prd.currency.manage"] },
],
},
{
key: "audit",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "view", labelKey: "permissionAccess.view", slugs: ["prd.audit.view"] },
],
},
{
key: "settings",
platformOnly: true,
levels: [
{ key: "none", labelKey: "permissionAccess.none", slugs: [] },
{ key: "manage", labelKey: "permissionAccess.manage", slugs: ["prd.wallet_reconcile.manage", "prd.currency.manage"] },
],
},
];
const PROFILE_BY_KEY = new Map(ADMIN_PERMISSION_GROUP_PROFILES.map((profile) => [profile.key, profile]));
export function permissionProfileForGroup(key: string): PermissionGroupProfile | undefined {
return PROFILE_BY_KEY.get(key);
}
export function allSlugsForProfile(profile: PermissionGroupProfile): string[] {
const out = new Set<string>();
for (const level of profile.levels) {
for (const slug of level.slugs) {
out.add(slug);
}
}
return Array.from(out);
}
export function resolveProfileLevel(profile: PermissionGroupProfile, selectedSlugs: readonly string[]): PermissionAccessLevel {
const selected = new Set(selectedSlugs);
let matched: PermissionAccessLevel = "none";
const order: PermissionAccessLevel[] = ["view", "review", "export", "manage", "special"];
for (const levelKey of order) {
const level = profile.levels.find((item) => item.key === levelKey);
if (level === undefined || level.slugs.length === 0) {
continue;
}
if (level.slugs.every((slug) => selected.has(slug))) {
matched = level.key;
}
}
return matched;
}
export function applyProfileLevel(
profile: PermissionGroupProfile,
levelKey: PermissionAccessLevel,
selectedSlugs: readonly string[],
): string[] {
const groupSlugs = new Set(allSlugsForProfile(profile));
const next = selectedSlugs.filter((slug) => !groupSlugs.has(slug));
const level = profile.levels.find((item) => item.key === levelKey);
if (level) {
next.push(...level.slugs);
}
return Array.from(new Set(next)).sort();
}

View File

@@ -103,6 +103,15 @@ export const PRD_WALLET_TRANSFER_ACCESS_ANY = [
PRD_WALLET_ADJUST_MANAGE,
] as const;
/** 报表中心页(含资金/风控/审计类导出报表入口) */
export const PRD_REPORTS_PAGE_ACCESS_ANY = [
PRD_REPORT_VIEW,
PRD_REPORT_EXPORT,
...PRD_WALLET_TRANSFER_ACCESS_ANY,
...PRD_RISK_ACCESS_ANY,
PRD_AUDIT_VIEW,
] as const;
/** 单玩家钱包查询 */
export const PRD_WALLET_PLAYER_ACCESS_ANY = [
PRD_USERS_VIEW_FINANCE,

View File

@@ -32,7 +32,6 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
{
dashboard: LayoutDashboard,
agents: Network,
agent_list: Users,
players: Users,
draws: CalendarClock,
rules_plays: ClipboardList,
@@ -56,6 +55,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
/** 旧版 localStorage / 接口缓存中的 segment避免首屏侧栏崩溃 */
const legacyAdminNavIconBySegment: Record<string, LucideIcon> = {
agent_list: Users,
config: SlidersHorizontal,
};

View File

@@ -11,7 +11,6 @@ export type AdminNavGroup =
export type AdminNavSegment =
| "dashboard"
| "agents"
| "agent_list"
| "players"
| "draws"
| "rules_plays"

View File

@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { putAdminUser } from "@/api/admin-users";
import { validateAdminPassword } from "@/lib/admin-input-validation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -62,6 +63,10 @@ export function AccountSettingsConsole() {
toast.error(t("validation.passwordMismatch"));
return;
}
if (validateAdminPassword(password) === "too_short") {
toast.error(t("validation.passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }));
return;
}
if (!adminProfile) {
toast.error(t("actions.updateFailed"));
return;

View File

@@ -22,6 +22,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
import { permissionProfileForGroup, resolveProfileLevel } from "@/lib/admin-permission-profiles";
import { Badge } from "@/components/ui/badge";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -57,8 +58,26 @@ function permissionGroupLabel(key: string, fallback: string, t: (key: string) =>
return translated === `permissionGroups.${key}` ? fallback : translated;
}
function permissionPackageLabel(key: string, fallback: string, t: (key: string, options?: { defaultValue?: string }) => string): string {
return t(`permissionLevels.${key}`, { defaultValue: fallback });
function countEnabledAreas(
permissionSlugs: string[],
catalog: AdminPermissionCatalogData | null,
isSuperAdmin: boolean,
): number {
if (!catalog) {
return permissionSlugs.length;
}
let count = 0;
for (const group of catalog.permission_menu_groups ?? []) {
const profile = permissionProfileForGroup(group.key);
if (profile === undefined || (profile.platformOnly && !isSuperAdmin)) {
continue;
}
if (resolveProfileLevel(profile, permissionSlugs) !== "none") {
count += 1;
}
}
return count;
}
export function AdminRolesConsole(): React.ReactElement {
@@ -67,6 +86,7 @@ export function AdminRolesConsole(): React.ReactElement {
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
const isSuperAdmin = profile?.is_super_admin === true;
const exportLabels = useExportLabels("adminRoles");
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
@@ -270,11 +290,7 @@ export function AdminRolesConsole(): React.ReactElement {
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("roleListHint", {
defaultValue: "可新增自定义角色并配置权限;内置角色(超级管理员、站点管理员、代理)不可删除。",
})}
</p>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
<div className="rounded-md border">
<Table id="admin-roles-table">
@@ -282,19 +298,19 @@ export function AdminRolesConsole(): React.ReactElement {
<TableRow>
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("roleTable.name")}</TableHead>
<TableHead>{t("roleTable.slug")}</TableHead>
{isSuperAdmin ? <TableHead>{t("roleTable.slug")}</TableHead> : null}
<TableHead>{t("roleTable.type")}</TableHead>
<TableHead>{t("roleTable.status")}</TableHead>
<TableHead>{t("roleTable.users")}</TableHead>
<TableHead>{t("roleTable.permissions")}</TableHead>
<TableHead>{t("roleTable.enabledAreas")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("roleTable.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && roles.length === 0 ? (
<AdminTableLoadingRow colSpan={8} />
<AdminTableLoadingRow colSpan={isSuperAdmin ? 8 : 7} />
) : roles.length === 0 ? (
<AdminTableNoResourceRow colSpan={8} className="text-muted-foreground" />
<AdminTableNoResourceRow colSpan={isSuperAdmin ? 8 : 7} className="text-muted-foreground" />
) : (
roles.map((role) => {
const fixedRole = isPlatformFixedRole(role);
@@ -306,7 +322,7 @@ export function AdminRolesConsole(): React.ReactElement {
<TableCell>
<span className="font-medium">{role.name}</span>
</TableCell>
<TableCell>{role.slug}</TableCell>
{isSuperAdmin ? <TableCell className="font-mono text-xs text-muted-foreground">{role.slug}</TableCell> : null}
<TableCell>
{role.is_system ? (
<Badge variant="secondary">{t("roleType.system")}</Badge>
@@ -320,7 +336,9 @@ export function AdminRolesConsole(): React.ReactElement {
</AdminStatusBadge>
</TableCell>
<TableCell className="tabular-nums">{role.user_count}</TableCell>
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
<TableCell className="tabular-nums">
{countEnabledAreas(role.permission_slugs, catalog, isSuperAdmin)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageRoles ? (
<AdminRowActionsMenu
@@ -370,11 +388,10 @@ export function AdminRolesConsole(): React.ReactElement {
>
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
<DialogTitle className="text-[15px] font-semibold tracking-tight text-foreground">
{t("rolePermissionDialog.title")}
{selectedRole
? t("rolePermissionDialog.titleWithName", { name: selectedRole.name })
: t("rolePermissionDialog.title")}
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{selectedRole ? selectedRole.name : null}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
<AdminPermissionPackageSelector
@@ -382,11 +399,7 @@ export function AdminRolesConsole(): React.ReactElement {
selectedSlugs={draftRolePermissions}
onChange={setDraftRolePermissions}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
helperText={t("rolePermissionDialog.packageHint", {
defaultValue:
"勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。",
})}
isSuperAdmin={isSuperAdmin}
emptyText={t("states.noData", { ns: "common" })}
heightClassName="h-[min(56vh,520px)]"
/>
@@ -404,7 +417,6 @@ export function AdminRolesConsole(): React.ReactElement {
title: t("confirmSaveRolePermissionsTitle"),
description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
confirmVariant: "destructive",
onConfirm: () => saveRolePermissions(),
})
}
@@ -421,7 +433,6 @@ export function AdminRolesConsole(): React.ReactElement {
<DialogTitle>
{editingRoleId === null ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
</DialogTitle>
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">

View File

@@ -24,6 +24,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Badge } from "@/components/ui/badge";
import { validateAdminPassword } from "@/lib/admin-input-validation";
import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
@@ -266,7 +267,7 @@ export function AdminUsersConsole(): React.ReactElement {
toast.error(t("nicknameRequired"));
return;
}
if (accountMode === "edit" && formPassword !== "" && formPassword.length < 8) {
if (accountMode === "edit" && formPassword !== "" && validateAdminPassword(formPassword) === "too_short") {
toast.error(t("newPasswordMin"));
return;
}
@@ -287,7 +288,7 @@ export function AdminUsersConsole(): React.ReactElement {
toast.error(t("usernameRequired"));
return;
}
if (formPassword.length < 8) {
if (validateAdminPassword(formPassword) === "too_short") {
toast.error(t("passwordMin"));
return;
}
@@ -404,12 +405,6 @@ export function AdminUsersConsole(): React.ReactElement {
</Button>
) : null}
</div>
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{t("modelGuidePlatform", {
defaultValue:
"这里只管理平台账号与平台角色。代理账号请到「代理经营」中创建和维护;账号层只绑定角色,不直接分配功能权限。",
})}
</div>
<div className="admin-list-toolbar">
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">

View File

@@ -16,136 +16,101 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields";
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { Button } from "@/components/ui/button";
import { percentValueToUi } from "@/lib/admin-rate-percent";
import { isLineRootAgentNode } from "@/lib/agent-profile-caps";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { cn } from "@/lib/utils";
import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null {
if (
totalShareRate == null ||
parentShareRate == null ||
parentShareRate <= 0
) {
return null;
}
return percentValueToUi((totalShareRate / parentShareRate) * 100);
}
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
export type AgentDetailTab = "profile" | "downline" | "players";
export type AgentLineDetailPanelProps = {
node: AgentNodeRow | null;
profile: AgentProfileRow | null;
profileLoading: boolean;
profileError?: string | null;
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
siteCode: string;
siteLabel: string | null;
parentName: string | null;
detailTab: AgentDetailTab;
onDetailTabChange: (tab: AgentDetailTab) => void;
canViewProfileTab: boolean;
canEditProfileTab: boolean;
profileReadOnly: boolean;
canSaveProfileTab: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
playersTabHint?: string | null;
canManageNode: boolean;
canCreateChild: boolean;
canCreateChildAgent: boolean;
canCreatePlayerAction: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void;
onAddChild: () => void;
onAddPlayer: () => void;
onEditCurrent: () => void;
onSelectChild: (node: AgentNodeRow) => void;
profileFields: AgentProfileFieldsProps | null;
profileSaving: boolean;
onSaveProfile: () => void;
playerCreateRequestKey?: number;
};
export function AgentLineDetailPanel({
node,
profile,
profileLoading,
profileError = null,
childAgents,
childCountById,
siteCode,
siteLabel,
parentName,
detailTab,
onDetailTabChange,
canViewProfileTab,
canEditProfileTab,
profileReadOnly,
canSaveProfileTab,
canViewDownlineTab,
canViewPlayersTab,
playersTabHint,
canManageNode,
canCreateChild,
canCreateChildAgent,
canCreatePlayerAction,
canDeleteChild,
onEditChild,
onDeleteChild,
onAddChild,
onAddPlayer,
onEditCurrent,
onSelectChild,
profileFields,
profileSaving,
onSaveProfile,
playerCreateRequestKey = 0,
}: AgentLineDetailPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
if (node === null) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/20 px-6 py-20 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80 bg-background">
<div className="flex flex-1 flex-col items-center justify-center px-6 py-20 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80">
<Network className="size-6 text-muted-foreground/70" aria-hidden />
</div>
<p className="mt-4 text-sm font-medium text-foreground">
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理查看占成与授信" })}
</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{t("lineUi.selectAgentHint", {
defaultValue: "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
})}
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理" })}
</p>
</div>
);
}
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
{
key: "overview",
label: t("lineUi.tabOverview", { defaultValue: "概览" }),
visible: true,
},
{
key: "profile",
label: profileReadOnly
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
: t("lineUi.tabProfile", { defaultValue: "占成与授信" }),
label: t("lineUi.tabProfileShort", { defaultValue: "占成授信" }),
visible: canViewProfileTab,
},
{
key: "downline",
label: t("lineUi.tabDownline", { defaultValue: "直属下级" }),
count: childAgents.length,
visible: canViewDownlineTab,
},
{
@@ -155,165 +120,94 @@ export function AgentLineDetailPanel({
},
];
const siteDisplay =
siteLabel && siteCode.trim() !== ""
? `${siteLabel} (${siteCode})`
: siteLabel ?? siteCode;
const childActionHint = canCreateChild
? null
: canCreateChildAgent
? t("lineUi.addChildUnavailableHint", {
defaultValue: "当前代理未开启“允许创建下级代理”,如需新增请先调整该代理配置。",
})
: t("lineUi.addChildNoPermissionHint", {
defaultValue: "当前账号没有为该节点创建下级代理的权限。",
});
const playerActionHint =
canViewPlayersTab && !canCreatePlayerAction ? playersTabHint ?? null : null;
const showPrimaryAction = detailTab === "downline" || detailTab === "players";
const primaryActionEnabled =
detailTab === "players" ? canCreatePlayerAction : canCreateChild;
const primaryActionLabel =
detailTab === "players"
? t("lineUi.createDirectPlayer", { defaultValue: "创建直属玩家" })
: t("createChild", { defaultValue: "添加下级代理" });
const primaryActionHint =
detailTab === "players" ? playerActionHint : childActionHint;
const metaParts = [
parentName
? t("lineUi.parentMeta", { defaultValue: "上级 {{name}}", name: parentName })
: null,
node.username?.trim() ? node.username : node.code,
].filter(Boolean);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
<div className="shrink-0 bg-card shadow-[0_1px_0_rgb(216_230_251_/_35%)]">
<header className="border-b border-border/60 px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2.5">
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground">
{node.name}
</h2>
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
{node.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="shrink-0 border-b border-border/60">
<header className="px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-lg font-semibold tracking-tight">{node.name}</h2>
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
{node.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</div>
{metaParts.length > 0 ? (
<p className="truncate text-sm text-muted-foreground">{metaParts.join(" · ")}</p>
) : null}
<AgentHeaderMetrics profile={profile} loading={profileLoading} />
</div>
{siteDisplay ? (
<p className="mt-1 truncate text-sm text-muted-foreground" title={siteDisplay}>
{siteDisplay}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
{canManageNode ? (
<>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
<Pencil className="mr-1.5 size-3.5" />
{t("lineUi.editAgent", { defaultValue: "编辑代理" })}
</Button>
{showPrimaryAction && primaryActionEnabled ? (
<Button
type="button"
size="sm"
onClick={detailTab === "players" ? onAddPlayer : onAddChild}
>
<Plus className="mr-1.5 size-3.5" />
{primaryActionLabel}
</Button>
) : null}
</div>
{primaryActionHint ? (
<p className="max-w-[26rem] text-right text-xs leading-5 text-muted-foreground">
{primaryActionHint}
</p>
) : null}
</>
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
<Pencil className="mr-1.5 size-3.5" />
{t("lineUi.editAgent", { defaultValue: "编辑" })}
</Button>
) : null}
</div>
</div>
</header>
</header>
<AdminSubnav
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
className="min-h-11 overflow-x-auto border-b border-border/60 px-4 sm:px-5"
>
{tabs
.filter((tab) => tab.visible)
.map((tab) => (
<AdminSubnavButton
key={tab.key}
active={detailTab === tab.key}
onClick={() => onDetailTabChange(tab.key)}
count={tab.count}
>
{tab.label}
</AdminSubnavButton>
))}
</AdminSubnav>
<AdminSubnav
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
className="min-h-11 overflow-x-auto px-4 sm:px-5"
>
{tabs
.filter((tab) => tab.visible)
.map((tab) => (
<AdminSubnavButton
key={tab.key}
active={detailTab === tab.key}
onClick={() => onDetailTabChange(tab.key)}
count={tab.count}
>
{tab.label}
</AdminSubnavButton>
))}
</AdminSubnav>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
{profileLoading && detailTab !== "overview" ? (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6 sm:py-6">
{profileError ? (
<p className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{profileError}
</p>
) : null}
{profileLoading && detailTab === "profile" ? (
<AdminLoadingInline className="py-16" />
) : null}
{detailTab === "overview" ? (
<OverviewTab
profile={profile}
profileLoading={profileLoading}
profileReadOnly={profileReadOnly}
/>
) : null}
{detailTab === "profile" && canViewProfileTab && profileFields && !profileLoading ? (
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm">
<CardHeader className="border-b border-border/60 pb-4">
<CardTitle className="text-base">
{profileReadOnly
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
: t("lineUi.tabProfile", { defaultValue: "占成与授信" })}
</CardTitle>
<p className="text-sm font-normal text-muted-foreground">
{profileReadOnly
? isLineRootAgentNode(node)
? t("lineUi.lineRootProfileReadOnlyHint", {
defaultValue:
"一级代理的占成、站点授信总额与回水由平台超管配置;您可向直属下级代理与玩家下放额度。",
})
: t("lineUi.profileReadOnlyHint", {
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
})
: t("lineUi.profileTabHint", {
defaultValue:
"占成、授信、回水与风控标签在此维护;登录名、密码与启停状态请用「编辑代理」。",
})}
</p>
</CardHeader>
<CardContent className="pt-5">
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
{canManageNode && canEditProfileTab ? (
<div className="mt-6 flex justify-end border-t border-border/60 pt-5">
<Button
type="button"
className="min-w-[10rem]"
disabled={profileSaving || profileFields.loading}
onClick={onSaveProfile}
>
{profileSaving
? t("common:actions.saving", { defaultValue: "保存中…" })
: t("lineUi.saveProfile", { defaultValue: "保存占成与授信" })}
</Button>
</div>
) : null}
</CardContent>
</Card>
<div className="mx-auto max-w-3xl space-y-5">
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
{canSaveProfileTab ? (
<div className="flex justify-end">
<Button
type="button"
disabled={profileSaving || profileFields.loading}
onClick={onSaveProfile}
>
{profileSaving
? t("common:actions.saving", { defaultValue: "保存中…" })
: t("lineUi.saveProfile", { defaultValue: "保存" })}
</Button>
</div>
) : null}
</div>
) : null}
{detailTab === "downline" && canViewDownlineTab && !profileLoading ? (
<DownlineTable
{detailTab === "downline" && canViewDownlineTab ? (
<DownlineSection
childAgents={childAgents}
childCountById={childCountById}
parentTotalShareRate={profile?.total_share_rate}
canManageNode={canManageNode}
canCreateChild={canCreateChild}
canDeleteChild={canDeleteChild}
@@ -324,13 +218,12 @@ export function AgentLineDetailPanel({
/>
) : null}
{detailTab === "players" && canViewPlayersTab && !profileLoading ? (
{detailTab === "players" && canViewPlayersTab ? (
<AgentsPlayersPanel
siteCode={siteCode}
agentNodeId={node.id}
allowCreatePlayer={canCreatePlayerAction}
embedded
createRequestKey={playerCreateRequestKey}
/>
) : null}
</div>
@@ -338,150 +231,36 @@ export function AgentLineDetailPanel({
);
}
function OverviewTab({
function AgentHeaderMetrics({
profile,
profileLoading,
profileReadOnly,
loading,
}: {
profile: AgentProfileRow | null;
profileLoading: boolean;
profileReadOnly: boolean;
loading: boolean;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const { t } = useTranslation("agents");
const rebateCap =
profile && !profileLoading ? percentValueToUi(profile.rebate_limit ?? 0) : null;
const parentRelativeShare = relativeShareRate(
profile?.total_share_rate,
profile?.parent_caps?.total_share_rate,
);
if (loading) {
return <p className="text-sm text-muted-foreground"></p>;
}
const yesLabel = t("common:states.yes", { defaultValue: "是" });
const noLabel = t("common:states.no", { defaultValue: "" });
const items = [
`${t("profile.totalShareRate", { defaultValue: "占成" })} ${profile?.total_share_rate ?? 0}%`,
`${t("profile.creditLimit", { defaultValue: "授信" })} ${formatCredit(profile?.credit_limit ?? 0)}`,
`${t("lineUi.availableCredit", { defaultValue: "可下发" })} ${formatCredit(profile?.available_credit ?? 0)}`,
`${t("profile.rebateLimit", { defaultValue: "回水上限" })} ${percentValueToUi(profile?.rebate_limit ?? 0)}%`,
];
return (
<div className="mx-auto max-w-5xl space-y-6">
{profileReadOnly ? (
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
{t("lineUi.selfAgentOverviewHint", {
defaultValue:
"以下为上级为您分配的占成与授信;如需调整请联系上级代理或平台。",
})}
</p>
) : null}
<div className="grid min-w-0 grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
money={false}
subtitle={
parentRelativeShare
? t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: parentRelativeShare,
})
: undefined
}
accent
/>
<MetricCard
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
/>
<MetricCard
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
/>
<MetricCard
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
highlight
/>
</div>
<div className="grid min-w-0 grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
value={profileLoading ? "…" : `${rebateCap ?? "0"}%`}
money={false}
/>
<MetricCard
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
value={
profileLoading ? "…" : `${percentValueToUi(profile?.default_player_rebate ?? 0)}%`
}
money={false}
/>
<MetricCard
label={t("profile.riskTags", { defaultValue: "风控标签" })}
value={
profileLoading
? "…"
: (profile?.risk_tags?.length ?? 0) > 0
? profile!.risk_tags!.join(", ")
: t("common:states.none", { defaultValue: "无" })
}
money={false}
/>
<CapabilityMetric
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
enabled={profile?.can_grant_extra_rebate === true}
loading={profileLoading}
yesLabel={yesLabel}
noLabel={noLabel}
/>
<CapabilityMetric
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
enabled={profile?.can_create_player !== false}
loading={profileLoading}
yesLabel={yesLabel}
noLabel={noLabel}
/>
<CapabilityMetric
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
enabled={profile?.can_create_child_agent === true}
loading={profileLoading}
yesLabel={yesLabel}
noLabel={noLabel}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">
{items.join(" · ")}
</p>
);
}
function CapabilityMetric({
label,
enabled,
loading = false,
yesLabel,
noLabel,
}: {
label: string;
enabled: boolean;
loading?: boolean;
yesLabel: string;
noLabel: string;
}): React.ReactElement {
return (
<div className="rounded-xl border border-border/70 bg-card px-4 py-4 shadow-sm">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p
className={cn(
"mt-1.5 text-2xl font-semibold tracking-tight",
loading ? "text-muted-foreground" : enabled ? "text-foreground" : "text-muted-foreground",
)}
>
{loading ? "…" : enabled ? yesLabel : noLabel}
</p>
</div>
);
}
function DownlineTable({
function DownlineSection({
childAgents,
childCountById,
parentTotalShareRate,
canManageNode,
canCreateChild,
canDeleteChild,
@@ -492,7 +271,6 @@ function DownlineTable({
}: {
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
parentTotalShareRate?: number;
canManageNode: boolean;
canCreateChild: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
@@ -502,174 +280,158 @@ function DownlineTable({
onAddChild: () => void;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const createChildLabel = t("lineUi.createDownline", { defaultValue: "创建下级代理" });
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" });
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" });
if (childAgents.length === 0) {
return (
<AdminNoResourceState message={t("lineUi.downlineEmptyShort", { defaultValue: "暂无直属下级" })}>
{canManageNode && canCreateChild ? (
<Button type="button" size="sm" onClick={onAddChild}>
<Plus className="mr-1.5 size-3.5" />
{t("createChild", { defaultValue: "添加下级" })}
</Button>
) : null}
</AdminNoResourceState>
);
}
return (
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead>
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
<TableHead>{t("lineUi.downlineColumns.email", { defaultValue: "邮箱" })}</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.totalShareRate", { defaultValue: "占成 (%)" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级数" })}
</TableHead>
<TableHead className="w-24">{t("common:status.label", { defaultValue: "状态" })}</TableHead>
{canManageNode ? (
<TableHead className="sticky right-0 z-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{childAgents.length === 0 ? (
<AdminTableNoResourceRow colSpan={canManageNode ? 10 : 9} cellClassName="py-12 text-center" />
) : (
childAgents.map((child) => {
const summary = child.profile_summary;
return (
<TableRow
key={child.id}
className="cursor-pointer"
onClick={() => onSelectChild(child)}
>
<TableCell className="font-mono text-xs">{child.code}</TableCell>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell className="text-xs">{child.username ?? "—"}</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs text-muted-foreground">
{child.email ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? (
<div className="space-y-0.5">
<div>{`${summary.total_share_rate ?? 0}%`}</div>
{parentTotalShareRate && parentTotalShareRate > 0 ? (
<div className="text-[11px] text-muted-foreground">
{t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: relativeShareRate(
summary.total_share_rate,
parentTotalShareRate,
) ?? "0",
})}
</div>
) : null}
</div>
) : "—"}
</TableCell>
<TableCell className={adminMoneyCellClassName("text-right text-xs")}>
{summary ? <AdminTableMoney>{formatCredit(summary.credit_limit)}</AdminTableMoney> : "—"}
</TableCell>
<TableCell className={adminMoneyCellClassName("text-right text-xs")}>
{summary ? <AdminTableMoney>{formatCredit(summary.allocated_credit)}</AdminTableMoney> : "—"}
</TableCell>
<TableCell className="text-center tabular-nums text-xs">
{childCountById.get(child.id) ?? 0}
</TableCell>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
{child.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</TableCell>
{canManageNode ? (
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
actions={[
{
key: "edit",
label: editChildLabel,
icon: Pencil,
onClick: () => onEditChild(child),
},
{
key: "delete",
label: deleteChildLabel,
icon: Trash2,
destructive: true,
disabled: !canDeleteChild(child),
onClick: () => onDeleteChild(child),
},
]}
/>
</TableCell>
) : null}
</TableRow>
);
})
)}
</TableBody>
</Table>
<div className="space-y-3">
{canManageNode && canCreateChild ? (
<div className="flex justify-end">
<Button type="button" size="sm" onClick={onAddChild}>
<Plus className="mr-1.5 size-3.5" />
{t("createChild", { defaultValue: "添加下级" })}
</Button>
</div>
) : null}
<DownlineTable
childAgents={childAgents}
childCountById={childCountById}
canManageNode={canManageNode}
onEditChild={onEditChild}
onDeleteChild={onDeleteChild}
onSelectChild={onSelectChild}
canDeleteChild={canDeleteChild}
/>
</div>
);
}
function MetricCard({
label,
value,
subtitle,
accent = false,
highlight = false,
money = true,
function DownlineTable({
childAgents,
childCountById,
canManageNode,
canDeleteChild,
onEditChild,
onDeleteChild,
onSelectChild,
}: {
label: string;
value: string;
subtitle?: string;
accent?: boolean;
highlight?: boolean;
/** 金额类指标:自适应字号 + 换行 */
money?: boolean;
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
canManageNode: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void;
onSelectChild: (node: AgentNodeRow) => void;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
return (
<div
className={cn(
"min-w-0 overflow-visible rounded-xl border bg-card px-4 py-4 shadow-sm transition-colors",
highlight && "border-primary/25 bg-primary/[0.04]",
accent && !highlight && "border-border/70",
!accent && !highlight && "border-border/70",
)}
>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
{money ? (
<AdminMoneyDisplay
as="p"
value={value}
size="lg"
className={cn("mt-1.5", highlight ? "text-primary" : "text-foreground")}
>
{value}
</AdminMoneyDisplay>
) : (
<p
className={cn(
"mt-1.5 text-2xl font-semibold tabular-nums tracking-tight",
highlight ? "text-primary" : "text-foreground",
)}
>
{value}
</p>
)}
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
<div className="admin-table-inset overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>{t("agentName", { defaultValue: "名称" })}</TableHead>
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.totalShareRate", { defaultValue: "占成" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.creditLimit", { defaultValue: "授信" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级" })}
</TableHead>
<TableHead>{t("common:status.label", { defaultValue: "状态" })}</TableHead>
{canManageNode ? (
<TableHead className="sticky right-0 z-20 w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{childAgents.map((child) => {
const summary = child.profile_summary;
return (
<TableRow
key={child.id}
className="cursor-pointer"
onClick={() => onSelectChild(child)}
>
<TableCell>
<div className="font-medium">{child.name}</div>
<div className="font-mono text-xs text-muted-foreground">{child.code}</div>
</TableCell>
<TableCell className="text-sm">{child.username ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-sm">
{summary ? `${summary.total_share_rate ?? 0}%` : "—"}
</TableCell>
<TableCell className={adminMoneyCellClassName("text-right text-sm")}>
{summary ? <AdminTableMoney>{formatCredit(summary.credit_limit)}</AdminTableMoney> : "—"}
</TableCell>
<TableCell className={adminMoneyCellClassName("text-right text-sm")}>
{summary ? (
<AdminTableMoney>{formatCredit(summary.available_credit)}</AdminTableMoney>
) : (
"—"
)}
</TableCell>
<TableCell className="text-center tabular-nums text-sm">
{childCountById.get(child.id) ?? 0}
</TableCell>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
{child.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</TableCell>
{canManageNode ? (
<TableCell
className="sticky right-0 z-10 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
actions={[
{
key: "edit",
label: t("lineUi.editDownline", { defaultValue: "编辑" }),
icon: Pencil,
onClick: () => onEditChild(child),
},
{
key: "delete",
label: t("lineUi.deleteDownline", { defaultValue: "删除" }),
icon: Trash2,
destructive: true,
disabled: !canDeleteChild(child),
onClick: () => onDeleteChild(child),
},
]}
/>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
}

View File

@@ -22,6 +22,7 @@ import { Switch } from "@/components/ui/switch";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
import {
ADMIN_PASSWORD_MIN_LENGTH,
validateAdminLoginAccount,
validateAdminPassword,
} from "@/lib/admin-input-validation";
@@ -75,8 +76,9 @@ export function AgentLineProvisionWizard({
return;
}
const normalized = defaultSiteCode.trim().toLowerCase();
if (unboundSites.some((row) => row.code.toLowerCase() === normalized)) {
setForm((f) => ({ ...f, site_code: normalized }));
const matched = unboundSites.find((row) => row.code.toLowerCase() === normalized);
if (matched) {
setForm((f) => ({ ...f, site_code: matched.code }));
}
}, [defaultSiteCode, form.site_code, sitesLoading, unboundSites]);
@@ -109,7 +111,7 @@ export function AgentLineProvisionWizard({
}
const passwordIssue = validateAdminPassword(form.password);
if (passwordIssue === "too_short") {
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 8 位" }));
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 6 位" }));
return;
}
@@ -282,10 +284,13 @@ export function AgentLineProvisionWizard({
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required
minLength={8}
minLength={ADMIN_PASSWORD_MIN_LENGTH}
/>
<p className="text-xs text-muted-foreground">
{t("agents:lineProvision.passwordHint", { defaultValue: "至少 8 位" })}
{t("agents:lineProvision.passwordHint", {
defaultValue: "至少 {{min}} 位",
min: ADMIN_PASSWORD_MIN_LENGTH,
})}
</p>
</div>

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
@@ -56,6 +57,36 @@ function pruneTreeForSearch(
return out;
}
function collectSearchExpandIds(
nodes: AgentNodeRow[],
normalized: string,
parentNameMap: Map<number, string>,
): Set<number> {
const ids = new Set<number>();
const walk = (list: AgentNodeRow[], ancestors: number[]): void => {
for (const node of list) {
const children = node.children ?? [];
const prunedChildren = pruneTreeForSearch(children, normalized, parentNameMap);
const selfMatch = nodeMatchesKeyword(node, normalized, parentNameMap);
if (selfMatch || prunedChildren.length > 0) {
for (const id of ancestors) {
ids.add(id);
}
if (prunedChildren.length > 0) {
ids.add(node.id);
}
walk(prunedChildren, [...ancestors, node.id]);
}
}
};
walk(nodes, []);
return ids;
}
export type AgentLineSidebarProps = {
siteLabel: string | null;
/** API 返回的嵌套树(含 children */
@@ -67,6 +98,8 @@ export type AgentLineSidebarProps = {
loading?: boolean;
onKeywordChange: (value: string) => void;
onSelect: (node: AgentNodeRow) => void;
errorMessage?: string | null;
onRetry?: () => void;
};
type TreeRowProps = {
@@ -98,7 +131,7 @@ function TreeRow({
<div
className={cn(
"flex w-full items-start gap-0.5 rounded-md py-1 pr-2 transition-colors",
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-background/80",
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-muted/15",
)}
style={{ paddingLeft: `${6 + indent}px` }}
>
@@ -163,6 +196,8 @@ export function AgentLineSidebar({
loading = false,
onKeywordChange,
onSelect,
errorMessage = null,
onRetry,
}: AgentLineSidebarProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
@@ -220,6 +255,19 @@ export function AgentLineSidebar({
});
}, [selectedId, tree]);
useEffect(() => {
if (normalizedKeyword === "") {
return;
}
const expandIds = collectSearchExpandIds(tree, normalizedKeyword, parentNameMap);
if (expandIds.size === 0) {
return;
}
setExpandedIds((prev) => new Set([...prev, ...expandIds]));
}, [normalizedKeyword, parentNameMap, tree]);
const toggleExpand = useCallback((id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev);
@@ -236,19 +284,8 @@ export function AgentLineSidebar({
const hasAnyAgent = displayForest.length > 0;
return (
<aside className="flex min-h-0 h-full w-full flex-col bg-muted/10 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
<div className="space-y-3 border-b border-border/60 bg-card px-4 py-4">
{siteLabel ? (
<p className="truncate text-xs font-medium text-foreground/80" title={siteLabel}>
{siteLabel}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{t("lineUi.agentCount", {
defaultValue: "本组 {{count}} 个代理",
count: agentCount,
})}
</p>
<aside className="flex min-h-0 h-full w-full flex-col lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
<div className="space-y-3 border-b border-border/60 px-4 py-4">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -265,6 +302,15 @@ export function AgentLineSidebar({
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{loading ? (
<AdminLoadingInline className="py-10" />
) : !hasAnyAgent && errorMessage ? (
<div className="space-y-3 px-2 py-8 text-center">
<p className="text-sm text-destructive">{errorMessage}</p>
{onRetry ? (
<Button type="button" size="sm" variant="outline" onClick={onRetry}>
{t("common:actions.retry", { defaultValue: "重试" })}
</Button>
) : null}
</div>
) : !hasAnyAgent ? (
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
) : (

View File

@@ -111,14 +111,29 @@ export function AgentProfileFields({
max: maxCreditLimit,
});
const showFieldHints = !isCard;
return (
<div className="space-y-6">
{(parentCaps || availableCredit !== null) && !loading ? (
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
<div className="flex flex-col gap-1.5 min-w-0">
{parentCaps ? (
<div className="flex flex-wrap items-center justify-between gap-2">
isCard ? (
<p className="text-sm text-muted-foreground">
{parentCaps
? t("profile.parentCaps", {
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
share: parentCaps.total_share_rate,
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})
: t("profile.availableCredit", {
defaultValue: "可下发额度 {{amount}}",
amount: formatAdminCreditMajorDecimal(availableCredit ?? 0, currencyCode),
})}
</p>
) : (
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
<div className="flex flex-col gap-1.5 min-w-0">
{parentCaps ? (
<p className="text-sm font-medium leading-snug">
{t("profile.parentCaps", {
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
@@ -126,27 +141,27 @@ export function AgentProfileFields({
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})}
</p>
</div>
) : null}
{availableCredit !== null ? (
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
{t("profile.availableCredit", {
defaultValue: "可下发额度 {{amount}}",
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
})}
</p>
) : null}
) : null}
{availableCredit !== null ? (
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
{t("profile.availableCredit", {
defaultValue: "可下发额度 {{amount}}",
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
})}
</p>
) : null}
</div>
</div>
</div>
)
) : null}
{loading ? (
<p className="text-sm text-muted-foreground animate-pulse">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
{!profileScalarsEditable && !loading ? (
{!profileScalarsEditable && !loading && !isCard ? (
<p className="rounded-lg border border-amber-200/80 bg-amber-50 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
{t("profile.lineRootScalarsReadOnlyHint", {
defaultValue:
@@ -166,7 +181,7 @@ export function AgentProfileFields({
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" />
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" flat={isCard} />
) : (
<AdminNumericStepper
id={`${idPrefix}-share-rate`}
@@ -179,19 +194,20 @@ export function AgentProfileFields({
preserveOverMaxOnBlur
/>
)}
{parentCaps ? (
{showFieldHints && parentCaps ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.relativeShareCapHint", {
defaultValue: "占上级比例最高 100%(上级总占成 {{parent}}%",
parent: parentCaps.total_share_rate,
})}
</p>
) : (
) : null}
{showFieldHints && !parentCaps ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
</p>
)}
{parentCaps && shareRate && actualShare !== null ? (
) : null}
{showFieldHints && parentCaps && shareRate && actualShare !== null ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.actualShareRate", {
defaultValue: "实际占成 {{rate}}%",
@@ -208,7 +224,7 @@ export function AgentProfileFields({
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} />
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} flat={isCard} />
) : (
<AdminNumericStepper
id={`${idPrefix}-credit-limit`}
@@ -221,7 +237,7 @@ export function AgentProfileFields({
preserveOverMaxOnBlur
/>
)}
{parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
{showFieldHints && parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.creditParentCapHint", {
defaultValue: "最高 {{max}}(上级可再下发 {{available}}",
@@ -246,7 +262,7 @@ export function AgentProfileFields({
})}
</p>
) : null}
{minCreditLimit > 0 ? (
{showFieldHints && minCreditLimit > 0 ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.creditAllocatedFloorHint", {
defaultValue: "不可低于已下发 {{amount}}",
@@ -263,7 +279,7 @@ export function AgentProfileFields({
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" />
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" flat={isCard} />
) : (
<AdminNumericStepper
id={`${idPrefix}-rebate-limit`}
@@ -297,7 +313,7 @@ export function AgentProfileFields({
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" />
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" flat={isCard} />
) : (
<AdminNumericStepper
id={`${idPrefix}-default-rebate`}
@@ -329,11 +345,15 @@ export function AgentProfileFields({
id={`${idPrefix}-risk-tags`}
value={riskTags.trim() === "" ? "—" : riskTags}
className="justify-start font-normal"
flat={isCard}
/>
) : (
<Input
id={`${idPrefix}-risk-tags`}
className="h-10 bg-background/50 transition-colors focus:bg-background"
className={cn(
"h-10 transition-colors",
isCard ? "bg-card focus:bg-card" : "bg-background/50 focus:bg-background",
)}
value={riskTags}
onChange={(e) => onRiskTagsChange(e.target.value)}
placeholder={t("profile.riskTagsPlaceholder", {
@@ -345,21 +365,29 @@ export function AgentProfileFields({
</div>
<div className="pt-2">
<div className="rounded-xl border border-border/80 bg-muted/20 overflow-hidden shadow-sm">
<div
className={cn(
"overflow-hidden rounded-xl border border-border/80",
isCard ? undefined : "bg-muted/20 shadow-sm",
)}
>
{showReadOnlyDisplay ? (
<>
<ReadOnlySwitchRow
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
checked={extraRebate}
flat={isCard}
/>
<ReadOnlySwitchRow
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
checked={canCreatePlayer}
flat={isCard}
/>
<ReadOnlySwitchRow
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
checked={canCreateChild}
isLast
flat={isCard}
/>
</>
) : (
@@ -368,11 +396,13 @@ export function AgentProfileFields({
checked={extraRebate}
onCheckedChange={onExtraRebateChange}
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
flat={isCard}
/>
<SwitchRow
checked={canCreatePlayer}
onCheckedChange={onCanCreatePlayerChange}
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
flat={isCard}
/>
<SwitchRow
checked={canCreateChild}
@@ -380,6 +410,7 @@ export function AgentProfileFields({
disabled={!canCreateChildAgent && !isSuperAdmin}
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
isLast
flat={isCard}
/>
</>
)}
@@ -402,17 +433,20 @@ function ReadOnlyScalar({
value,
suffix,
className,
flat = false,
}: {
id?: string;
value: string;
suffix?: string;
className?: string;
flat?: boolean;
}): React.ReactElement {
return (
<div
id={id}
className={cn(
"flex min-h-10 min-w-0 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 py-2 text-center shadow-xs",
"flex min-h-10 min-w-0 items-center justify-center rounded-md border border-border/80 px-3 py-2 text-center",
flat ? "bg-card" : "bg-muted/35 shadow-xs",
className,
)}
>
@@ -428,17 +462,20 @@ function ReadOnlySwitchRow({
label,
checked,
isLast = false,
flat = false,
}: {
label: string;
checked: boolean;
isLast?: boolean;
flat?: boolean;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
return (
<div
className={cn(
"flex items-center justify-between gap-4 bg-background/60 px-4 py-3.5",
"flex items-center justify-between gap-4 px-4 py-3.5",
!flat && "bg-background/60",
!isLast && "border-b border-border/60",
)}
>
@@ -458,18 +495,23 @@ function SwitchRow({
label,
disabled = false,
isLast = false,
flat = false,
}: {
checked: boolean;
onCheckedChange: (value: boolean) => void;
label: string;
disabled?: boolean;
isLast?: boolean;
flat?: boolean;
}): React.ReactElement {
return (
<div className={cn(
"flex items-center justify-between gap-4 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30",
!isLast && "border-b border-border/50"
)}>
<div
className={cn(
"flex items-center justify-between gap-4 px-4 py-3.5 transition-colors",
flat ? "hover:bg-muted/15" : "bg-background/50 hover:bg-muted/30",
!isLast && "border-b border-border/50",
)}
>
<Label
className={cn("font-medium", disabled ? "cursor-default text-muted-foreground" : "cursor-pointer")}
onClick={() => !disabled && onCheckedChange(!checked)}

View File

@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -13,6 +13,7 @@ import {
putAgentNode,
putAgentNodeProfile,
} from "@/api/admin-agents";
import { getAdminPlayers } from "@/api/admin-player";
import {
AgentLineDetailPanel,
type AgentDetailTab,
@@ -94,6 +95,8 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
export function AgentsConsole(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const tRef = useTranslationRef(["agents", "common"]);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const profile = useAdminProfile();
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
@@ -123,12 +126,12 @@ export function AgentsConsole(): React.ReactElement {
const [err, setErr] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [detailTab, setDetailTab] = useState<AgentDetailTab>("overview");
const [detailTab, setDetailTab] = useState<AgentDetailTab>("profile");
const [profileSaving, setProfileSaving] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
const [selectedProfileErr, setSelectedProfileErr] = useState<string | null>(null);
const [playerCreateRequestKey, setPlayerCreateRequestKey] = useState(0);
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
const [targetParentId, setTargetParentId] = useState<number | null>(null);
@@ -307,6 +310,10 @@ export function AgentsConsole(): React.ReactElement {
}
return null;
}, [adminSiteId, boundSite, siteOptions]);
const rootNode = useMemo(
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
[flatNodes],
);
const activeSiteCode = useMemo(() => {
const fromAgent = boundAgent?.site_code?.trim();
if (fromAgent) {
@@ -319,12 +326,8 @@ export function AgentsConsole(): React.ReactElement {
if (boundSite != null && boundSite.id === adminSiteId) {
return boundSite.code.trim();
}
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
}, [adminSiteId, boundAgent?.site_code, boundSite, flatNodes, siteOptions]);
const rootNode = useMemo(
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
[flatNodes],
);
return rootNode?.site_code?.trim() ?? "";
}, [adminSiteId, boundAgent?.site_code, boundSite, rootNode?.site_code, siteOptions]);
const selectedNode = useMemo(
() =>
@@ -342,8 +345,7 @@ export function AgentsConsole(): React.ReactElement {
const isOwnAgentNode =
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
const canViewProfileTab =
canManageProfile && selectedNode !== null && !isOwnAgentNode;
const canViewProfileTab = canManageProfile && selectedNode !== null;
const canEditSelectedProfile =
canViewProfileTab && isRootProfileEditableByActor(selectedNode, isSuperAdmin);
@@ -473,6 +475,7 @@ export function AgentsConsole(): React.ReactElement {
.catch(() => {
if (!cancelled) {
setSelectedProfile(null);
resetProfileForm();
setSelectedProfileErr(
t("profile.loadFailed", { defaultValue: "代理档案加载失败,请稍后重试。" }),
);
@@ -500,51 +503,46 @@ export function AgentsConsole(): React.ReactElement {
.catch(() => setRootProfile(null));
}, [rootNode?.id]);
/** 代理看自己,或站点方看一级代理:占成授信 Tab 只读 */
const profileReadOnly = isOwnAgentNode || lineRootProfileLocked;
const isSiteAdmin = isSiteAdminOperator(profile);
const canShowDownlineTab = useMemo(
() =>
selectedNode !== null &&
(selectedProfileLoading ||
isSiteAdmin ||
isSuperAdmin ||
selectedProfile?.can_create_child_agent === true),
[isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
);
const canShowDownlineTab = selectedNode !== null;
const canShowPlayersTab = useMemo(
() =>
selectedNode !== null &&
(selectedProfileLoading ||
(hasUsersManagePermission &&
(isSiteAdmin ||
isSuperAdmin ||
selectedProfile?.can_create_player === true))),
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
(hasUsersManagePermission || isSiteAdmin || isSuperAdmin),
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode],
);
const playersTabHint = useMemo(() => {
if (selectedNode === null || selectedProfileLoading) {
return null;
const canCreatePlayerOnSelected = useMemo(() => {
if (selectedNode === null || selectedProfileLoading || !hasUsersManagePermission) {
return false;
}
if (selectedProfile?.can_create_player !== true) {
return t("lineUi.playersUnavailableHint", {
defaultValue: "当前代理未开启“允许创建玩家”,如需新增请先调整该代理配置。",
});
if (boundAgent !== null && selectedNode.id !== boundAgent.id) {
return false;
}
if (!hasUsersManagePermission) {
return t("lineUi.playersNoPermissionHint", {
defaultValue: "当前账号没有该节点的玩家管理权限。",
});
if (isSuperAdmin || isSiteAdmin) {
return selectedProfile?.can_create_player !== false;
}
if (boundAgent !== null) {
return selectedProfile?.can_create_player === true;
}
return false;
}, [
boundAgent,
hasUsersManagePermission,
isSiteAdmin,
isSuperAdmin,
selectedNode,
selectedProfile?.can_create_player,
selectedProfileLoading,
]);
return null;
}, [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading, t]);
const [directPlayerCountById, setDirectPlayerCountById] = useState<Map<number, number>>(
() => new Map(),
);
const prevSelectedNodeIdRef = useRef<number | null>(null);
const canCreateChildOnSelected = useMemo(
() =>
@@ -561,11 +559,11 @@ export function AgentsConsole(): React.ReactElement {
}
if (detailTab === "profile" && !canViewProfileTab) {
setDetailTab("overview");
setDetailTab(canShowPlayersTab ? "players" : "downline");
} else if (detailTab === "downline" && !canShowDownlineTab) {
setDetailTab(canShowPlayersTab ? "players" : "overview");
setDetailTab(canShowPlayersTab ? "players" : "profile");
} else if (detailTab === "players" && !canShowPlayersTab) {
setDetailTab(canShowDownlineTab ? "downline" : "overview");
setDetailTab(canViewProfileTab ? "profile" : "downline");
}
}, [
canShowDownlineTab,
@@ -577,14 +575,89 @@ export function AgentsConsole(): React.ReactElement {
]);
useEffect(() => {
setDetailTab("overview");
}, [selectedNodeId]);
if (selectedNodeId === null) {
prevSelectedNodeIdRef.current = null;
return;
}
const nodeChanged = prevSelectedNodeIdRef.current !== selectedNodeId;
prevSelectedNodeIdRef.current = selectedNodeId;
if (!nodeChanged) {
return;
}
setDetailTab((current) => {
if (current === "profile" && canViewProfileTab) {
return current;
}
if (current === "downline" && canShowDownlineTab) {
return current;
}
if (current === "players" && canShowPlayersTab) {
return current;
}
if (canViewProfileTab) {
return "profile";
}
if (canShowPlayersTab) {
return "players";
}
return "downline";
});
}, [canShowDownlineTab, canShowPlayersTab, canViewProfileTab, selectedNodeId]);
useEffect(() => {
if (isOwnAgentNode && detailTab === "profile") {
setDetailTab("overview");
if (detailTab !== "downline" || activeSiteCode.trim() === "") {
return;
}
}, [detailTab, isOwnAgentNode]);
let cancelled = false;
for (const child of selectedChildAgents) {
void getAdminPlayers({
site_code: activeSiteCode.trim(),
agent_node_id: child.id,
page: 1,
per_page: 1,
})
.then((data) => {
if (cancelled) {
return;
}
setDirectPlayerCountById((prev) => {
const next = new Map(prev);
next.set(child.id, data.meta.total);
return next;
});
})
.catch(() => {
/* 删除按钮降级为仅后端校验 */
});
}
return () => {
cancelled = true;
};
}, [activeSiteCode, detailTab, selectedChildAgents]);
const syncSelectedNodeToUrl = useCallback(
(nodeId: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("agent_node_id", String(nodeId));
params.delete("node");
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
},
[pathname, router, searchParams],
);
const selectAgentNode = useCallback(
(node: AgentNodeRow) => {
setSelectedNodeId(node.id);
syncSelectedNodeToUrl(node.id);
},
[syncSelectedNodeToUrl],
);
useAsyncEffect(() => {
if (!canViewAgents) {
@@ -674,8 +747,9 @@ export function AgentsConsole(): React.ReactElement {
const canDeleteNode = (node: AgentNodeRow): boolean => {
const blockedByChildren = (node.children?.length ?? 0) > 0;
const blockedBySelf = profile?.agent?.id === node.id;
const blockedByPlayers = (directPlayerCountById.get(node.id) ?? 0) > 0;
return canManageNode && !blockedByChildren && !blockedBySelf;
return canManageNode && !blockedByChildren && !blockedBySelf && !blockedByPlayers;
};
const handleDeleteNode = (node: AgentNodeRow): void => {
@@ -732,7 +806,8 @@ export function AgentsConsole(): React.ReactElement {
}
return {
disabled: profileReadOnly || !canEditSelectedProfile,
disabled:
isOwnAgentNode || lineRootProfileLocked || !canEditSelectedProfile,
loading: selectedProfileLoading,
parentCaps: profileParentCaps,
availableCredit: profileAvailableCredit,
@@ -763,7 +838,8 @@ export function AgentsConsole(): React.ReactElement {
canEditSelectedProfile,
canViewProfileTab,
isSuperAdmin,
profileReadOnly,
isOwnAgentNode,
lineRootProfileLocked,
selectedNode,
profileAvailableCredit,
profileCanCreateChild,
@@ -780,7 +856,7 @@ export function AgentsConsole(): React.ReactElement {
selectedProfileLoading,
]);
const showAgentSidebar = loading || visibleAgentRows.length > 0;
const showAgentSidebar = loading || visibleAgentRows.length > 0 || err !== null;
const hasSiteContext =
siteOptions.length > 0 ||
@@ -832,13 +908,13 @@ export function AgentsConsole(): React.ReactElement {
}
const passwordIssue = validateAdminPassword(nodePassword);
if (passwordIssue === "too_short") {
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
toast.error(t("passwordMinLength", { defaultValue: "密码至少 6 位" }));
return;
}
} else if (nodePassword.trim()) {
const passwordIssue = validateAdminPassword(nodePassword);
if (passwordIssue === "too_short") {
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
toast.error(t("passwordMinLength", { defaultValue: "密码至少 6 位" }));
return;
}
} else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) {
@@ -876,8 +952,9 @@ export function AgentsConsole(): React.ReactElement {
setNodeSaving(true);
try {
let createdNodeId: number | null = null;
if (nodeDialogMode === "create" && targetParentId !== null) {
await postAgentNode({
const created = await postAgentNode({
parent_id: targetParentId,
name: nodeName.trim(),
username: nodeUsername.trim(),
@@ -885,6 +962,7 @@ export function AgentsConsole(): React.ReactElement {
status: nodeStatus,
...(canManageProfile ? profilePayload(null) : {}),
});
createdNodeId = created.id;
toast.success(t("createSuccess", { name: nodeName.trim() }));
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
await putAgentNode(editingNodeId, {
@@ -903,16 +981,12 @@ export function AgentsConsole(): React.ReactElement {
setNodeDialogOpen(false);
await loadTree(adminSiteId);
if (nodeDialogMode === "create" && targetParentId !== null) {
const refreshed = flattenTree((await getAgentTree(adminSiteId ?? undefined)).tree);
const created = refreshed.find(
(node) => node.parent_id === targetParentId && node.name === nodeName.trim(),
);
if (created) {
setSelectedNodeId(created.id);
}
if (nodeDialogMode === "create" && createdNodeId !== null) {
setSelectedNodeId(createdNodeId);
syncSelectedNodeToUrl(createdNodeId);
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
setSelectedNodeId(editingNodeId);
syncSelectedNodeToUrl(editingNodeId);
if (canManageProfile) {
void getAgentNodeProfile(editingNodeId)
.then((p) => {
@@ -996,8 +1070,13 @@ export function AgentsConsole(): React.ReactElement {
<AgentLineProvisionWizard
embedded
defaultSiteCode={activeSiteCode}
onSuccess={async () => {
onSuccess={async (result) => {
await loadTree(adminSiteId);
const nodeId = result.line_root?.agent_node_id ?? result.agent_node?.id ?? null;
if (nodeId !== null) {
setSelectedNodeId(nodeId);
syncSelectedNodeToUrl(nodeId);
}
}}
/>
</div>
@@ -1024,24 +1103,20 @@ export function AgentsConsole(): React.ReactElement {
onKeywordChange={(value) => {
setKeyword(value);
}}
onSelect={(node) => {
setSelectedNodeId(node.id);
}}
onSelect={selectAgentNode}
errorMessage={!loading && visibleAgentRows.length === 0 ? err : null}
onRetry={() => void loadTree(adminSiteId)}
/>
) : null}
{selectedProfileErr ? (
<p className="border-b border-border/70 px-4 py-2 text-sm text-destructive">{selectedProfileErr}</p>
) : null}
<AgentLineDetailPanel
node={selectedNode}
profile={selectedProfile}
profileLoading={selectedProfileLoading}
profileError={selectedProfileErr}
childAgents={selectedChildAgents}
childCountById={childCountById}
siteCode={activeSiteCode}
siteLabel={selectedSiteLabel}
parentName={
selectedNode?.parent_id !== null && selectedNode?.parent_id !== undefined
? (parentNameMap.get(selectedNode.parent_id) ?? null)
@@ -1050,33 +1125,22 @@ export function AgentsConsole(): React.ReactElement {
detailTab={detailTab}
onDetailTabChange={setDetailTab}
canViewProfileTab={canViewProfileTab}
canEditProfileTab={canEditSelectedProfile}
profileReadOnly={profileReadOnly}
canEditProfileTab={canEditSelectedProfile && !isOwnAgentNode}
canSaveProfileTab={canManageProfile && canEditSelectedProfile && !isOwnAgentNode}
canViewDownlineTab={canShowDownlineTab}
canViewPlayersTab={canShowPlayersTab}
playersTabHint={playersTabHint}
canManageNode={canManageNode}
canCreateChild={canCreateChildOnSelected}
canCreateChildAgent={canCreateChildAgent}
canCreatePlayerAction={
isSuperAdmin ||
isSiteAdmin ||
(selectedProfile?.can_create_player === true &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]))
}
canCreatePlayerAction={canCreatePlayerOnSelected}
canDeleteChild={canDeleteNode}
onEditChild={(node) => openEditForNode(node)}
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
onAddPlayer={() => setPlayerCreateRequestKey((value) => value + 1)}
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
onDeleteChild={(node) => handleDeleteNode(node)}
onSelectChild={(child) => {
setSelectedNodeId(child.id);
}}
onSelectChild={selectAgentNode}
profileFields={inlineProfileFields}
profileSaving={profileSaving}
onSaveProfile={() => void saveInlineProfile()}
playerCreateRequestKey={playerCreateRequestKey}
/>
</div>
) : (

View File

@@ -127,38 +127,8 @@ export function AgentsDirectoryConsole(): React.ReactElement {
});
}, [items, keyword, parentNameMap, status]);
const totalOperatingAgents = useMemo(
() => items.filter((item) => !item.is_root).length,
[items],
);
const enabledOperatingAgents = useMemo(
() => items.filter((item) => !item.is_root && item.status === 1).length,
[items],
);
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
<p className="text-xs text-muted-foreground">
{t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalOperatingAgents}</p>
</div>
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
<p className="text-xs text-muted-foreground">
{t("summary.enabledAgents", { defaultValue: "启用中的经营代理数" })}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{enabledOperatingAgents}</p>
</div>
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
<p className="text-xs text-muted-foreground">
{t("summary.visibleList", { defaultValue: "当前平铺列表条数" })}
</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{filteredItems.length}</p>
</div>
</div>
<AdminPageCard
title={t("listTitle", { defaultValue: "代理列表" })}
actions={
@@ -209,10 +179,10 @@ export function AgentsDirectoryConsole(): React.ReactElement {
</div>
) : null}
<div className="overflow-x-auto">
<div className="admin-table-inset overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead>
<TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead>
<TableHead className="w-[90px]">{t("depth", { defaultValue: "层级" })}</TableHead>
@@ -231,7 +201,7 @@ export function AgentsDirectoryConsole(): React.ReactElement {
<TableHead className="w-[130px] text-right">
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
</TableHead>
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<TableHead className="sticky right-0 z-20 w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
</TableRow>
@@ -289,12 +259,12 @@ export function AgentsDirectoryConsole(): React.ReactElement {
<TableCell className="text-right">
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</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 shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{
key: "view",
label: t("common:actions.viewDetails", { defaultValue: "查看详情" }),
label: t("lineUi.openInTree", { defaultValue: "在树中打开" }),
icon: Eye,
href: `/admin/agents?agent_node_id=${item.id}`,
},

View File

@@ -0,0 +1,17 @@
"use client";
import { useSearchParams } from "next/navigation";
import { AgentsConsole } from "@/modules/agents/agents-console";
import { AgentsDirectoryConsole } from "@/modules/agents/agents-directory-console";
export function AgentsManagementScreen(): React.ReactElement {
const searchParams = useSearchParams();
const view = searchParams.get("view") === "list" ? "list" : "tree";
if (view === "list") {
return <AgentsDirectoryConsole />;
}
return <AgentsConsole />;
}

View File

@@ -1,18 +1,13 @@
"use client";
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Eye, Pencil, Plus, ReceiptText, Search, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAgentNodeProfile } from "@/api/admin-agents";
import {
getSettlementBills,
postSettlementBillBadDebtWriteOff,
postSettlementBillConfirm,
postSettlementBillPayment,
type SettlementBillRow,
} from "@/api/admin-agent-settlement";
import {
deleteAdminPlayer,
getAdminPlayer,
@@ -57,18 +52,21 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { playerBalanceCells } from "@/lib/admin-player-display";
import { formatAdminMinorDecimal, formatAdminMinorUnits, parseAdminMajorToMinor } from "@/lib/money";
import { formatAdminMinorUnits } from "@/lib/money";
import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { AGENT_PERCENT_HARD_MAX } from "@/lib/agent-profile-caps";
import {
NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
validateNativePlayerPassword,
validateNativePlayerUsername,
} from "@/lib/admin-input-validation";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_SETTLEMENT_AGENT_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
import { resolvePlayerSettlementHref } from "@/modules/settlement/settlement-player-deeplink";
import { settlementCenterListHref } from "@/modules/settlement/settlement-center-nav";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -148,8 +146,6 @@ type AgentsPlayersPanelProps = {
allowCreatePlayer?: boolean;
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
embedded?: boolean;
/** 外部触发创建直属玩家的计数器 */
createRequestKey?: number;
};
export function AgentsPlayersPanel({
@@ -157,10 +153,11 @@ export function AgentsPlayersPanel({
agentNodeId,
allowCreatePlayer,
embedded = false,
createRequestKey = 0,
}: AgentsPlayersPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "players", "common"]);
const router = useRouter();
const formatDt = useAdminDateTimeFormatter();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const createPlayerLabel = embedded
? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" })
: t("playersPanel.create", { defaultValue: "创建玩家" });
@@ -173,21 +170,6 @@ export function AgentsPlayersPanel({
const isSiteAdmin = isSiteAdminOperator(profile);
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profileAllowsCreate =
allowCreatePlayer === undefined
? boundAgent?.can_create_player !== false
: allowCreatePlayer === true;
const canCreatePlayer =
isSuperAdmin ||
(profileAllowsCreate &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
const canManagePlayerRows = canCreatePlayer;
const canOperateBills =
isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
const canFinanceAdjustments = canOperateBills && boundAgent === null;
const effectiveAgentId = useMemo(() => {
if (agentNodeId !== null) {
return agentNodeId;
@@ -195,6 +177,26 @@ export function AgentsPlayersPanel({
return boundAgent?.id ?? null;
}, [agentNodeId, boundAgent?.id]);
const profileAllowsCreate =
allowCreatePlayer === undefined
? boundAgent?.can_create_player !== false
: allowCreatePlayer === true;
const canAttachAgentNodeOnCreate =
effectiveAgentId !== null &&
(isSuperAdmin ||
isSiteAdmin ||
(boundAgent !== null && effectiveAgentId === boundAgent.id));
const canCreatePlayer =
canAttachAgentNodeOnCreate &&
profileAllowsCreate &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
const canManagePlayerRows = canCreatePlayer;
const [settlementNavBusy, setSettlementNavBusy] = useState(false);
const [keyword, setKeyword] = useState("");
const [appliedKeyword, setAppliedKeyword] = useState("");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [items, setItems] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]);
@@ -231,18 +233,6 @@ export function AgentsPlayersPanel({
const [editRebateRate, setEditRebateRate] = useState("");
const [editRiskTags, setEditRiskTags] = useState("");
const [editDetailLoading, setEditDetailLoading] = useState(false);
const [billingDialogOpen, setBillingDialogOpen] = useState(false);
const [billingPlayer, setBillingPlayer] = useState<AdminPlayerRow | null>(null);
const [billingBills, setBillingBills] = useState<SettlementBillRow[]>([]);
const [billingLoading, setBillingLoading] = useState(false);
const [billingBusy, setBillingBusy] = useState(false);
const [selectedBillId, setSelectedBillId] = useState<number | null>(null);
const [payAmount, setPayAmount] = useState("");
const [payMethod, setPayMethod] = useState("");
const [payProof, setPayProof] = useState("");
const [badDebtReason, setBadDebtReason] = useState("");
const lastCreateRequestKeyRef = useRef(createRequestKey);
const load = useCallback(async () => {
if (siteCode.trim() === "") {
setItems([]);
@@ -259,6 +249,7 @@ export function AgentsPlayersPanel({
page,
per_page: perPage,
site_code: siteCode.trim(),
...(appliedKeyword.trim() !== "" ? { keyword: appliedKeyword.trim() } : {}),
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
});
setItems(data.items);
@@ -272,7 +263,7 @@ export function AgentsPlayersPanel({
} finally {
setLoading(false);
}
}, [effectiveAgentId, page, perPage, siteCode]);
}, [appliedKeyword, effectiveAgentId, page, perPage, siteCode, t]);
useAsyncEffect(() => {
void load();
@@ -355,9 +346,7 @@ export function AgentsPlayersPanel({
username: username.trim(),
password: password,
nickname: nickname.trim() || null,
...(effectiveAgentId != null && (isSuperAdmin || isSiteAdmin || boundAgent !== null)
? { agent_node_id: effectiveAgentId }
: {}),
...(canAttachAgentNodeOnCreate ? { agent_node_id: effectiveAgentId! } : {}),
credit_limit: parsedCreditLimit,
...(parsedRebateRate !== null
? { rebate_rate: parsedRebateRate }
@@ -407,16 +396,6 @@ export function AgentsPlayersPanel({
}
}
useEffect(() => {
if (createRequestKey === 0 || createRequestKey === lastCreateRequestKeyRef.current) {
return;
}
lastCreateRequestKeyRef.current = createRequestKey;
if (canCreatePlayer) {
openCreateDialog();
}
}, [canCreatePlayer, createRequestKey]);
const applyEditForm = (row: AdminPlayerRow): void => {
const form = fillEditFormFromPlayer(row);
setEditUsername(form.username);
@@ -601,47 +580,19 @@ export function AgentsPlayersPanel({
}
}
const selectedBill = useMemo(
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null,
[billingBills, selectedBillId],
);
const canOperateSelectedBill =
selectedBill !== null &&
canOperateBills &&
settlementBillOperableByBoundAgent(selectedBill, boundAgent);
const billingCurrency = billingPlayer?.default_currency ?? "NPR";
function resetBillingForm(): void {
setPayAmount("");
setPayMethod("");
setPayProof("");
setBadDebtReason("");
}
async function openBillingDialog(row: AdminPlayerRow): Promise<void> {
setBillingDialogOpen(true);
setBillingPlayer(row);
setBillingBills([]);
setSelectedBillId(null);
resetBillingForm();
setBillingLoading(true);
async function goToPlayerSettlement(row: AdminPlayerRow): Promise<void> {
setSettlementNavBusy(true);
try {
const data = await getSettlementBills({
bill_type: "player",
keyword: row.site_player_id,
per_page: 20,
});
const items = (data.items ?? []).filter(
(bill) =>
bill.bill_type === "player" &&
bill.owner_id === row.id &&
(bill.status === "pending_confirm" || Number(bill.unpaid_amount ?? 0) > 0),
);
setBillingBills(items);
const first = items[0] ?? null;
setSelectedBillId(first?.id ?? null);
setPayAmount(
first ? formatAdminMinorDecimal(first.unpaid_amount ?? 0, row.default_currency ?? "NPR") : "",
const href = await resolvePlayerSettlementHref(row, adminSiteId);
if (href) {
router.push(href);
return;
}
router.push(settlementCenterListHref(adminSiteId));
toast.info(
t("playersPanel.noPendingBillsGoCenter", {
defaultValue: "该玩家暂无待处理账单,已打开结算中心。",
}),
);
} catch (e) {
toast.error(
@@ -650,172 +601,50 @@ export function AgentsPlayersPanel({
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
);
} finally {
setBillingLoading(false);
setSettlementNavBusy(false);
}
}
async function handleConfirmBill(): Promise<void> {
if (selectedBill === null) return;
setBillingBusy(true);
try {
await postSettlementBillConfirm(selectedBill.id);
toast.success(
t("playersPanel.billConfirmed", { defaultValue: "账单已确认,请继续登记收付或核销" }),
);
if (billingPlayer) {
await openBillingDialog(billingPlayer);
}
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("playersPanel.billConfirmFailed", { defaultValue: "确认账单失败" }),
);
} finally {
setBillingBusy(false);
}
}
async function handlePayBill(): Promise<void> {
if (selectedBill === null) return;
const fallbackAmount = formatAdminMinorDecimal(
selectedBill.unpaid_amount ?? 0,
billingCurrency,
);
const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency);
if (amount === null || amount <= 0 || amount > Number(selectedBill.unpaid_amount ?? 0)) {
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入有效的收付金额" }));
return;
}
setBillingBusy(true);
try {
await postSettlementBillPayment(selectedBill.id, {
amount,
method: payMethod.trim() || undefined,
proof: payProof.trim() || undefined,
});
toast.success(t("playersPanel.billPaid", { defaultValue: "已登记收付" }));
await load();
if (billingPlayer) {
await openBillingDialog(billingPlayer);
}
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("playersPanel.billPayFailed", { defaultValue: "登记收付失败" }),
);
} finally {
setBillingBusy(false);
}
}
async function handleWriteOffBill(): Promise<void> {
if (selectedBill === null) return;
const reason = badDebtReason.trim();
if (!reason) {
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
return;
}
setBillingBusy(true);
try {
await postSettlementBillBadDebtWriteOff(selectedBill.id, {
reason,
});
toast.success(t("playersPanel.billWrittenOff", { defaultValue: "已核销坏账" }));
await load();
if (billingPlayer) {
await openBillingDialog(billingPlayer);
}
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("playersPanel.billWriteOffFailed", { defaultValue: "核销坏账失败" }),
);
} finally {
setBillingBusy(false);
}
}
function requestConfirmBillAction(): void {
if (selectedBill === null) return;
requestConfirm({
title: t("playersPanel.confirmBillTitle", { defaultValue: "确认账单?" }),
description: t("playersPanel.confirmBillDescription", {
defaultValue: "确认后账单会进入待收付状态,请确认金额与玩家无误。",
}),
confirmLabel: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
confirmVariant: "default",
onConfirm: handleConfirmBill,
});
}
function requestPayBillAction(): void {
if (selectedBill === null) return;
const fallbackAmount = formatAdminMinorDecimal(
selectedBill.unpaid_amount ?? 0,
billingCurrency,
);
const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency);
if (amount === null || amount <= 0) {
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的有效金额" }));
return;
}
if (amount > Number(selectedBill.unpaid_amount ?? 0)) {
toast.error(t("playersPanel.paymentAmountTooLarge", { defaultValue: "收付金额不能超过未结金额" }));
return;
}
requestConfirm({
title: t("playersPanel.payBillConfirmTitle", { defaultValue: "确认登记收付?" }),
description: t("playersPanel.payBillConfirmDescription", {
defaultValue: "这会写入收付记录并更新玩家账单金额,请确认金额与凭证无误。",
}),
confirmLabel: t("agents:settlementBills.paid", { defaultValue: "登记收付" }),
confirmVariant: "default",
onConfirm: handlePayBill,
});
}
function requestWriteOffBillAction(): void {
if (selectedBill === null) return;
if (!badDebtReason.trim()) {
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
return;
}
requestConfirm({
title: t("playersPanel.writeOffBillConfirmTitle", { defaultValue: "确认核销坏账?" }),
description: t("playersPanel.writeOffBillConfirmDescription", {
defaultValue: "核销会把该玩家账单未结金额归档为坏账记录,请确认已无法收回。",
}),
confirmLabel: t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" }),
confirmVariant: "destructive",
onConfirm: handleWriteOffBill,
});
}
return (
<div className="space-y-4">
<ConfirmDialog />
<div className="flex flex-wrap items-center justify-between gap-3">
{!embedded ? (
<p className="text-xs text-muted-foreground">
{t("playersPanel.creditListHint", {
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
})}
</p>
) : (
<div />
)}
{canCreatePlayer && !embedded ? (
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" />
{createPlayerLabel}
<div className="admin-list-toolbar">
<div className="admin-list-field min-w-[12rem] flex-1">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setAppliedKeyword(keyword);
setPage(1);
}
}}
placeholder={t("playersPanel.searchPh", { defaultValue: "搜索玩家账号 / 标识" })}
className="pl-9"
/>
</div>
</div>
<div className="admin-list-actions">
<Button
type="button"
size="sm"
onClick={() => {
setAppliedKeyword(keyword);
setPage(1);
}}
>
{t("common:actions.search", { defaultValue: "搜索" })}
</Button>
) : null}
{canCreatePlayer ? (
<Button type="button" size="sm" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" />
{createPlayerLabel}
</Button>
) : null}
</div>
</div>
{loading ? (
@@ -825,41 +654,51 @@ export function AgentsPlayersPanel({
{listErr ? (
<p className="mb-3 text-sm text-destructive">{listErr}</p>
) : null}
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div
className={
embedded
? "admin-table-inset overflow-hidden"
: "admin-table-shell overflow-hidden rounded-xl border border-border/70"
}
>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableRow className="hover:bg-transparent">
<TableHead className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
<TableHead className="whitespace-nowrap">
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("players:riskTags", { defaultValue: "风控标签" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("players:fundingMode", { defaultValue: "资金模式" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
{!embedded ? (
<>
<TableHead className="whitespace-nowrap">
{t("players:riskTags", { defaultValue: "风控标签" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("players:fundingMode", { defaultValue: "资金模式" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
</>
) : null}
<TableHead className="text-right whitespace-nowrap">
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("players:rebateRate", { defaultValue: "回水" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
{!embedded ? (
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
) : null}
<TableHead className="sticky right-0 z-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<AdminTableNoResourceRow colSpan={embedded ? 9 : 10} cellClassName="py-12 text-center" />
<AdminTableNoResourceRow colSpan={embedded ? 7 : 10} cellClassName="py-12 text-center" />
) : (
items.map((row) => {
const balances = playerBalanceCells(row, formatAdminMinorUnits);
@@ -876,26 +715,30 @@ export function AgentsPlayersPanel({
<span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
</TableCell>
<TableCell className="max-w-[14rem]">
{riskTags.length > 0 ? (
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
{riskTags.map((tag) => (
<span
key={`${row.id}-${tag}`}
className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium leading-4 text-amber-900"
>
{tag}
</span>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<PlayerFundingModeBadge row={row} />
</TableCell>
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
{!embedded ? (
<>
<TableCell className="max-w-[14rem]">
{riskTags.length > 0 ? (
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
{riskTags.map((tag) => (
<span
key={`${row.id}-${tag}`}
className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium leading-4 text-amber-900"
>
{tag}
</span>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<PlayerFundingModeBadge row={row} />
</TableCell>
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
</>
) : null}
<TableCell className="text-right text-xs tabular-nums">
<span>{balances.balance}</span>
<span className="text-muted-foreground"> / </span>
@@ -911,22 +754,22 @@ export function AgentsPlayersPanel({
>
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell>
{!embedded ? (
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell>
) : null}
<TableCell>
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
{playerStatusLabel(row.status, t)}
</AdminStatusBadge>
</TableCell>
) : null}
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
className="sticky right-0 z-10 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
busy={confirmBusy}
busy={confirmBusy || settlementNavBusy}
actions={[
{
key: "detail",
@@ -942,7 +785,7 @@ export function AgentsPlayersPanel({
defaultValue: "处理账单",
}),
icon: ReceiptText,
onClick: () => void openBillingDialog(row),
onClick: () => void goToPlayerSettlement(row),
},
]
: []),
@@ -1040,7 +883,10 @@ export function AgentsPlayersPanel({
className="bg-background/50 transition-colors focus:bg-background"
/>
<p className="text-[11px] text-muted-foreground/80">
{t("playersPanel.passwordHint", { defaultValue: "至少 8 位" })}
{t("playersPanel.passwordHint", {
defaultValue: "至少 {{min}} 位",
min: NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
})}
</p>
</div>
</div>
@@ -1101,157 +947,6 @@ export function AgentsPlayersPanel({
</DialogContent>
</Dialog>
<Dialog
open={billingDialogOpen}
onOpenChange={(open) => {
setBillingDialogOpen(open);
if (!open) {
setBillingPlayer(null);
setBillingBills([]);
setSelectedBillId(null);
resetBillingForm();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("playersPanel.manageSettlement", { defaultValue: "处理账单" })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{billingLoading ? (
<p className="text-sm text-muted-foreground">
{t("playersPanel.billingLoading", { defaultValue: "正在加载账单…" })}
</p>
) : billingBills.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("playersPanel.noPendingBills", { defaultValue: "当前没有可处理的未结账单。" })}
</p>
) : (
<>
<div className="space-y-2">
<Label>{t("playersPanel.selectBill", { defaultValue: "选择账单" })}</Label>
<Select
value={selectedBillId ? String(selectedBillId) : ""}
onValueChange={(value) => {
const next = billingBills.find((bill) => bill.id === Number(value)) ?? null;
setSelectedBillId(next?.id ?? null);
setPayAmount(
next
? formatAdminMinorDecimal(next.unpaid_amount ?? 0, billingCurrency)
: "",
);
setPayMethod("");
setPayProof("");
setBadDebtReason("");
}}
>
<SelectTrigger>
<SelectValue placeholder={t("playersPanel.selectBill", { defaultValue: "选择账单" })} />
</SelectTrigger>
<SelectContent>
{billingBills.map((bill) => (
<SelectItem key={bill.id} value={String(bill.id)}>
{`#${bill.id} · ${bill.status} · ${bill.player_site_player_id ?? bill.owner_id} · ${formatAdminMinorUnits(bill.unpaid_amount ?? 0, billingCurrency)}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedBill ? (
<div className="space-y-4 rounded-xl border border-border/70 p-4">
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<span className="text-muted-foreground">
{t("playersPanel.billStatus", { defaultValue: "状态" })}:
</span>{" "}
{selectedBill.status}
</div>
<div>
<span className="text-muted-foreground">
{t("playersPanel.billUnpaid", { defaultValue: "未结" })}:
</span>{" "}
{formatAdminMinorUnits(selectedBill.unpaid_amount ?? 0, billingCurrency)}
</div>
</div>
{canOperateSelectedBill && selectedBill.status === "pending_confirm" ? (
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestConfirmBillAction}>
{t("agents:settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
) : null}
{canOperateSelectedBill && selectedBill.status !== "pending_confirm" && Number(selectedBill.unpaid_amount ?? 0) > 0 ? (
<div className="space-y-3">
<div className="space-y-1">
<Label>{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
<Input
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
inputMode="decimal"
placeholder={formatAdminMinorDecimal(
selectedBill.unpaid_amount ?? 0,
billingCurrency,
)}
/>
</div>
<div className="space-y-1">
<Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
<Input
value={payMethod}
onChange={(e) => setPayMethod(e.target.value)}
placeholder={t("agents:settlementBills.paymentMethodPlaceholder", {
defaultValue: "例如:现金 / 银行转账",
})}
/>
</div>
<div className="space-y-1">
<Label>{t("agents:settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
<Input
value={payProof}
onChange={(e) => setPayProof(e.target.value)}
placeholder={t("agents:settlementBills.paymentProofPlaceholder", {
defaultValue: "可填写流水号、截图说明或备注",
})}
/>
</div>
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestPayBillAction}>
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
</Button>
{canFinanceAdjustments &&
["confirmed", "partial_paid", "overdue"].includes(selectedBill.status) &&
Number(selectedBill.unpaid_amount ?? 0) > 0 &&
!["adjustment", "reversal", "bad_debt"].includes(selectedBill.bill_type) ? (
<>
<div className="space-y-1 pt-2">
<Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<Input
value={badDebtReason}
onChange={(e) => setBadDebtReason(e.target.value)}
placeholder={t("agents:settlementBills.badDebtReasonPlaceholder", {
defaultValue: "例如:客户失联、确认坏账",
})}
/>
</div>
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestWriteOffBillAction}>
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button>
</>
) : null}
</div>
) : null}
</div>
) : null}
</>
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>

View File

@@ -2,12 +2,10 @@
import { Check, ChevronDown, Search } from "lucide-react";
import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import {
AdminSubnavBar,
} from "@/components/admin/admin-subnav";
import { AdminSubnav, AdminSubnavBar, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { buttonVariants } from "@/components/ui/button";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
@@ -22,6 +20,9 @@ import { cn } from "@/lib/utils";
export function AgentsSubnav(): React.ReactElement {
const { t } = useTranslation("agents");
const pathname = usePathname();
const searchParams = useSearchParams();
const isAgentsHome = pathname === "/admin/agents";
const agentsView = searchParams.get("view") === "list" ? "list" : "tree";
const profile = useAdminProfile();
const { sites: siteOptions } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
@@ -74,7 +75,7 @@ export function AgentsSubnav(): React.ReactElement {
}, [deferredKeyword, siteOptions]);
const siteReadOnlyLabel =
pathname !== "/admin/agents/list" &&
isAgentsHome &&
!canSwitchSite &&
selectedSite != null ? (
<div className="flex h-10 min-w-[200px] items-center justify-end gap-2 rounded-md border border-border/70 bg-background px-3 text-sm">
@@ -84,7 +85,7 @@ export function AgentsSubnav(): React.ReactElement {
) : null;
const siteSelector =
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
isAgentsHome && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
<PopoverTrigger
className={cn(
@@ -152,11 +153,22 @@ export function AgentsSubnav(): React.ReactElement {
return (
<AdminSubnavBar trailing={siteSelector ?? siteReadOnlyLabel}>
<div className="pb-1">
<p className="text-sm font-medium text-foreground">
{t("title", { defaultValue: "代理管理" })}
</p>
</div>
{isAgentsHome ? (
<AdminSubnav aria-label={t("viewModes.label")}>
<AdminSubnavLink href="/admin/agents" active={agentsView === "tree"}>
{t("viewModes.tree")}
</AdminSubnavLink>
<AdminSubnavLink href="/admin/agents?view=list" active={agentsView === "list"}>
{t("viewModes.list")}
</AdminSubnavLink>
</AdminSubnav>
) : (
<div className="pb-1">
<p className="text-sm font-medium text-foreground">
{t("title", { defaultValue: "代理管理" })}
</p>
</div>
)}
</AdminSubnavBar>
);
}

View File

@@ -15,7 +15,14 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -28,6 +35,28 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminAuditLogListData } from "@/types/api/admin-audit";
const MODULE_FILTER_OPTIONS = [
"agent",
"system",
"settings",
"integration",
"player_manage",
"player_service",
"draw",
"settlement",
"wallet",
"reconcile",
"reconcile_jobs",
"report_jobs",
"odds",
"play_config",
"risk_cap",
"jackpot",
"dashboard",
] as const;
const OPERATOR_TYPE_OPTIONS = ["admin", "player", "system"] as const;
export function AuditLogsConsole(): React.ReactElement {
const { t } = useTranslation(["audit", "common"]);
const tRef = useTranslationRef(["audit", "common"]);
@@ -39,15 +68,13 @@ export function AuditLogsConsole(): React.ReactElement {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [operatorId, setOperatorId] = useState("");
const [moduleCode, setModuleCode] = useState("");
const [actionCode, setActionCode] = useState("");
const [operatorType, setOperatorType] = useState("");
const [moduleCode, setModuleCode] = useState("all");
const [operatorType, setOperatorType] = useState("all");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [appliedOperatorId, setAppliedOperatorId] = useState("");
const [appliedModule, setAppliedModule] = useState("");
const [appliedAction, setAppliedAction] = useState("");
const [appliedOpType, setAppliedOpType] = useState("");
const [appliedModule, setAppliedModule] = useState("all");
const [appliedOpType, setAppliedOpType] = useState("all");
const [appliedStartDate, setAppliedStartDate] = useState("");
const [appliedEndDate, setAppliedEndDate] = useState("");
@@ -66,9 +93,8 @@ export function AuditLogsConsole(): React.ReactElement {
page,
per_page: perPage,
operator_id: operatorIdParam,
module_code: appliedModule.trim() || undefined,
action_code: appliedAction.trim() || undefined,
operator_type: appliedOpType.trim() || undefined,
module_code: appliedModule !== "all" ? appliedModule : undefined,
operator_type: appliedOpType !== "all" ? appliedOpType : undefined,
start_date: appliedStartDate || undefined,
end_date: appliedEndDate || undefined,
});
@@ -79,189 +105,203 @@ export function AuditLogsConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
}, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
useAsyncEffect(() => {
void load();
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
}, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
const meta = data?.meta;
const operatorTypeLabel = (type: string): string => {
const key = `operatorTypes.${type}` as const;
const translated = t(key);
return translated === key ? type : translated;
};
const showTargetColumn = (row: AdminAuditLogListData["items"][number]): boolean => {
const summary = row.summary_label || `${row.module_label} · ${row.action_label}`;
const target = row.target_label;
if (!target || target === "—") {
return false;
}
return !summary.includes(target);
};
return (
<Card className="admin-list-card w-full max-w-none">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("title")}</CardTitle>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="aud-operator-id" className="sm:shrink-0">
{t("operator")}
</Label>
<Input
id="aud-operator-id"
value={operatorId}
onChange={(e) => setOperatorId(e.target.value)}
placeholder={t("operatorIdPlaceholder")}
className="w-full sm:w-36"
inputMode="numeric"
/>
</div>
<div className="admin-list-field">
<Label htmlFor="aud-mod" className="sm:shrink-0">
{t("moduleCode")}
</Label>
<Input
id="aud-mod"
value={moduleCode}
onChange={(e) => setModuleCode(e.target.value)}
placeholder={t("exactMatch")}
className="w-full sm:w-40"
/>
</div>
<div className="admin-list-field">
<Label htmlFor="aud-act" className="sm:shrink-0">
{t("actionCode")}
</Label>
<Input
id="aud-act"
value={actionCode}
onChange={(e) => setActionCode(e.target.value)}
placeholder={t("exactMatch")}
className="w-full sm:w-40"
/>
</div>
<div className="admin-list-field">
<Label htmlFor="aud-op" className="sm:shrink-0">
{t("operatorType")}
</Label>
<Input
id="aud-op"
value={operatorType}
onChange={(e) => setOperatorType(e.target.value)}
placeholder={t("operatorTypePlaceholder")}
className="w-full sm:w-40"
/>
</div>
<div className="admin-list-field">
<Label htmlFor="aud-date-range" className="sm:shrink-0">
{t("time")}
</Label>
<div className="min-w-0 w-full sm:w-56">
<AdminDateRangeField
id="aud-date-range"
from={startDate}
to={endDate}
onRangeChange={(range) => {
setStartDate(range.from);
setEndDate(range.to);
}}
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("title")}</CardTitle>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="aud-module" className="sm:shrink-0">
{t("filterModule")}
</Label>
<Select value={moduleCode} onValueChange={(value) => setModuleCode(value ?? "all")}>
<SelectTrigger id="aud-module" className="w-full sm:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filterModuleAll")}</SelectItem>
{MODULE_FILTER_OPTIONS.map((code) => (
<SelectItem key={code} value={code}>
{t(`moduleOptions.${code}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="admin-list-field">
<Label htmlFor="aud-op-type" className="sm:shrink-0">
{t("filterOperatorType")}
</Label>
<Select value={operatorType} onValueChange={(value) => setOperatorType(value ?? "all")}>
<SelectTrigger id="aud-op-type" className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filterOperatorTypeAll")}</SelectItem>
{OPERATOR_TYPE_OPTIONS.map((code) => (
<SelectItem key={code} value={code}>
{t(`operatorTypes.${code}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="admin-list-field">
<Label htmlFor="aud-operator-id" className="sm:shrink-0">
{t("operator")}
</Label>
<Input
id="aud-operator-id"
value={operatorId}
onChange={(e) => setOperatorId(e.target.value)}
placeholder={t("operatorIdPlaceholder")}
className="w-full sm:w-36"
inputMode="numeric"
/>
</div>
</div>
<div className="admin-list-actions">
<AdminTableExportButton
tableId="audit-logs-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button
type="button"
onClick={() => {
setAppliedOperatorId(operatorId);
setAppliedModule(moduleCode);
setAppliedAction(actionCode);
setAppliedOpType(operatorType);
setAppliedStartDate(startDate);
setAppliedEndDate(endDate);
setPage(1);
}}
>
{t("actions.search", { ns: "common" })}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setOperatorId("");
setModuleCode("");
setActionCode("");
setOperatorType("");
setStartDate("");
setEndDate("");
setAppliedOperatorId("");
setAppliedModule("");
setAppliedAction("");
setAppliedOpType("");
setAppliedStartDate("");
setAppliedEndDate("");
setPage(1);
}}
>
{t("actions.reset", { ns: "common" })}
</Button>
</div>
</div>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
{(loading && !data) || data ? (
<>
<div className="admin-table-shell">
<Table id="audit-logs-table">
<TableHeader>
<TableRow>
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("operator")}</TableHead>
<TableHead>{t("module")}</TableHead>
<TableHead>{t("action")}</TableHead>
<TableHead>{t("target")}</TableHead>
<TableHead>{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={6} />
) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={6} />
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell className="text-xs">
<div className="font-medium text-foreground">{row.operator_label}</div>
{row.operator_subtitle ? (
<div className="text-muted-foreground">{row.operator_subtitle}</div>
) : null}
</TableCell>
<TableCell className="text-sm">{row.module_label}</TableCell>
<TableCell className="text-sm">{row.action_label}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.target_label}</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="admin-list-field">
<Label htmlFor="aud-date-range" className="sm:shrink-0">
{t("time")}
</Label>
<div className="min-w-0 w-full sm:w-56">
<AdminDateRangeField
id="aud-date-range"
from={startDate}
to={endDate}
onRangeChange={(range) => {
setStartDate(range.from);
setEndDate(range.to);
}}
/>
</div>
</div>
{meta ? (
<AdminListPaginationFooter
selectId="audit-logs-per-page"
total={meta.total}
page={meta.current_page}
lastPage={Math.max(1, meta.last_page)}
perPage={meta.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
<div className="admin-list-actions">
<AdminTableExportButton
tableId="audit-logs-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button
type="button"
onClick={() => {
setAppliedOperatorId(operatorId);
setAppliedModule(moduleCode);
setAppliedOpType(operatorType);
setAppliedStartDate(startDate);
setAppliedEndDate(endDate);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
>
{t("actions.search", { ns: "common" })}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setOperatorId("");
setModuleCode("all");
setOperatorType("all");
setStartDate("");
setEndDate("");
setAppliedOperatorId("");
setAppliedModule("all");
setAppliedOpType("all");
setAppliedStartDate("");
setAppliedEndDate("");
setPage(1);
}}
>
{t("actions.reset", { ns: "common" })}
</Button>
</div>
</div>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
{(loading && !data) || data ? (
<>
<div className="admin-table-shell">
<Table id="audit-logs-table">
<TableHeader>
<TableRow>
<TableHead className="w-40 whitespace-nowrap">{t("time")}</TableHead>
<TableHead className="w-36">{t("operator")}</TableHead>
<TableHead>{t("summary")}</TableHead>
<TableHead className="w-36">{t("target")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={4} />
) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={4} message={t("empty")} />
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
<TableCell className="text-sm">
<div className="font-medium text-foreground">{row.operator_label}</div>
<div className="text-xs text-muted-foreground">
{operatorTypeLabel(row.operator_type)}
</div>
</TableCell>
<TableCell className="text-sm leading-relaxed">
{row.summary_label || `${row.module_label} · ${row.action_label}`}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{showTargetColumn(row) ? row.target_label : "—"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{meta ? (
<AdminListPaginationFooter
selectId="audit-logs-per-page"
total={meta.total}
page={meta.current_page}
lastPage={Math.max(1, meta.last_page)}
perPage={meta.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : (
<AdminNoResourceState message={t("empty")} />
)}
</CardContent>
</Card>
);
}
}

View File

@@ -32,18 +32,13 @@ export function ConfigDocToolbar({
className?: string;
}) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border/60 bg-card",
className,
)}
>
<div className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:p-4">
<div className={cn("rounded-lg border border-border/70 bg-background", className)}>
<div className="flex flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">{switcher}</div>
<div className="flex shrink-0 flex-wrap items-center gap-1.5 sm:justify-end">{actions}</div>
</div>
{footer ? (
<div className="border-t border-border/50 bg-muted/20 px-3 py-2.5 sm:px-4">{footer}</div>
<div className="border-t border-border/60 px-3 py-2 text-xs text-muted-foreground sm:px-3">{footer}</div>
) : null}
</div>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { Plus, RefreshCw, Rocket, Save } from "lucide-react";
import { Plus, RefreshCw, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -75,7 +75,6 @@ export function ConfigVersionActions({
{t("versionActions.saveDraft")}
</Button>
<Button type="button" size="sm" disabled={draftActionBusy} onClick={onPublish}>
<Rocket className="size-3.5" aria-hidden />
{resolvedPublishLabel}
</Button>
</>

View File

@@ -1,11 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { Check, ChevronRight, Layers, MoreHorizontal } from "lucide-react";
import { Check, ChevronDown, MoreHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Button } from "@/components/ui/button";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import {
Dialog,
DialogContent,
@@ -20,20 +20,13 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import type { ConfigVersionSummary } from "@/types/api/admin-config";
const STATUS_ORDER = ["draft", "active", "archived"] as const;
export type ConfigVersionSwitcherProps = {
versions: ConfigVersionSummary[];
selectedId: string;
@@ -48,34 +41,12 @@ export type ConfigVersionSwitcherProps = {
rollbackBusy?: boolean;
};
function formatVersionNote(
reason: string | null | undefined,
formatDt: (iso: string | null | undefined) => string,
): string | null {
if (!reason?.trim()) {
return null;
}
const trimmed = reason.trim();
const isoMatch = trimmed.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/);
if (trimmed.startsWith("draft") && isoMatch) {
return formatDt(isoMatch[0]);
}
if (trimmed.startsWith("seed:")) {
return trimmed.slice(5);
}
if (trimmed.length > 56) {
return `${trimmed.slice(0, 56)}`;
}
return trimmed;
}
export function ConfigVersionSwitcher({
versions,
selectedId,
onSelectedIdChange,
loading = false,
sheetTitle,
sheetDescription,
className,
onDeleteVersion,
onRollbackVersion,
@@ -83,48 +54,24 @@ export function ConfigVersionSwitcher({
}: ConfigVersionSwitcherProps) {
const { t } = useTranslation(["config", "adminUsers"]);
const formatDt = useAdminDateTimeFormatter();
const [sheetOpen, setSheetOpen] = useState(false);
const [open, setOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ConfigVersionSummary | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const resolvedSheetTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" });
const resolvedSheetDescription =
sheetDescription ?? t("versionSwitcher.sheetDescription", { ns: "config" });
const resolvedTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" });
const sortedVersions = useMemo(
() => [...versions].sort((a, b) => b.id - a.id),
() => [...versions].sort((a, b) => b.version_no - a.version_no),
[versions],
);
const groupedVersions = useMemo(() => {
const groups = new Map<string, ConfigVersionSummary[]>();
for (const status of STATUS_ORDER) {
groups.set(status, []);
}
for (const v of sortedVersions) {
const list = groups.get(v.status) ?? [];
list.push(v);
groups.set(v.status, list);
}
return groups;
}, [sortedVersions]);
const selectedVersion = useMemo(
() => sortedVersions.find((v) => String(v.id) === selectedId) ?? null,
[selectedId, sortedVersions],
);
const visibleSections = useMemo(
() =>
STATUS_ORDER.map((status) => ({
status,
rows: groupedVersions.get(status) ?? [],
})).filter((section) => section.rows.length > 0),
[groupedVersions],
);
function switchTo(id: number) {
onSelectedIdChange(String(id));
setSheetOpen(false);
setOpen(false);
}
async function confirmDelete() {
@@ -143,181 +90,120 @@ export function ConfigVersionSwitcher({
}
}
const triggerLabel = loading
? t("versionSwitcher.loading", { ns: "config" })
: selectedVersion
? `v${selectedVersion.version_no}`
: t("versionSwitcher.noneSelected", { ns: "config" });
return (
<>
<div className={cn("flex min-w-0 items-center gap-3", className)}>
<div className="flex min-w-0 flex-1 items-center gap-2">
{selectedVersion ? (
<>
<span className="font-mono text-base font-semibold tabular-nums text-foreground">
v{selectedVersion.version_no}
</span>
<ConfigStatusBadge status={selectedVersion.status} className="h-5 px-1.5 text-[11px]" />
</>
) : (
<span className="text-sm text-muted-foreground">
{loading
? t("versionSwitcher.loading", { ns: "config" })
: t("versionSwitcher.noneSelected", { ns: "config" })}
</span>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={loading || sortedVersions.length === 0}
onClick={() => setSheetOpen(true)}
className="h-8 shrink-0 gap-1.5 text-muted-foreground hover:text-foreground"
className={cn(
"inline-flex h-9 min-w-[10rem] max-w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none transition-colors hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
>
<Layers className="size-3.5" aria-hidden />
{t("versionSwitcher.switch", { ns: "config" })}
</Button>
</div>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 overflow-hidden border-l bg-background p-0 sm:max-w-md"
>
<SheetHeader className="space-y-1 border-b border-border/60 px-5 py-4 text-left">
<SheetTitle className="text-[15px] font-semibold tracking-tight">
{resolvedSheetTitle}
</SheetTitle>
{resolvedSheetDescription ? (
<SheetDescription className="text-[13px] leading-relaxed">
{resolvedSheetDescription}
</SheetDescription>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono tabular-nums">{triggerLabel}</span>
{selectedVersion ? (
<ConfigStatusBadge status={selectedVersion.status} className="h-5 shrink-0 px-1.5 text-[11px]" />
) : null}
</SheetHeader>
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</PopoverTrigger>
<div className="flex-1 overflow-y-auto px-3 py-3">
<PopoverContent align="start" className="w-[min(20rem,calc(100vw-2rem))] p-0">
<div className="border-b border-border/60 px-3 py-2 text-sm font-medium">{resolvedTitle}</div>
<ScrollArea className="max-h-72">
{sortedVersions.length === 0 ? (
<AdminNoResourceState compact className="px-2 py-8" />
<AdminNoResourceState compact className="px-3 py-6" />
) : (
<div className="space-y-5">
{visibleSections.map((section) => (
<section key={section.status}>
<p className="mb-2 px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t(`versionStatus.${section.status}`, { ns: "config" })}
</p>
<ul className="space-y-1">
{section.rows.map((v) => {
const isCurrent = selectedId === String(v.id);
const note = formatVersionNote(v.reason, formatDt);
const effectiveLabel = v.effective_at
? formatDt(v.effective_at)
: null;
const meta = [effectiveLabel, note].filter(Boolean).join(" · ");
const showMenu =
(onDeleteVersion && v.status !== "active") ||
(onRollbackVersion && v.status !== "draft");
<ul className="p-1">
{sortedVersions.map((v) => {
const isCurrent = selectedId === String(v.id);
const secondary =
v.status === "active" && v.effective_at ? formatDt(v.effective_at) : null;
const showMenu =
(onDeleteVersion && v.status !== "active") ||
(onRollbackVersion && v.status !== "draft");
return (
<li key={v.id}>
<div
className={cn(
"group flex items-stretch gap-0.5 rounded-lg border transition-colors",
isCurrent
? "border-primary/40 bg-primary/[0.04]"
: "border-transparent hover:border-border/60 hover:bg-muted/30",
)}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left"
onClick={() => switchTo(v.id)}
return (
<li key={v.id} className="flex items-center gap-0.5">
<button
type="button"
className={cn(
"flex min-w-0 flex-1 items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors",
isCurrent ? "bg-muted" : "hover:bg-muted/60",
)}
onClick={() => switchTo(v.id)}
>
<span className="font-mono tabular-nums">v{v.version_no}</span>
<ConfigStatusBadge status={v.status} className="h-5 px-1.5 text-[11px]" />
{secondary ? (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{secondary}
</span>
) : (
<span className="flex-1" />
)}
{isCurrent ? <Check className="size-4 shrink-0 text-foreground" aria-hidden /> : null}
</button>
{showMenu ? (
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground"
aria-label={t("versionSwitcher.moreActions", {
ns: "config",
version: v.version_no,
})}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-32">
{onRollbackVersion && v.status !== "draft" ? (
<DropdownMenuItem
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setOpen(false);
}}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-[15px] font-semibold tabular-nums text-foreground">
v{v.version_no}
</span>
<ConfigStatusBadge
status={v.status}
className="h-5 px-1.5 text-[11px]"
/>
</div>
{meta ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{meta}
</p>
) : null}
</div>
{isCurrent ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" strokeWidth={2.5} aria-hidden />
<span className="sr-only">
{t("versionSwitcher.current", { ns: "config" })}
</span>
</span>
) : (
<ChevronRight
className="size-4 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground"
aria-hidden
/>
)}
</button>
{showMenu ? (
<div className="flex shrink-0 items-center pr-1">
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 data-popup-open:opacity-100"
aria-label={t("versionSwitcher.moreActions", {
ns: "config",
version: v.version_no,
})}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-36">
{onRollbackVersion && v.status !== "draft" ? (
<DropdownMenuItem
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setSheetOpen(false);
}}
>
{t("versionSwitcher.rollback", { ns: "config" })}
</DropdownMenuItem>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<DropdownMenuItem
variant="destructive"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
{t("versionSwitcher.delete", { ns: "config" })}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
</div>
</li>
);
})}
</ul>
</section>
))}
</div>
{t("versionSwitcher.rollback", { ns: "config" })}
</DropdownMenuItem>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<DropdownMenuItem
variant="destructive"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
{t("versionSwitcher.delete", { ns: "config" })}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</li>
);
})}
</ul>
)}
</div>
</SheetContent>
</Sheet>
</ScrollArea>
</PopoverContent>
</Popover>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<Dialog open={deleteTarget !== null} onOpenChange={(openState) => !openState && setDeleteTarget(null)}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("versionSwitcher.deleteConfirmDescription", {
ns: "config",
id: deleteTarget?.id,
version: deleteTarget?.version_no,
})}
</DialogDescription>
@@ -339,4 +225,4 @@ export function ConfigVersionSwitcher({
</Dialog>
</>
);
}
}

View File

@@ -18,10 +18,7 @@ import {
import { Button } from "@/components/ui/button";
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import {
Dialog,
DialogContent,
@@ -37,7 +34,7 @@ import { ratioToPercentUi } from "@/lib/admin-rate-percent";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
@@ -56,9 +53,7 @@ import type {
} from "@/types/api/admin-config";
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
import { oddsDraftIsDirty } from "@/modules/config/doc/odds-config-dirty";
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
import { OddsConfigSummaryPanel } from "@/modules/config/doc/odds-config-summary-panel";
import {
Table,
TableBody,
@@ -120,7 +115,6 @@ export function OddsConfigDocScreen({
const tRef = useTranslationRef(["config", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [internalSelectedId, setInternalSelectedId] = useState("");
@@ -465,8 +459,6 @@ export function OddsConfigDocScreen({
}
}
const activeHead = resolvedList.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
@@ -509,18 +501,10 @@ export function OddsConfigDocScreen({
{ id: "d2", label: "2D" },
];
const activeCatLabel = catTabs.find((tab) => tab.id === catTab)?.label ?? catTab;
const activePlayLabel = resolvedPlayCode
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
: "—";
const isDirty = useMemo(() => {
if (!resolvedDetail || !isDraft) {
return false;
}
return oddsDraftIsDirty(resolvedDraftRows, resolvedDetail.items);
}, [isDraft, resolvedDetail, resolvedDraftRows]);
const filtersInner = (
<>
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
@@ -602,8 +586,7 @@ export function OddsConfigDocScreen({
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={resolvedLoadingList}
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={embedded ? undefined : t("odds.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
@@ -623,58 +606,9 @@ export function OddsConfigDocScreen({
onPublish={() => void requestPublishConfirm()}
/>
}
footer={
!resolvedDetail ? null : (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
<span>
{t("odds.activeVersionPrefix", { ns: "config" })}
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
</span>
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("odds.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : activeHead ? (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
) : null}
</ConfigVersionToolbarMeta>
)
}
/>
);
const rebateField = (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="grid max-w-xs gap-1.5">
<Label htmlFor="odds-rebate-rate">{t("odds.rebateRate", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
id="odds-rebate-rate"
type="text"
inputMode="decimal"
className="h-9 text-base font-semibold"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue className="h-9 w-full max-w-xs justify-center text-base font-semibold">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
</div>
);
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
@@ -726,6 +660,26 @@ export function OddsConfigDocScreen({
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell className="font-medium">{t("odds.rebateRate", { ns: "config" })}</TableCell>
<TableCell className="text-right">
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="ml-auto h-9 w-full max-w-[9rem] text-base font-semibold"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue className="ml-auto h-9 w-full max-w-[9rem] justify-center text-base font-semibold">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
);
@@ -798,19 +752,7 @@ export function OddsConfigDocScreen({
/>
) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
{mergedLayout ? (
<div className="space-y-4">
{mergedOddsTable}
{rebateField}
</div>
) : (
<>
{classicOddsGrid}
{!embedded ? (
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</>
)}
{mergedLayout ? mergedOddsTable : classicOddsGrid}
</div>
) : null}
</>
@@ -889,51 +831,31 @@ export function OddsConfigDocScreen({
if (embedded && mergedLayout) {
return (
<div className="space-y-4">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">{toolbarBlock}</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_min(100%,260px)] xl:items-start">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<div className="grid gap-0 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<OddsConfigPlayNav
catTab={catTab}
onCatTabChange={setCatTab}
onPlayCodeChange={setPlayCode}
types={sortedTypes}
resolvedPlayCode={resolvedPlayCode}
/>
</aside>
<div className="min-w-0">
<div className="border-b border-border/50 px-4 py-3 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
<p className="text-sm text-muted-foreground">
{t("odds.currentSelection", {
ns: "config",
category: activeCatLabel,
play: activePlayLabel,
})}
</p>
</div>
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? (
<OddsConfigDraftBar
isDirty={isDirty}
saving={saving}
loadingDetail={resolvedLoadingDetail}
onSave={() => void handleSave()}
onPublish={() => void requestPublishConfirm()}
/>
) : null}
</div>
{toolbarBlock}
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<OddsConfigPlayNav
catTab={catTab}
onCatTabChange={setCatTab}
onPlayCodeChange={setPlayCode}
types={sortedTypes}
resolvedPlayCode={resolvedPlayCode}
/>
</aside>
<div className="min-w-0">
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
</div>
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? (
<OddsConfigDraftBar
saving={saving}
loadingDetail={resolvedLoadingDetail}
onSave={() => void handleSave()}
onPublish={() => void requestPublishConfirm()}
/>
) : null}
</div>
<OddsConfigSummaryPanel
compact
detail={resolvedDetail}
activeHead={activeHead ?? null}
/>
</div>
{dialogs}
</div>

View File

@@ -1,13 +1,12 @@
"use client";
import { Rocket, Save } from "lucide-react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type OddsConfigDraftBarProps = {
isDirty: boolean;
saving: boolean;
loadingDetail?: boolean;
onSave: () => void;
@@ -16,7 +15,6 @@ type OddsConfigDraftBarProps = {
};
export function OddsConfigDraftBar({
isDirty,
saving,
loadingDetail = false,
onSave,
@@ -29,23 +27,17 @@ export function OddsConfigDraftBar({
return (
<div
className={cn(
"sticky bottom-0 z-10 flex flex-col gap-3 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-5",
"sticky bottom-0 z-10 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 bg-card/95 px-4 py-2.5 backdrop-blur sm:px-5",
className,
)}
>
<p className="text-sm text-muted-foreground">
{isDirty ? t("odds.draftBar.unsaved") : t("odds.draftBar.saved")}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
<Save className="size-3.5" aria-hidden />
{t("versionActions.saveDraft")}
</Button>
<Button type="button" size="sm" disabled={busy} onClick={onPublish}>
<Rocket className="size-3.5" aria-hidden />
{t("versionActions.publishCurrent")}
</Button>
</div>
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
<Save className="size-3.5" aria-hidden />
{t("versionActions.saveDraft")}
</Button>
<Button type="button" size="sm" disabled={busy} onClick={onPublish}>
{t("versionActions.publishCurrent")}
</Button>
</div>
);
}

View File

@@ -136,8 +136,8 @@ export function OddsConfigPlayNav({
className={cn(
"w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors",
active
? "bg-primary font-medium text-primary-foreground shadow-sm"
: "text-foreground hover:bg-muted/80",
? "bg-muted font-medium text-foreground"
: "text-foreground hover:bg-muted/60",
)}
onClick={() => onPlayCodeChange(type.play_code)}
aria-current={active ? "true" : undefined}

View File

@@ -1,150 +0,0 @@
"use client";
import { FileText, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates";
import { prizeScopeLabel, type PrizeScopeCode } from "@/modules/config/doc/prize-scopes";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { cn } from "@/lib/utils";
import type { ConfigVersionSummary, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
type OddsConfigSummaryPanelProps = {
catTabLabel?: string;
playLabel?: string;
detail: OddsVersionDetail | null;
scopeRows?: Partial<Record<PrizeScopeCode, OddsItemRow>>;
playRebatePercent?: string;
activeHead?: ConfigVersionSummary | null;
/** 合并页:仅展示版本与操作提示,不重复主编辑区数值 */
compact?: boolean;
className?: string;
};
export function OddsConfigSummaryPanel({
catTabLabel,
playLabel,
detail,
scopeRows,
playRebatePercent,
activeHead = null,
compact = false,
className,
}: OddsConfigSummaryPanelProps) {
const { t } = useTranslation("config");
const formatDt = useAdminDateTimeFormatter();
const isDraft = detail?.status === "draft";
const isActive = detail?.status === "active";
const versionLabel = detail ? `v${detail.version_no}` : "—";
return (
<aside
className={cn(
"xl:sticky xl:top-24 xl:max-h-[calc(100vh-7rem)] xl:overflow-y-auto",
className,
)}
>
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<div className="flex items-center gap-2 border-b border-border/50 px-4 py-3.5">
<FileText className="size-4 text-primary" aria-hidden />
<h3 className="text-base font-semibold">
{compact ? t("odds.summary.contextTitle") : t("odds.summary.title")}
</h3>
</div>
<div className="space-y-4 px-4 py-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">{t("odds.summary.version")}</span>
<span className="font-mono text-sm font-medium">{versionLabel}</span>
{detail ? <ConfigStatusBadge status={detail.status} /> : null}
</div>
{activeHead ? (
<div className="space-y-1 text-sm">
<p className="text-muted-foreground">{t("odds.summary.activeVersion")}</p>
<p className="font-mono font-medium">
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</p>
</div>
) : null}
{!compact && catTabLabel && playLabel ? (
<dl className="space-y-2 text-sm">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
<dt className="text-muted-foreground">{t("odds.category")}</dt>
<dd>{catTabLabel}</dd>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
<dt className="text-muted-foreground">{t("odds.playType")}</dt>
<dd>{playLabel}</dd>
</div>
</dl>
) : null}
{!compact && scopeRows ? (
<dl className="space-y-2.5">
{(["first", "second", "third", "starter", "consolation"] as PrizeScopeCode[]).map((scope) => {
const row = scopeRows[scope];
return (
<div key={scope} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<dt className="text-muted-foreground">{prizeScopeLabel(scope, t)}</dt>
<dd className="text-right text-lg font-semibold text-foreground">
{row ? oddsMultiplierLabel(row.odds_value) : "—"}
</dd>
</div>
);
})}
{playRebatePercent ? (
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<dt className="text-muted-foreground">{t("odds.rebateRate")}</dt>
<dd className="text-right text-lg font-semibold text-foreground">{playRebatePercent}</dd>
</div>
) : null}
</dl>
) : null}
{detail && !isDraft ? (
<Alert className="border-sky-500/30 bg-sky-500/5 text-foreground">
<Info className="size-4 text-sky-600 dark:text-sky-400" aria-hidden />
<AlertDescription className="text-xs leading-relaxed">
{t("odds.summary.readOnlyHint")}
</AlertDescription>
</Alert>
) : isDraft ? (
<Alert className="border-primary/25 bg-primary/5 text-foreground">
<AlertDescription className="text-xs leading-relaxed">
{t("odds.summary.draftHint")}
</AlertDescription>
</Alert>
) : isActive ? (
<Alert className="border-emerald-500/30 bg-emerald-500/5 text-foreground">
<AlertDescription className="text-xs leading-relaxed">
{t("odds.summary.activeHint")}
</AlertDescription>
</Alert>
) : null}
</div>
</div>
</aside>
);
}
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
}
/** 当前玩法在摘要中展示的回水百分比(与赔率区输入一致)。 */
export function playRebatePercentFromScopes(
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>,
order: readonly PrizeScopeCode[],
): string {
const first = order.map((s) => scopeRows[s]).find(Boolean);
if (!first) {
return "0";
}
return rateToPercentUi(String(first.rebate_rate));
}

View File

@@ -17,11 +17,7 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { ConfigSection } from "@/modules/config/config-section";
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
import {
Dialog,
@@ -52,7 +48,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref";
@@ -152,7 +148,6 @@ export function PlayConfigDocScreen() {
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
@@ -429,8 +424,6 @@ export function PlayConfigDocScreen() {
return <span>{name || row.play_code}</span>;
}
const activeHead = list.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deletePlayConfigVersion(row.id);
@@ -488,7 +481,7 @@ export function PlayConfigDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
@@ -515,100 +508,63 @@ export function PlayConfigDocScreen() {
}
/>
}
footer={
detail ? (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
{activeHead ? (
<span>
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</span>
) : null}
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("play.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : activeHead ? (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
) : null}
</ConfigVersionToolbarMeta>
) : null
}
/>
}
>
{detail ? (
<ConfigSection
title={t("play.filters.sectionTitle", { ns: "config" })}
description={isDraft ? t("play.filters.sectionDescription", { ns: "config" }) : undefined}
>
{!isDraft ? (
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
{t("play.readOnlyDraftHint", { ns: "config" })}
</div>
) : null}
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="flex flex-1 flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<div className="flex min-w-0 flex-col gap-1.5 md:w-[320px]">
<span className="text-sm font-medium">{t("play.filters.keyword", { ns: "config" })}</span>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
className="h-8"
/>
</div>
<div className="flex flex-col gap-1.5 md:w-[140px]">
<span className="text-sm font-medium">{t("play.filters.category", { ns: "config" })}</span>
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
<SelectTrigger className="h-8">
<SelectValue>
{categoryFilter === "all"
? t("play.filters.allCategories", { ns: "config" })
: categoryLabel(categoryFilter)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
{categoryOptions.map((category) => (
<SelectItem key={category} value={category}>
{categoryLabel(category)}
</SelectItem>
))}
<SelectItem value="uncategorized">
{t("play.filters.uncategorized", { ns: "config" })}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 md:w-[140px]">
<span className="text-sm font-medium">{t("play.filters.status", { ns: "config" })}</span>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
>
<SelectTrigger className="h-8">
<SelectValue>
{statusFilter === "all"
? t("play.filters.allStatuses", { ns: "config" })
: statusFilter === "enabled"
? t("play.states.enabled", { ns: "config" })
: t("play.states.disabled", { ns: "config" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("play.filters.allStatuses", { ns: "config" })}</SelectItem>
<SelectItem value="enabled">{t("play.states.enabled", { ns: "config" })}</SelectItem>
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-end justify-start lg:flex-none">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
className="h-8 w-full min-w-[12rem] max-w-xs"
/>
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
<SelectTrigger className="h-8 w-[8.5rem]">
<SelectValue>
{categoryFilter === "all"
? t("play.filters.allCategories", { ns: "config" })
: categoryLabel(categoryFilter)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
{categoryOptions.map((category) => (
<SelectItem key={category} value={category}>
{categoryLabel(category)}
</SelectItem>
))}
<SelectItem value="uncategorized">
{t("play.filters.uncategorized", { ns: "config" })}
</SelectItem>
</SelectContent>
</Select>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
>
<SelectTrigger className="h-8 w-[6.5rem]">
<SelectValue>
{statusFilter === "all"
? t("play.filters.allStatuses", { ns: "config" })
: statusFilter === "enabled"
? t("play.states.enabled", { ns: "config" })
: t("play.states.disabled", { ns: "config" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("play.filters.allStatuses", { ns: "config" })}</SelectItem>
<SelectItem value="enabled">{t("play.states.enabled", { ns: "config" })}</SelectItem>
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
</SelectContent>
</Select>
{(keyword || categoryFilter !== "all" || statusFilter !== "all") ? (
<Button
type="button"
size="sm"
variant="outline"
variant="ghost"
className="h-8 px-2"
onClick={() => {
setKeyword("");
setCategoryFilter("all");
@@ -617,76 +573,52 @@ export function PlayConfigDocScreen() {
>
{t("play.filters.reset", { ns: "config" })}
</Button>
</div>
) : null}
</div>
{isDraft ? (
<div className="space-y-2 border-t border-border/60 pt-3">
<div className="text-xs font-medium text-muted-foreground">
{t("play.batchSwitchesTitle", { ns: "config" })}
</div>
<ConfigChipGroup>
{batchSwitchStates.map((group) => {
const groupOn = group.allEnabled;
const isPartial =
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
return (
<div
key={group.key}
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-card px-3 py-2"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{group.label}</p>
<p className="text-xs text-muted-foreground">
{group.total > 0
? isPartial
? t("play.batchPartialEnabled", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
: t("play.batchEnabledCount", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
: t("play.noPlayTypes", { ns: "config" })}
</p>
</div>
<div className="flex shrink-0 items-center justify-center">
<Checkbox
checked={groupOn}
indeterminate={isPartial}
disabled={saving || group.total === 0 || confirmBusy}
aria-label={t("play.aria.batchGroupSwitch", {
<ConfigChipGroup label={t("play.batchSwitchesTitle", { ns: "config" })}>
{batchSwitchStates.map((group) => {
const groupOn = group.allEnabled;
const isPartial =
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
return (
<label
key={group.key}
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm"
>
<span>{group.label}</span>
<Checkbox
checked={groupOn}
indeterminate={isPartial}
disabled={saving || group.total === 0 || confirmBusy}
aria-label={t("play.aria.batchGroupSwitch", {
ns: "config",
group: group.label,
})}
onCheckedChange={(checked) => {
const enable = checked === true;
const action = enable
? t("play.batchSwitchEnable", { ns: "config" })
: t("play.batchSwitchDisable", { ns: "config" });
requestConfirm({
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
description: t("play.batchSwitchConfirmDescription", {
ns: "config",
action,
group: group.label,
})}
onCheckedChange={(checked) => {
const enable = checked === true;
const action = enable
? t("play.batchSwitchEnable", { ns: "config" })
: t("play.batchSwitchDisable", { ns: "config" });
requestConfirm({
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
description: t("play.batchSwitchConfirmDescription", {
ns: "config",
action,
group: group.label,
count: group.total,
}),
confirmVariant: enable ? "default" : "destructive",
onConfirm: () => applyBatchSwitch(group, enable),
});
}}
/>
</div>
</div>
);
})}
</ConfigChipGroup>
</div>
count: group.total,
}),
confirmVariant: enable ? "default" : "destructive",
onConfirm: () => applyBatchSwitch(group, enable),
});
}}
/>
</label>
);
})}
</ConfigChipGroup>
) : null}
</ConfigSection>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}

View File

@@ -481,8 +481,7 @@ export function RebateConfigDocScreen({
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={resolvedLoading}
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}

View File

@@ -15,14 +15,11 @@ import {
putRiskCapItems,
} from "@/api/admin-config";
import { getAdminDraws } from "@/api/admin-draws";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { ConfigSection } from "@/modules/config/config-section";
import { ConfigVersionToolbarMeta } from "@/modules/config/config-version-toolbar-meta";
import {
Dialog,
DialogContent,
@@ -35,7 +32,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
import {
Table,
TableBody,
@@ -44,13 +40,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
@@ -63,9 +53,7 @@ import { PRD_RISK_CAP_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
import type {
AdminDrawListItem,
} from "@/types/api/admin-draws";
import type { AdminDrawListItem } from "@/types/api/admin-draws";
import type {
ConfigVersionSummary,
RiskCapItemRow,
@@ -102,6 +90,14 @@ function formatMinorToEditableMajor(minor: number, currencyCode: string): string
return formatAdminMinorDecimal(minor, currencyCode).replace(/,/g, "");
}
function syncDefaultCapInput(rows: DraftRiskRow[], currencyCode: string): string {
const defaultRow = rows.find(isDefaultRiskRow);
if (!defaultRow) {
return "";
}
return formatMinorToEditableMajor(defaultRow.cap_amount, currencyCode);
}
export function RiskCapDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useTranslationRef(["config", "common"]);
@@ -113,16 +109,15 @@ export function RiskCapDocScreen() {
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<RiskCapVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<DraftRiskRow[]>([]);
const [defaultCapInput, setDefaultCapInput] = useState("");
const [loadingList, setLoadingList] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [drawOptions, setDrawOptions] = useState<AdminDrawListItem[]>([]);
const [defaultCapStr, setDefaultCapStr] = useState("");
const [syncOpen, setSyncOpen] = useState(false);
const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const [showNumberCaps, setShowNumberCaps] = useState(false);
const amountCurrencyCode = "NPR";
@@ -159,40 +154,49 @@ export function RiskCapDocScreen() {
void loadDrawOptions();
}, []);
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
const defaultRow = rows.find(isDefaultRiskRow);
if (!defaultRow) {
setDefaultCapStr("");
return;
}
setDefaultCapStr(formatMinorToEditableMajor(defaultRow.cap_amount, amountCurrencyCode));
}
const applyDefaultCapInput = useCallback(
(value: string) =>
(prev: DraftRiskRow[]): DraftRiskRow[] => {
const n = parseAdminMajorToMinor(value, amountCurrencyCode);
const rest = prev.filter((row) => !isDefaultRiskRow(row));
if (n != null && n > 0) {
return [defaultRiskRowFromAmount(n), ...rest];
}
return rest;
},
[amountCurrencyCode],
);
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
const d = await getRiskCapVersion(id);
setDetail(d);
const mapped = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(mapped);
syncDefaultCapFromRows(mapped);
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
syncDefaultCapFromRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
const loadDetail = useCallback(
async (id: number) => {
setLoadingDetail(true);
try {
const d = await getRiskCapVersion(id);
setDetail(d);
const mapped = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(mapped);
setDefaultCapInput(syncDefaultCapInput(mapped, amountCurrencyCode));
setShowNumberCaps(mapped.some((row) => !isDefaultRiskRow(row)));
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
setDefaultCapInput("");
setShowNumberCaps(false);
} finally {
setLoadingDetail(false);
}
},
[amountCurrencyCode, tRef],
);
useEffect(() => {
if (list.length === 0) {
@@ -201,7 +205,7 @@ export function RiskCapDocScreen() {
setSelectedId("");
setDetail(null);
setDraftRows([]);
syncDefaultCapFromRows([]);
setDefaultCapInput("");
});
}
return;
@@ -251,11 +255,13 @@ export function RiskCapDocScreen() {
if (!detail || !canEditDraft) {
return;
}
if (draftRows.length === 0) {
const rowsToSave = applyDefaultCapInput(defaultCapInput)(draftRows);
setDraftRows(rowsToSave);
if (rowsToSave.length === 0) {
toast.error(t("riskCap.validation.requireAtLeastOne", { ns: "config" }));
return;
}
for (const r of draftRows) {
for (const r of rowsToSave) {
if (isDefaultRiskRow(r)) {
if (r.cap_amount <= 0) {
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
@@ -278,7 +284,7 @@ export function RiskCapDocScreen() {
}
setSaving(true);
try {
const payload = draftRows.map((r) => ({
const payload = rowsToSave.map((r) => ({
draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null,
normalized_number: r.normalized_number,
cap_amount: r.cap_amount,
@@ -294,7 +300,7 @@ export function RiskCapDocScreen() {
cap_type: it.cap_type,
}));
setDraftRows(saved);
syncDefaultCapFromRows(saved);
setDefaultCapInput(syncDefaultCapInput(saved, amountCurrencyCode));
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
@@ -320,7 +326,7 @@ export function RiskCapDocScreen() {
cap_type: it.cap_type,
}));
setDraftRows(pub);
syncDefaultCapFromRows(pub);
setDefaultCapInput(syncDefaultCapInput(pub, amountCurrencyCode));
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
@@ -351,7 +357,7 @@ export function RiskCapDocScreen() {
cap_type: it.cap_type,
}));
setDraftRows(nd);
syncDefaultCapFromRows(nd);
setDefaultCapInput(syncDefaultCapInput(nd, amountCurrencyCode));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.createDraftFailed", { ns: "config" }));
} finally {
@@ -359,37 +365,25 @@ export function RiskCapDocScreen() {
}
}
function applyDefaultCap() {
const n = parseAdminMajorToMinor(defaultCapStr, amountCurrencyCode);
if (n == null || !Number.isFinite(n) || n <= 0) {
toast.error(t("riskCap.validation.enterValidCapAmount", { ns: "config" }));
return;
}
setDraftRows((prev) => {
const next = prev.filter((row) => !isDefaultRiskRow(row));
return [defaultRiskRowFromAmount(n), ...next];
});
setSyncOpen(false);
toast.message(t("riskCap.savedLocalDraft", { ns: "config" }));
}
const specialRows = useMemo(
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
() =>
draftRows
.map((row, index) => ({ row, index }))
.filter(({ row }) => !isDefaultRiskRow(row) && row.draw_id == null),
[draftRows],
);
const globalRows = useMemo(
() => specialRows.filter(({ row }) => row.draw_id == null),
[specialRows],
);
const drawRows = useMemo(
() => specialRows.filter(({ row }) => row.draw_id != null),
[specialRows],
const drawBoundRows = useMemo(
() => draftRows.filter((row) => !isDefaultRiskRow(row) && row.draw_id != null),
[draftRows],
);
function drawLabel(drawId: number): string {
return drawOptions.find((draw) => draw.id === drawId)?.draw_no ?? String(drawId);
}
const defaultCapDisplay = detail
? formatAdminMinorDecimal(
draftRows.find(isDefaultRiskRow)?.cap_amount ?? 0,
amountCurrencyCode,
)
? formatAdminMinorDecimal(draftRows.find(isDefaultRiskRow)?.cap_amount ?? 0, amountCurrencyCode)
: formatAdminMinorDecimal(0, amountCurrencyCode);
async function handleDeleteVersion(row: ConfigVersionSummary) {
@@ -436,7 +430,7 @@ export function RiskCapDocScreen() {
cap_type: it.cap_type,
}));
setDraftRows(mapped);
syncDefaultCapFromRows(mapped);
setDefaultCapInput(syncDefaultCapInput(mapped, amountCurrencyCode));
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
@@ -458,7 +452,7 @@ export function RiskCapDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.risk-cap", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
@@ -486,276 +480,191 @@ export function RiskCapDocScreen() {
/>
}
footer={
detail ? (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
detail?.effective_at ? (
<ConfigVersionToolbarMeta>
<span>
{t("riskCap.effectiveAt", {
ns: "config",
value: detail.effective_at ? formatDt(detail.effective_at) : "—",
value: formatDt(detail.effective_at),
})}
</span>
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("riskCap.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
)}
</ConfigVersionToolbarMeta>
) : null
}
/>
}
contentClassName="space-y-8"
contentClassName="space-y-6"
>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="grid gap-3 md:grid-cols-3">
{[
{
key: "default",
label: t("riskCap.summary.defaultCap", { ns: "config" }),
value: defaultCapDisplay,
hint: t("riskCap.summary.defaultHint", { ns: "config" }),
},
{
key: "global",
label: t("riskCap.summary.globalCaps", { ns: "config" }),
value: t("riskCap.groups.count", { ns: "config", count: globalRows.length }),
hint: t("riskCap.summary.globalHint", { ns: "config" }),
},
{
key: "draw",
label: t("riskCap.summary.drawCaps", { ns: "config" }),
value: t("riskCap.groups.count", { ns: "config", count: drawRows.length }),
hint: t("riskCap.summary.drawHint", { ns: "config" }),
},
].map((card) => (
<div key={card.key} className="rounded-xl border border-border/60 bg-background p-4 shadow-sm">
<p className="text-xs text-muted-foreground">{card.label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">{card.value}</p>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{card.hint}</p>
</div>
))}
</div>
<ConfigSection title={t("riskCap.defaultCap.title", { ns: "config" })}>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
id="default-cap"
type="text"
inputMode="decimal"
className="h-9 w-[220px] text-base font-semibold"
disabled={saving}
value={defaultCapStr}
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
onChange={(e) => setDefaultCapStr(e.target.value)}
/>
) : (
<ConfigReadonlyValue className="h-9 w-[220px] text-base font-semibold">
{defaultCapDisplay}
</ConfigReadonlyValue>
)}
</div>
{canEditDraft ? (
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
{t("riskCap.actions.update", { ns: "config" })}
</Button>
) : null}
</div>
</ConfigSection>
<ConfigSection
title={t("riskCap.specialCaps.title", { ns: "config" })}
description={t("riskCap.specialCaps.description", { ns: "config" })}
actions={
canEditDraft ? (
<Button
type="button"
variant="outline"
<AdminPageCard title={t("riskCap.defaultCap.title", { ns: "config" })}>
<div className="max-w-xs space-y-2">
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
id="default-cap"
type="text"
inputMode="decimal"
className="font-semibold tabular-nums"
disabled={saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
>
{t("riskCap.actions.addSpecialCap", { ns: "config" })}
</Button>
) : null
}
>
value={defaultCapInput}
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
onChange={(e) => setDefaultCapInput(e.target.value)}
onBlur={() => setDraftRows(applyDefaultCapInput(defaultCapInput))}
/>
) : (
<ConfigReadonlyValue className="font-semibold tabular-nums">{defaultCapDisplay}</ConfigReadonlyValue>
)}
</div>
</AdminPageCard>
{canEditDraft && !showNumberCaps && specialRows.length === 0 && drawBoundRows.length === 0 ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={saving || loadingDetail}
onClick={() => {
setShowNumberCaps(true);
setDraftRows((prev) => [...prev, newRow()]);
}}
>
{t("riskCap.actions.addNumberCap", { ns: "config" })}
</Button>
) : null}
{showNumberCaps || specialRows.length > 0 || drawBoundRows.length > 0 ? (
<AdminPageCard
title={t("riskCap.specialCaps.title", { ns: "config" })}
actions={
canEditDraft ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
>
{t("riskCap.actions.addSpecialCap", { ns: "config" })}
</Button>
) : null
}
>
{loadingDetail ? (
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
) : specialRows.length === 0 ? (
<AdminLoadingState
minHeight="6rem"
className="py-4"
label={t("riskCap.loadingDetails", { ns: "config" })}
/>
) : specialRows.length === 0 && drawBoundRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
) : (
<div className="space-y-4">
{[
{
key: "global",
title: t("riskCap.groups.globalTitle", { ns: "config" }),
description: t("riskCap.groups.globalDescription", { ns: "config" }),
rows: globalRows,
emptyText: t("riskCap.groups.globalEmpty", { ns: "config" }),
},
{
key: "draw",
title: t("riskCap.groups.drawTitle", { ns: "config" }),
description: t("riskCap.groups.drawDescription", { ns: "config" }),
rows: drawRows,
emptyText: t("riskCap.groups.drawEmpty", { ns: "config" }),
},
].map((group) => (
<div key={group.key} className="rounded-xl border border-border/60 bg-muted/10 p-3">
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
<p className="text-xs leading-5 text-muted-foreground">{group.description}</p>
</div>
<span className="rounded-full bg-background px-2 py-1 text-xs text-muted-foreground">
{t("riskCap.groups.count", { ns: "config", count: group.rows.length })}
</span>
</div>
{group.rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{group.emptyText}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">{t("riskCap.table.scope", { ns: "config" })}</TableHead>
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("riskCap.table.actions", { ns: "config" })}
</TableHead>
{specialRows.length > 0 ? (
<div className="admin-table-shell">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("riskCap.table.actions", { ns: "config" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{specialRows.map(({ row: r, index: idx }) => (
<TableRow key={r.clientKey}>
<TableCell>
{canEditDraft ? (
<Input
className="h-8 font-mono tabular-nums"
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),
})
}
/>
) : (
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-8 tabular-nums"
disabled={saving}
value={formatMinorToEditableMajor(r.cap_amount, amountCurrencyCode)}
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
onChange={(e) =>
updateRow(idx, {
cap_amount: parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
})
}
/>
) : (
<ConfigReadonlyValue>
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canEditDraft ? (
<AdminRowActionsMenu
busy={saving}
actions={[
{
key: "delete",
label: t("actions.delete", { ns: "adminUsers" }),
icon: Trash2,
destructive: true,
onClick: () => removeRow(idx),
},
]}
/>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{group.rows.map(({ row: r, index: idx }) => (
<TableRow key={r.clientKey}>
<TableCell>
{canEditDraft ? (
<Select
value={r.draw_id == null ? "__global__" : String(r.draw_id)}
onValueChange={(value) =>
updateRow(idx, { draw_id: value === "__global__" ? null : Number(value) })
}
>
<SelectTrigger className="h-8 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__global__">
{t("riskCap.scope.global", { ns: "config" })}
</SelectItem>
{drawOptions.map((draw) => (
<SelectItem key={draw.id} value={String(draw.id)}>
{draw.draw_no}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<ConfigReadonlyValue>
{r.draw_id == null
? t("riskCap.scope.global", { ns: "config" })
: drawOptions.find((draw) => draw.id === r.draw_id)?.draw_no ??
t("riskCap.scope.drawId", { ns: "config", id: r.draw_id })}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
{canEditDraft ? (
<Input
className="h-8 font-mono tabular-nums"
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),
})
}
/>
) : (
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-8 tabular-nums"
disabled={saving}
value={formatMinorToEditableMajor(r.cap_amount, amountCurrencyCode)}
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
onChange={(e) =>
updateRow(idx, {
cap_amount:
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
})
}
/>
) : (
<ConfigReadonlyValue>
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canEditDraft ? (
<AdminRowActionsMenu
busy={saving}
actions={[
{
key: "delete",
label: t("actions.delete", { ns: "adminUsers" }),
icon: Trash2,
destructive: true,
onClick: () => removeRow(idx),
},
]}
/>
) : (
<span className="text-sm text-muted-foreground">
{t("riskCap.readOnly", { ns: "config" })}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
))}
</TableBody>
</Table>
</div>
))}
) : null}
{drawBoundRows.length > 0 ? (
<div className="admin-table-shell">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("riskCap.table.draw", { ns: "config" })}</TableHead>
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead>{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{drawBoundRows.map((row) => (
<TableRow key={row.clientKey}>
<TableCell>{drawLabel(row.draw_id ?? 0)}</TableCell>
<TableCell className="font-mono">{row.normalized_number}</TableCell>
<TableCell>{formatAdminMinorDecimal(row.cap_amount, amountCurrencyCode)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : null}
</div>
)}
</ConfigSection>
<RiskCapRuntimePanel />
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("riskCap.syncDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("riskCap.syncDialog.description", { ns: "config", value: defaultCapStr || "(empty)" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={applyDefaultCap}>
{t("riskCap.syncDialog.confirm", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AdminPageCard>
) : null}
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
@@ -782,4 +691,4 @@ export function RiskCapDocScreen() {
<ConfirmDialog />
</ConfigDocPage>
);
}
}

View File

@@ -1,287 +0,0 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import { getAdminDraws } from "@/api/admin-draws";
import { getAdminRiskPools } from "@/api/admin-risk";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigSection } from "@/modules/config/config-section";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListItem } from "@/types/api/admin-draws";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
type PoolFilter = "all" | "sold_out" | "high_risk";
export function RiskCapRuntimePanel() {
const { t } = useTranslation(["config", "risk", "draws", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const [draws, setDraws] = useState<AdminDrawListItem[]>([]);
const [drawsLoading, setDrawsLoading] = useState(true);
const [drawId, setDrawId] = useState<string>("");
const [numberQ, setNumberQ] = useState("");
const [appliedNumber, setAppliedNumber] = useState("");
const [poolFilter, setPoolFilter] = useState<PoolFilter>("all");
const [pools, setPools] = useState<AdminRiskPoolRow[]>([]);
const [currencyCode, setCurrencyCode] = useState<string | null>(null);
const [poolsLoading, setPoolsLoading] = useState(false);
const [poolsError, setPoolsError] = useState<string | null>(null);
const selectedDraw = useMemo(
() => draws.find((d) => String(d.id) === drawId) ?? null,
[draws, drawId],
);
const loadDraws = useCallback(async () => {
setDrawsLoading(true);
try {
const data = await getAdminDraws({ page: 1, per_page: 50 });
setDraws(data.items);
if (data.items.length > 0) {
setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev));
}
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDraws([]);
} finally {
setDrawsLoading(false);
}
}, []);
const loadPools = useCallback(async () => {
if (!drawId) {
setPools([]);
return;
}
const id = Number(drawId);
if (!Number.isFinite(id)) {
return;
}
setPoolsLoading(true);
setPoolsError(null);
try {
const data = await getAdminRiskPools(id, {
page: 1,
per_page: 200,
normalized_number: appliedNumber.trim() || undefined,
sold_out_only: poolFilter === "sold_out",
high_risk_only: poolFilter === "high_risk",
sort: poolFilter === "high_risk" ? "usage_desc" : "number_asc",
});
setPools(data.items);
setCurrencyCode(data.currency_code);
} catch (e) {
setPoolsError(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setPools([]);
} finally {
setPoolsLoading(false);
}
}, [appliedNumber, drawId, poolFilter]);
useAsyncEffect(() => {
void loadDraws();
}, []);
useAsyncEffect(() => {
void loadPools();
}, [appliedNumber, drawId, poolFilter]);
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;
return (
<ConfigSection
title={t("riskCap.runtime.title", { ns: "config" })}
description={t("riskCap.runtime.description", { ns: "config" })}
>
<div className="flex flex-wrap items-end gap-3">
<div className="grid min-w-[12rem] flex-1 gap-1.5">
<Label htmlFor="risk-cap-draw">{t("riskCap.runtime.drawLabel", { ns: "config" })}</Label>
<Select
value={drawId}
onValueChange={(v) => setDrawId(v == null ? "" : String(v))}
disabled={drawsLoading || draws.length === 0}
>
<SelectTrigger id="risk-cap-draw" className="font-mono">
<SelectValue>
{(v) => {
if (v == null || v === "") {
return t("riskCap.runtime.drawPlaceholder", { ns: "config" });
}
const draw = draws.find((d) => String(d.id) === String(v));
return draw ? `${draw.draw_no} · ${draw.status}` : String(v);
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{draws.map((d) => (
<SelectItem key={d.id} value={String(d.id)}>
{d.draw_no} · {d.status}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{riskBase ? (
<div className="flex flex-wrap gap-2 pb-0.5">
<Link href={`${riskBase}/pools`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{t("subnav.riskPools", { ns: "draws" })}
</Link>
<Link
href={`${riskBase}/pools?filter=high_risk`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("filterHighRisk", { ns: "risk" })}
</Link>
<Link
href={`${riskBase}/pools?filter=sold_out`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("filterSoldOut", { ns: "risk" })}
</Link>
</div>
) : null}
</div>
{drawId ? (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="grid gap-1.5">
<Label htmlFor="risk-cap-number-q">{t("riskCap.occupancy.searchLabel", { ns: "config" })}</Label>
<Input
id="risk-cap-number-q"
className="w-[140px] font-mono"
placeholder={t("riskCap.occupancy.searchPlaceholder", { ns: "config" })}
value={numberQ}
onChange={(e) => setNumberQ(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2">
{(
[
{ id: "all", label: t("riskCap.runtime.filterAll", { ns: "config" }) },
{ id: "sold_out", label: t("riskCap.runtime.filterSoldOut", { ns: "config" }) },
{ id: "high_risk", label: t("riskCap.runtime.filterHighRisk", { ns: "config" }) },
] as const
).map((f) => (
<Button
key={f.id}
type="button"
size="sm"
variant={poolFilter === f.id ? "default" : "outline"}
onClick={() => setPoolFilter(f.id)}
>
{f.label}
</Button>
))}
</div>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setAppliedNumber(numberQ.trim());
}}
>
{t("actions.search", { ns: "common" })}
</Button>
<Button type="button" size="sm" variant="outline" disabled={poolsLoading} onClick={() => void loadPools()}>
{t("versionActions.refresh", { ns: "config" })}
</Button>
{pools.length > 0 ? (
<AdminTableExportButton
tableId="risk-cap-runtime-pools"
filename={`risk-pools-${selectedDraw?.draw_no ?? drawId}`}
/>
) : null}
</div>
{poolsError ? <p className="text-sm text-destructive">{poolsError}</p> : null}
<div className="admin-table-shell">
<Table id="risk-cap-runtime-pools">
<TableHeader>
<TableRow>
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{poolsLoading ? (
<AdminTableLoadingRow colSpan={5} />
) : pools.length === 0 ? (
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
) : (
pools.map((row) => (
<TableRow
key={row.normalized_number}
className={cn(
row.is_sold_out && "bg-destructive/5",
!row.is_sold_out && (row.usage_ratio ?? 0) >= 0.8 && "bg-amber-500/10",
)}
>
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.locked_amount, currencyCode ?? undefined)}
</TableCell>
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.remaining_amount, currencyCode ?? undefined)}
</TableCell>
<TableCell className="text-center text-sm font-semibold">
{row.usage_ratio != null ? `${Math.round(row.usage_ratio * 100)}%` : "—"}
</TableCell>
<TableCell className="text-center text-xs">
{row.is_sold_out
? t("riskCap.runtime.soldYes", { ns: "config" })
: t("riskCap.runtime.soldNo", { ns: "config" })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<p className="text-xs text-muted-foreground">
{t("riskCap.runtime.manageHint", { ns: "config" })}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("riskCap.runtime.noDraws", { ns: "config" })}</p>
)}
</ConfigSection>
);
}

View File

@@ -2,16 +2,23 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
import {
BarChart3,
ClipboardList,
Network,
RefreshCw,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
@@ -22,20 +29,20 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import {
DashboardKpiCard,
DashboardQuickLinksCard,
DashboardReportsTeaser,
DashboardScopeMetric,
DashboardSignedStatRow,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
import {
formatDashboardCreditMajor,
formatDashboardMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardAgentOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
function buildTodayBetHint(
@@ -52,6 +59,13 @@ function buildTodayBetHint(
return `${dateHint} · ${t("agent.noBetToday")}`;
}
function agentLineHref(agentNodeId: number | null | undefined): string {
if (agentNodeId != null && agentNodeId > 0) {
return `/admin/agents?agent_node_id=${agentNodeId}`;
}
return "/admin/agents";
}
export function AgentDashboardConsole(): ReactElement {
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
const tRef = useTranslationRef(["dashboard", "common"]);
@@ -69,25 +83,33 @@ export function AgentDashboardConsole(): ReactElement {
}, [i18n.language, i18n.resolvedLanguage, t]);
useAdminCurrencyCatalog();
const playOptions = useCachedPlayTypeOptions();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
const analyticsScope = useMemo(
() => ({
siteCode: agent?.site_code ?? "",
agentNodeId: agent?.id,
}),
[agent?.id, agent?.site_code],
);
const canReports = adminHasAnyPermission(permissions, [PRD_REPORT_VIEW]);
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
const lineHref = agentLineHref(agent?.id);
const quickLinks = useMemo(
() => [
{ href: "/admin/tickets", label: t("agent.quickLinks.tickets"), icon: ClipboardList },
{ href: lineHref, label: t("agent.quickLinks.players"), icon: Users },
{ href: lineHref, label: t("agent.quickLinks.agents"), icon: Network },
{
href: settlementCenterScopeHref(undefined, "awaiting_payment"),
label: t("agent.quickLinks.bills"),
icon: Wallet,
},
...(canReports
? [{ href: "/admin/reports", label: t("agent.quickLinks.reports"), icon: BarChart3 }]
: []),
],
[canReports, lineHref, t],
);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -99,14 +121,8 @@ export function AgentDashboardConsole(): ReactElement {
try {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.agent_overview);
setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
setDrawId(null);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
@@ -175,6 +191,7 @@ export function AgentDashboardConsole(): ReactElement {
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
icon={<TrendingUp className="size-4" />}
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
href="/admin/tickets"
/>
<DashboardKpiCard
label={t("agent.todayShareProfit")}
@@ -184,12 +201,14 @@ export function AgentDashboardConsole(): ReactElement {
hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
})}`}
href={canReports ? "/admin/reports" : undefined}
/>
<DashboardKpiCard
label={t("agent.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Users className="size-4" />}
hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })}
href={lineHref}
/>
<DashboardKpiCard
label={t("agent.pendingBills")}
@@ -199,6 +218,7 @@ export function AgentDashboardConsole(): ReactElement {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
href={settlementCenterScopeHref(undefined, "awaiting_payment")}
/>
</div>
@@ -289,6 +309,8 @@ export function AgentDashboardConsole(): ReactElement {
</CardContent>
</Card>
</div>
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
</section>
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
@@ -296,22 +318,14 @@ export function AgentDashboardConsole(): ReactElement {
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
{canAnalytics ? (
<DashboardAnalyticsPanel
enabled={canAnalytics}
playOptions={playOptions}
scope={analyticsScope}
<DashboardReportsTeaser
title={t("analytics.title")}
description={t("agent.shareProfitScopeHint")}
/>
) : !loading ? (
<p className="text-xs text-muted-foreground">{t("warnings.analyticsUnavailable")}</p>
) : null}
</div>
);
}
}

View File

@@ -38,7 +38,6 @@ import {
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardDrawPanel,
AdminDashboardLifetimeFinance,
AdminDashboardPlatformRisk,
AdminDashboardResultBatchQueue,
@@ -172,7 +171,6 @@ export function DashboardConsole(): ReactElement {
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
const [resultBatchQueue, setResultBatchQueue] = useState<AdminDashboardResultBatchQueue | null>(
@@ -201,7 +199,6 @@ export function DashboardConsole(): ReactElement {
setError(null);
setFinance(null);
setCapabilities(null);
setDrawPanel(null);
setResultBatchQueue(null);
setLifetimeFinance(null);
setTodayFinance(null);
@@ -230,9 +227,6 @@ export function DashboardConsole(): ReactElement {
setTodayFinance(d.today_finance);
setApiWarnings(d.warnings ?? []);
setPlatformRisk(d.platform_risk);
if (d.draw != null) {
setDrawPanel(d.draw);
}
if (d.risk != null) {
setRiskLocked(d.risk.locked_amount);
setRiskCap(d.risk.cap_amount);
@@ -544,6 +538,23 @@ export function DashboardConsole(): ReactElement {
</Card>
</div>
{showAnalytics ? (
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
{loading ? (
<Skeleton className="h-52 w-full" />
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>
) : null}
{!showAnalytics ? (
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
@@ -565,22 +576,7 @@ export function DashboardConsole(): ReactElement {
{showAnalytics ? (
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
<DashboardPlayRankingCard analytics={analytics} />
<DashboardAgentRankingCard analytics={analytics} />
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
{loading ? (
<Skeleton className="h-52 w-full" />
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>
<DashboardAgentRankingCard analytics={analytics} />
</aside>
) : null}
</section>

View File

@@ -4,7 +4,13 @@ import Link from "next/link";
import type { ReactElement, ReactNode } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react";
import {
AlertTriangle,
ArrowRightIcon,
CheckCircle2,
ChevronRightIcon,
type LucideIcon,
} from "lucide-react";
import {
Bar,
BarChart,
@@ -19,7 +25,7 @@ import {
YAxis,
} from "recharts";
import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { Skeleton } from "@/components/ui/skeleton";
@@ -268,6 +274,7 @@ export function DashboardKpiCard({
currencyCode,
sparklineValues,
deltaLabel,
href,
}: {
label: string;
value?: ReactNode;
@@ -281,6 +288,8 @@ export function DashboardKpiCard({
currencyCode?: string | null;
sparklineValues?: number[];
deltaLabel?: ReactNode;
/** 整张卡片可点击跳转 */
href?: string;
}): ReactElement {
const resolvedValue =
typeof signedAmountMinor === "number" && currencyCode !== undefined
@@ -295,17 +304,30 @@ export function DashboardKpiCard({
? String(resolvedValue)
: undefined;
return (
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
const card = (
<div
className={cn(
"flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4",
href && "transition-colors group-hover/kpi:border-primary/30 group-hover/kpi:shadow-sm",
)}
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4",
kpiAccentClass(accent),
)}
>
{icon}
<div className="flex shrink-0 items-center gap-1">
<div
className={cn(
"flex size-9 items-center justify-center rounded-lg [&_svg]:size-4",
kpiAccentClass(accent),
)}
>
{icon}
</div>
{href ? (
<ChevronRightIcon
className="size-4 text-muted-foreground/50 transition group-hover/kpi:text-primary"
aria-hidden
/>
) : null}
</div>
</div>
<p
@@ -336,10 +358,126 @@ export function DashboardKpiCard({
</div>
) : null}
{hint ? (
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
<p
className={cn(
"mt-2 text-[11px] leading-snug",
href ? "text-primary/80 group-hover/kpi:underline" : "text-muted-foreground",
)}
>
{hint}
</p>
) : null}
</div>
);
if (!href) {
return card;
}
return (
<Link
href={href}
className="group/kpi block h-full min-h-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{card}
</Link>
);
}
export type DashboardQuickLink = {
href: string;
label: string;
icon?: LucideIcon;
};
/** 仪表盘快捷入口:按钮或图标卡片两种布局 */
export function DashboardQuickLinksCard({
title,
links,
variant = "buttons",
}: {
title: string;
links: readonly DashboardQuickLink[];
variant?: "buttons" | "tiles";
}): ReactElement {
const { t } = useTranslation("dashboard");
if (links.length === 0) {
return <></>;
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent
className={cn(
variant === "buttons" ? "flex flex-wrap gap-2" : "grid gap-3 sm:grid-cols-3",
)}
>
{links.map((link) => {
if (variant === "buttons") {
return (
<Link
key={link.href}
href={link.href}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground",
)}
>
{link.label}
</Link>
);
}
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className="flex flex-col gap-2 rounded-xl border bg-muted/20 px-4 py-4 transition-colors hover:bg-muted/40"
>
{Icon ? <Icon className="size-5 text-primary" aria-hidden /> : null}
<span className="text-sm font-medium">{link.label}</span>
<span className="text-xs text-muted-foreground">{t("cs.openModule")}</span>
</Link>
);
})}
</CardContent>
</Card>
);
}
/** 轻量报表入口:替代站点/代理仪表盘内嵌完整 analytics 面板 */
export function DashboardReportsTeaser({
title,
description,
href = "/admin/reports",
}: {
title: string;
description: string;
href?: string;
}): ReactElement {
const { t } = useTranslation("dashboard");
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0 pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<Link
href={href}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{t("viewReports")}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</CardHeader>
<CardContent>
<p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function MiniSparkline({

View File

@@ -14,10 +14,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import {
DashboardKpiCard,
DashboardScopeMetric,
} from "@/modules/dashboard/dashboard-visuals";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import type {
@@ -136,18 +133,21 @@ export function SiteCsDashboardConsole(): ReactElement {
value={overview.player_count}
icon={<Users className="size-4" />}
hint={t("cs.playerCountHint")}
href="/admin/players"
/>
<DashboardKpiCard
label={t("cs.ticketsToday")}
value={overview.ticket_order_count_today}
icon={<ClipboardList className="size-4" />}
hint={activityHint}
href="/admin/tickets"
/>
<DashboardKpiCard
label={t("cs.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Search className="size-4" />}
hint={t("cs.activePlayersHint")}
href="/admin/tickets"
/>
</div>
@@ -172,19 +172,6 @@ export function SiteCsDashboardConsole(): ReactElement {
})}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("cs.scopeTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<DashboardScopeMetric label={t("cs.playerCount")} value={String(overview.player_count)} />
<DashboardScopeMetric
label={t("cs.ticketsToday")}
value={String(overview.ticket_order_count_today)}
/>
</CardContent>
</Card>
</section>
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">

View File

@@ -2,15 +2,22 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
import {
BarChart3,
ClipboardList,
Network,
RefreshCw,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
@@ -20,19 +27,19 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import {
DashboardKpiCard,
DashboardQuickLinksCard,
DashboardReportsTeaser,
DashboardScopeMetric,
DashboardSignedStatRow,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
import {
formatDashboardMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardSiteOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
function buildTodayBetHint(
@@ -65,26 +72,32 @@ export function SiteDashboardConsole(): ReactElement {
return formatAdminCalendarToday(locale, weekday);
}, [i18n.language, i18n.resolvedLanguage, t]);
const playOptions = useCachedPlayTypeOptions();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
const analyticsScope = useMemo(
() => ({
siteCode: site?.code ?? overview?.site_code ?? "",
agentNodeId: undefined,
}),
[overview?.site_code, site?.code],
);
const canReports = adminHasAnyPermission(permissions, [PRD_REPORT_VIEW]);
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
const quickLinks = useMemo(
() => [
{ href: "/admin/tickets", label: t("site.quickLinks.tickets"), icon: ClipboardList },
{ href: "/admin/players", label: t("site.quickLinks.players"), icon: Users },
{ href: "/admin/agents", label: t("site.quickLinks.agents"), icon: Network },
{
href: settlementCenterScopeHref(site?.id, "awaiting_payment"),
label: t("site.quickLinks.bills"),
icon: Wallet,
},
...(canReports
? [{ href: "/admin/reports", label: t("site.quickLinks.reports"), icon: BarChart3 }]
: []),
],
[canReports, site?.id, t],
);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
@@ -95,14 +108,8 @@ export function SiteDashboardConsole(): ReactElement {
try {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.site_overview);
setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
setDrawId(null);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
@@ -171,6 +178,7 @@ export function SiteDashboardConsole(): ReactElement {
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
icon={<TrendingUp className="size-4" />}
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
href="/admin/tickets"
/>
<DashboardKpiCard
label={t("site.todayProfit")}
@@ -180,12 +188,14 @@ export function SiteDashboardConsole(): ReactElement {
hint={t("todayPayoutHint", {
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
})}
href={canReports ? "/admin/reports" : undefined}
/>
<DashboardKpiCard
label={t("site.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Users className="size-4" />}
hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })}
href="/admin/players"
/>
<DashboardKpiCard
label={t("site.pendingBills")}
@@ -195,6 +205,7 @@ export function SiteDashboardConsole(): ReactElement {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
href={settlementCenterScopeHref(site?.id, "awaiting_payment")}
/>
</div>
@@ -245,6 +256,8 @@ export function SiteDashboardConsole(): ReactElement {
</CardContent>
</Card>
</div>
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
</section>
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
@@ -252,18 +265,10 @@ export function SiteDashboardConsole(): ReactElement {
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
{canAnalytics ? (
<DashboardAnalyticsPanel
enabled={canAnalytics}
playOptions={playOptions}
scope={analyticsScope}
<DashboardReportsTeaser
title={t("analytics.title")}
description={t("site.profitScopeHint")}
/>
) : null}
</div>

View File

@@ -13,13 +13,12 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import {
AbnormalTransferPanelFooter,
DashboardKpiCard,
DashboardScopeMetric,
DashboardStatRow,
DashboardQuickLinksCard,
} from "@/modules/dashboard/dashboard-visuals";
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
@@ -27,7 +26,6 @@ import type {
AdminDashboardSiteFinanceOverview,
AdminDashboardWarning,
} from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
export function SiteFinanceDashboardConsole(): ReactElement {
@@ -40,8 +38,6 @@ export function SiteFinanceDashboardConsole(): ReactElement {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardSiteFinanceOverview | null>(null);
const [walletPermission, setWalletPermission] = useState(false);
@@ -55,15 +51,9 @@ export function SiteFinanceDashboardConsole(): ReactElement {
try {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.site_finance_overview);
setApiWarnings(d.warnings ?? []);
setWalletPermission(d.capabilities?.wallet_transfer_view ?? false);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
setDrawId(null);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
@@ -80,15 +70,19 @@ export function SiteFinanceDashboardConsole(): ReactElement {
const displayCurrency = overview?.currency_code ?? "NPR";
const abnormalCount = overview?.abnormal_transfer_count ?? null;
const abnormalHref = "/admin/wallet/transfer-orders?abnormal=1";
const quickLinks = useMemo(
() => [
{ href: "/admin/reconcile", label: t("finance.quickLinks.reconcile") },
{ href: "/admin/wallet/transfer-orders", label: t("finance.quickLinks.transfers") },
{ href: "/admin/settlement-center", label: t("finance.quickLinks.bills") },
{
href: settlementCenterScopeHref(site?.id, "awaiting_payment"),
label: t("finance.quickLinks.bills"),
},
{ href: "/admin/reports", label: t("finance.quickLinks.reports") },
],
[t],
[site?.id, t],
);
return (
@@ -144,6 +138,7 @@ export function SiteFinanceDashboardConsole(): ReactElement {
icon={<AlertTriangle className="size-4" />}
hint={t("abnormalTransferScope")}
accent={(abnormalCount ?? 0) > 0 ? "destructive" : "muted"}
href={abnormalHref}
/>
<DashboardKpiCard
label={t("finance.pendingConfirmBills")}
@@ -151,6 +146,7 @@ export function SiteFinanceDashboardConsole(): ReactElement {
icon={<ClipboardList className="size-4" />}
hint={t("finance.pendingConfirmHint")}
accent={overview.pending_confirm_bill_count > 0 ? "primary" : "muted"}
href={settlementCenterScopeHref(site?.id, "pending_confirm")}
/>
<DashboardKpiCard
label={t("finance.payableBills")}
@@ -160,85 +156,43 @@ export function SiteFinanceDashboardConsole(): ReactElement {
amount: formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency),
})}
accent={overview.payable_bill_count > 0 ? "destructive" : "muted"}
href={settlementCenterScopeHref(site?.id, "awaiting_payment")}
/>
<DashboardKpiCard
label={t("finance.walletPlayers")}
value={overview.wallet_player_count}
icon={<Users className="size-4" />}
hint={t("finance.creditPlayersHint", { count: overview.credit_player_count })}
href="/admin/players"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("finance.settlementTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<DashboardStatRow
label={t("finance.pendingConfirmBills")}
value={String(overview.pending_confirm_bill_count)}
/>
<DashboardStatRow
label={t("finance.payableBills")}
value={String(overview.payable_bill_count)}
/>
<DashboardStatRow
label={t("finance.payableUnpaidLabel")}
value={formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency)}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("finance.reconcileTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<AbnormalTransferPanelFooter
total={abnormalCount}
walletPermission={walletPermission}
/>
<Link
href="/admin/wallet/transfer-orders?abnormal=1"
className={buttonVariants({ variant: "outline", size: "sm", className: "w-full" })}
>
<Wallet className="size-3.5" />
{t("viewTransferOrders")}
</Link>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
<CardTitle className="text-sm font-semibold">{t("finance.reconcileTitle")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{quickLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
{link.label}
</Link>
))}
<CardContent className="space-y-3">
<AbnormalTransferPanelFooter
total={abnormalCount}
walletPermission={walletPermission}
/>
<Link
href={abnormalHref}
className={buttonVariants({ variant: "outline", size: "sm", className: "w-full" })}
>
<Wallet className="size-3.5" />
{t("viewTransferOrders")}
</Link>
</CardContent>
</Card>
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
</section>
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
{t("finance.overviewEmpty")}
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
</div>
);
}
}

View File

@@ -4,11 +4,9 @@ import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
getAdminDraw,
getAdminDrawFinanceSummary,
postAdminCancelDraw,
postAdminManualCloseDraw,
@@ -17,23 +15,20 @@ import {
} from "@/api/admin-draws";
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { canManageDrawResults } from "@/lib/draw-access";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
import { DrawStatusBadge } from "./draw-status-badge";
import {
PRD_DRAW_REOPEN_MANAGE,
PRD_PAYOUT_MANAGE,
@@ -50,28 +45,20 @@ function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
const formatDt = useAdminDateTimeFormatter();
return (
<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>
<ol className="grid gap-2 sm:grid-cols-3">
{steps.map((step) => (
<li key={step.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-xs text-muted-foreground">{step.label}</p>
<p className="mt-0.5 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
</li>
))}
</ol>
);
}
export function DrawDetailConsole({ drawId }: { drawId: string }) {
export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const idNum = Number(drawId);
const { draw: data, loading, error, refresh, drawId: idNum } = useDrawDetail();
const profile = useAdminProfile();
const canManageDraw = canManageDrawResults(profile?.permissions);
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
@@ -79,41 +66,29 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
]);
const [data, setData] = useState<AdminDrawShowData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [acting, setActing] = useState<string | null>(null);
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError(tRef.current("invalidDrawId"));
setLoading(false);
const loadFinance = useCallback(async () => {
if (!Number.isFinite(idNum) || !data) {
setFinanceSummary(null);
return;
}
setLoading(true);
setError(null);
try {
const draw = await getAdminDraw(idNum);
setData(draw);
if (draw.capabilities?.can_view_draw_finance !== false) {
try {
setFinanceSummary(await getAdminDrawFinanceSummary(idNum));
} catch {
setFinanceSummary(null);
}
} else {
setFinanceSummary(null);
}
} catch (e) {
setData(null);
if (data.capabilities?.can_view_draw_finance === false) {
setFinanceSummary(null);
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
return;
}
}, [idNum, tRef]);
try {
setFinanceSummary(await getAdminDrawFinanceSummary(idNum));
} catch {
setFinanceSummary(null);
}
}, [data, idNum]);
useAsyncEffect(() => {
void loadFinance();
}, [loadFinance]);
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
if (!Number.isFinite(idNum)) return;
@@ -121,7 +96,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
try {
await action();
toast.success(t("actionSuccess", { name }));
await load();
await refresh();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name }));
} finally {
@@ -129,10 +104,6 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
}
}
useAsyncEffect(() => {
void load();
}, [idNum]);
const scheduleSteps = useMemo((): ScheduleStep[] => {
if (!data) return [];
const steps: ScheduleStep[] = [
@@ -241,182 +212,116 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const pendingReview = batch.pending_review ?? 0;
const totalBatches = batch.total ?? batch.published;
const financeCurrency = financeSummary?.currency_code ?? "NPR";
const hasResultActivity =
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
const showActions =
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
const hasResultActivity = totalBatches > 0 || pendingReview > 0 || batch.published > 0;
return (
<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">
<div className="mb-3">
<Link
href="/admin/draws"
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{t("backToList")}
</Link>
</div>
<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-wrap items-center gap-2">
<DrawStatusBadge
status={data.status}
label={drawStatusLabel(data.status, t)}
/>
{hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? (
<>
<span className="text-xs text-muted-foreground">{t("hallPreviewStatusLabel")}</span>
<DrawStatusBadge
status={data.hall_preview_status}
label={drawStatusLabel(data.hall_preview_status, t)}
/>
</>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 border-t pt-6">
<section className="space-y-3">
<h3 className="text-sm font-medium">{t("overviewTitle")}</h3>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewBetTotal")}</p>
<p className="mt-1 font-mono text-sm tabular-nums">
{formatAdminMinorUnits(
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
financeCurrency,
)}
</p>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewPayoutTotal")}</p>
<p className="mt-1 font-mono text-sm tabular-nums">
{formatAdminMinorUnits(
financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0,
financeCurrency,
)}
</p>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p>
<p
className={cn(
"mt-1 font-mono text-sm tabular-nums",
signedMoneyClass(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
true,
),
)}
>
{formatAdminMinorUnits(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
financeCurrency,
)}
</p>
</div>
</div>
</section>
<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">
{canManageDraw ? (
<span className="rounded-md bg-muted px-2.5 py-1">
{t("batchSummaryTotal", { count: totalBatches })}
</span>
) : null}
{canManageDraw ? (
pendingReview > 0 ? (
<Link
href={`/admin/draws/${drawId}/review`}
className="rounded-md bg-amber-500/15 px-2.5 py-1 font-medium text-amber-800 dark:text-amber-200"
>
{t("batchSummaryPending", { count: pendingReview })}
</Link>
) : (
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
{t("batchSummaryPending", { count: 0 })}
</span>
)
) : null}
{batch.published > 0 ? (
<Link
href={`/admin/draws/${drawId}/results`}
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>
) : (
<p className="text-sm text-muted-foreground">
{t("noResultBatchesYet")}
<span className="ml-1">{t("reviewQueueHint")}</span>
{canManageDraw ? (
<>
{" "}
<Link
href={`/admin/draws/${drawId}/review`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{t("goToReviewTab")}
</Link>
</>
) : null}
</p>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border border-border/60 px-3 py-2.5">
<p className="text-xs text-muted-foreground">{t("overviewBetTotal")}</p>
<p className="mt-1 font-mono text-sm tabular-nums">
{formatAdminMinorUnits(
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
financeCurrency,
)}
</section>
</p>
</div>
<div className="rounded-lg border border-border/60 px-3 py-2.5">
<p className="text-xs text-muted-foreground">{t("overviewPayoutTotal")}</p>
<p className="mt-1 font-mono text-sm tabular-nums">
{formatAdminMinorUnits(
financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0,
financeCurrency,
)}
</p>
</div>
<div className="rounded-lg border border-border/60 px-3 py-2.5">
<p className="text-xs text-muted-foreground">{t("overviewProfitLoss")}</p>
<p
className={cn(
"mt-1 font-mono text-sm tabular-nums",
signedMoneyClass(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
true,
),
)}
>
{formatAdminMinorUnits(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
financeCurrency,
)}
</p>
</div>
</div>
{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>
</section>
<ScheduleTimeline steps={scheduleSteps} />
{hasResultActivity ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
{canManageDraw && totalBatches > 0 ? (
<span className="rounded-md bg-muted px-2 py-1 tabular-nums">
{t("batchSummaryTotal", { count: totalBatches })}
</span>
) : null}
</CardContent>
</Card>
{canManageDraw && pendingReview > 0 ? (
<Link
href={`/admin/draws/${drawId}/review`}
className="rounded-md bg-amber-500/15 px-2 py-1 font-medium text-amber-800 dark:text-amber-200"
>
{t("batchSummaryPending", { count: pendingReview })}
</Link>
) : null}
{batch.published > 0 ? (
<Link
href={`/admin/draws/${drawId}/results`}
className="rounded-md bg-emerald-500/15 px-2 py-1 font-medium text-emerald-800 dark:text-emerald-200"
>
{t("batchSummaryPublished", { count: batch.published })}
</Link>
) : null}
{data.capabilities?.can_view_draw_finance !== false ? (
<Link
href={`/admin/draws/${drawId}/finance`}
className="text-sm font-medium text-primary hover:underline"
>
{t("viewFinance")}
</Link>
) : null}
</div>
) : canManageDraw ? (
<Link
href={`/admin/draws/${drawId}/review`}
className="text-sm font-medium text-primary hover:underline"
>
{t("goToReviewTab")}
</Link>
) : null}
{availableActions.length > 0 ? (
<div className="flex flex-wrap gap-2 border-t border-border/60 pt-4">
{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>
) : null}
<ConfirmDialog />
</div>
);
}
}

View File

@@ -0,0 +1,79 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { getAdminDraw } from "@/api/admin-draws";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
type DrawDetailContextValue = {
drawId: number;
draw: AdminDrawShowData | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
};
const DrawDetailContext = createContext<DrawDetailContextValue | null>(null);
export function DrawDetailProvider({
drawId,
children,
}: {
drawId: string;
children: ReactNode;
}): React.ReactElement {
const tRef = useTranslationRef(["draws", "common"]);
const idNum = Number(drawId);
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError(tRef.current("invalidDrawId"));
setDraw(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setDraw(await getAdminDraw(idNum));
} catch (e) {
setDraw(null);
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum, tRef]);
useAsyncEffect(() => {
void refresh();
}, [refresh]);
const value = useMemo(
() => ({ drawId: idNum, draw, loading, error, refresh }),
[draw, error, idNum, loading, refresh],
);
return <DrawDetailContext.Provider value={value}>{children}</DrawDetailContext.Provider>;
}
export function useDrawDetail(): DrawDetailContextValue {
const ctx = useContext(DrawDetailContext);
if (ctx == null) {
throw new Error("useDrawDetail must be used within DrawDetailProvider");
}
return ctx;
}

View File

@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display";
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
export function DrawDetailHeader(): React.ReactElement {
const { t } = useTranslation("draws");
const { draw, loading, error } = useDrawDetail();
if (error) {
return <p className="mb-4 text-sm text-destructive">{error}</p>;
}
if (loading && !draw) {
return <AdminLoadingInline className="mb-4 py-2" />;
}
if (!draw) {
return <div className="mb-4" />;
}
return (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<Link
href="/admin/draws"
className="shrink-0 text-sm text-muted-foreground hover:text-foreground hover:underline"
>
{t("backToList")}
</Link>
<h1 className="font-mono text-lg font-semibold tracking-tight">{draw.draw_no}</h1>
<div className="flex flex-wrap items-center gap-2">
<DrawStatusBadge status={draw.status} label={drawStatusLabel(draw.status, t)} />
{hallPreviewDiffersFromDbStatus(draw.status, draw.hall_preview_status) ? (
<DrawStatusBadge
status={draw.hall_preview_status}
label={drawStatusLabel(draw.hall_preview_status, t)}
/>
) : null}
</div>
</div>
<p className="text-sm text-muted-foreground">
{t("detailSubtitle", { date: draw.business_date, seq: draw.sequence_no })}
</p>
</div>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import type { ReactNode } from "react";
import { DrawDetailHeader } from "@/modules/draws/draw-detail-header";
import { DrawDetailProvider } from "@/modules/draws/draw-detail-context";
import { DrawSubnav } from "@/modules/draws/draw-subnav";
export function DrawDetailShell({
drawId,
children,
}: {
drawId: string;
children: ReactNode;
}): React.ReactElement {
return (
<DrawDetailProvider drawId={drawId}>
<DrawDetailHeader />
<DrawSubnav drawId={drawId} />
{children}
</DrawDetailProvider>
);
}

View File

@@ -11,7 +11,7 @@ import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
import { Button, buttonVariants } from "@/components/ui/button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
@@ -37,7 +37,7 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { formatAdminMinorUnits } from "@/lib/money";
import { drawStatusLabel, settlementBatchStatusLabel } from "./draw-display";
import { settlementBatchStatusLabel } from "./draw-display";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
@@ -109,44 +109,25 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">{t("financeOverview")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<span className="text-muted-foreground">{t("drawNo")}</span>
<p className="font-mono font-semibold">{data.draw_no}</p>
</div>
<div>
<span className="text-muted-foreground">{t("status")}</span>
<p className="mt-1">
<DrawStatusBadge status={data.draw_status} label={drawStatusLabel(data.draw_status, t)} />
</p>
</div>
<div>
<span className="text-muted-foreground">{t("orderAndItemCount")}</span>
<p className="tabular-nums">
{data.order_count} / {data.ticket_item_count}
</p>
</div>
<div>
<span className="text-muted-foreground">{t("actualBet")}</span>
<p className="tabular-nums font-medium">{formatMoney(data.total_bet_minor)}</p>
</div>
<div>
<span className="text-muted-foreground">{t("currentPayout")}</span>
<p className="tabular-nums font-medium">{formatMoney(data.total_payout_minor)}</p>
</div>
<div>
<span className="text-muted-foreground">{t("grossProfit")}</span>
<p className={cn("tabular-nums font-semibold", signedMoneyClass(data.approx_house_gross_minor, true))}>
{formatMoney(data.approx_house_gross_minor)}
</p>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<div className="grid gap-3 text-sm sm:grid-cols-3">
<div className="rounded-lg border border-border/60 px-3 py-2">
<p className="text-xs text-muted-foreground">{t("orderAndItemCount")}</p>
<p className="mt-0.5 tabular-nums font-medium">
{data.order_count} / {data.ticket_item_count}
</p>
</div>
<div className="rounded-lg border border-border/60 px-3 py-2">
<p className="text-xs text-muted-foreground">{t("actualBet")}</p>
<p className="mt-0.5 tabular-nums font-medium">{formatMoney(data.total_bet_minor)}</p>
</div>
<div className="rounded-lg border border-border/60 px-3 py-2">
<p className="text-xs text-muted-foreground">{t("grossProfit")}</p>
<p className={cn("mt-0.5 tabular-nums font-semibold", signedMoneyClass(data.approx_house_gross_minor, true))}>
{formatMoney(data.approx_house_gross_minor)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
@@ -174,11 +155,11 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("relatedSettlementBatches")}</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg border border-border/60">
<div className="border-b border-border/60 px-3 py-2.5">
<h2 className="text-sm font-semibold">{t("relatedSettlementBatches")}</h2>
</div>
<div className="p-3">
{data.settlement_batches.length === 0 ? (
<AdminNoResourceState className="py-4" />
) : (
@@ -232,8 +213,8 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
<ConfirmDialog />
</div>
);

View File

@@ -1,57 +1,30 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
deleteAdminPendingResultBatch,
getAdminDrawResultBatches,
postAdminPublishResultBatch,
} from "@/api/admin-draws";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { cn } from "@/lib/utils";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawBatchStatusLabel, drawPrizeTypeLabel, drawStatusLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
/** 深链兼容:/publish/[batchId] 直接打开发布弹窗 */
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const router = useRouter();
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
]);
const { refresh: refreshDraw } = useDrawDetail();
const idNum = Number(drawId);
const batchNum = Number(batchId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [publishing, setPublishing] = useState(false);
const [discarding, setDiscarding] = useState(false);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
@@ -69,53 +42,17 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, tRef]);
useAsyncEffect(() => {
void load();
}, [idNum]);
}, [load]);
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
const batch = useMemo(() => {
if (!Number.isFinite(batchNum)) return undefined;
return data?.batches.find((b) => b.id === batchNum);
}, [batchNum, data]);
async function discardBatch(): Promise<void> {
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
setDiscarding(true);
try {
await deleteAdminPendingResultBatch(idNum, batchNum);
toast.success(t("discardPendingBatchSuccess"));
router.replace(`/admin/draws/${drawId}/review`);
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
);
} finally {
setDiscarding(false);
}
}
async function publish(): Promise<void> {
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
setPublishing(true);
try {
const res = await postAdminPublishResultBatch(idNum, batchNum);
toast.success(
t("publishSuccess", {
drawNo: res.draw_no,
status: drawStatusLabel(res.status, t),
}),
);
await load();
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("publishFailed");
toast.error(msg);
} finally {
setPublishing(false);
}
}
if (loading && !data) {
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
@@ -123,131 +60,29 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
if (error) {
return <p className="text-sm text-destructive">{error}</p>;
}
if (!data) {
return <AdminNoResourceState />;
}
if (!batch) {
return (
<Alert variant="destructive">
<AlertTitle>{t("batchNotFound")}</AlertTitle>
<AlertDescription>{t("batchNotFoundDesc")}</AlertDescription>
</Alert>
);
return <AdminNoResourceState message={t("batchNotFound")} />;
}
const canPublish =
canManageDraw && batch.status === "pending_review";
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Link href={`/admin/draws/${drawId}/review`} className={buttonVariants({ variant: "ghost", size: "sm" })}>
{t("backToReviewQueue")}
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t("publishTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!canManageDraw ? (
<Alert variant="destructive">
<AlertTitle>{t("noPublishPermission")}</AlertTitle>
<AlertDescription>{t("noPublishPermission")}</AlertDescription>
</Alert>
) : null}
{!canPublish && canManageDraw ? (
<Alert>
<AlertTitle>{t("cannotPublish")}</AlertTitle>
<AlertDescription>
{t("cannotPublishDesc", { status: drawBatchStatusLabel(batch.status, t) })}
</AlertDescription>
</Alert>
) : null}
{canPublish ? (
<>
<Alert>
<AlertTitle>{t("checkBeforePublish")}</AlertTitle>
<AlertDescription>{t("checkBeforePublishDesc")}</AlertDescription>
</Alert>
<p className="text-sm text-muted-foreground">{t("publishReadOnlyHint")}</p>
</>
) : null}
<div className="overflow-x-auto rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("prize")}</TableHead>
<TableHead>#</TableHead>
<TableHead className="font-mono">4D</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batch.items.map((it) => (
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
<TableCell className="text-xs">
{drawPrizeTypeLabel(it.prize_type, it.prize_index, t)}
</TableCell>
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell>
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className="font-mono text-xs text-muted-foreground">
{t("sourceTypeFull", {
source: batch.source_type === "manual" ? t("manualEntry") : t("rngAutoGenerate"),
count: batch.items.length,
hash: batch.rng_seed_hash ?? "—",
})}
</p>
</CardContent>
<CardFooter className="flex flex-wrap justify-end gap-2">
<Link
href={`/admin/draws/${drawId}/results`}
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
>
{t("publishedView")}
</Link>
{canPublish ? (
<Button
type="button"
variant="outline"
disabled={publishing || discarding}
onClick={() =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardBatch(),
})
}
>
{discarding ? t("discardingPendingBatch") : t("discardPendingBatch")}
</Button>
) : null}
<Button
type="button"
disabled={!canPublish || publishing || discarding}
onClick={() =>
requestConfirm({
title: t("confirm.publishTitle"),
description: t("confirm.publishDescription"),
confirmVariant: "destructive",
onConfirm: () => publish(),
})
}
>
{publishing ? t("submitting") : t("confirmPublish")}
</Button>
</CardFooter>
</Card>
<ConfirmDialog />
</div>
<DrawPublishDialog
open
onOpenChange={(open) => {
if (!open) {
router.replace(`/admin/draws/${drawId}/review`);
}
}}
drawId={idNum}
batch={batch}
onPublished={() => {
void refreshDraw();
router.replace(`/admin/draws/${drawId}/results`);
}}
onDiscarded={() => {
void refreshDraw();
router.replace(`/admin/draws/${drawId}/review`);
}}
/>
);
}
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DrawPublishPanel } from "@/modules/draws/draw-publish-panel";
import type { AdminDrawBatchRow } from "@/types/api/admin-draws";
type DrawPublishDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
drawId: number;
batch: AdminDrawBatchRow | null;
onPublished: () => void;
onDiscarded: () => void;
};
export function DrawPublishDialog({
open,
onOpenChange,
drawId,
batch,
onPublished,
onDiscarded,
}: DrawPublishDialogProps): React.ReactElement {
const { t } = useTranslation("draws");
return (
<Dialog open={open && batch != null} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(90vh,40rem)] w-full max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-lg">
<DialogHeader className="border-b border-border/60 px-5 py-4">
<DialogTitle className="text-base">
{t("publishTitle")}
{batch ? ` · v${batch.result_version}` : ""}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{batch ? (
<DrawPublishPanel
drawId={drawId}
batch={batch}
onPublished={() => {
onPublished();
onOpenChange(false);
}}
onDiscarded={() => {
onDiscarded();
onOpenChange(false);
}}
/>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
deleteAdminPendingResultBatch,
postAdminPublishResultBatch,
} from "@/api/admin-draws";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow } from "@/types/api/admin-draws";
import { drawPrizeTypeLabel, drawStatusLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
type DrawPublishPanelProps = {
drawId: number;
batch: AdminDrawBatchRow;
onPublished: () => void;
onDiscarded: () => void;
};
export function DrawPublishPanel({
drawId,
batch,
onPublished,
onDiscarded,
}: DrawPublishPanelProps): React.ReactElement {
const { t } = useTranslation(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const [publishing, setPublishing] = useState(false);
const [discarding, setDiscarding] = useState(false);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const canPublish = canManageDraw && batch.status === "pending_review";
async function discardBatch(): Promise<void> {
setDiscarding(true);
try {
await deleteAdminPendingResultBatch(drawId, batch.id);
toast.success(t("discardPendingBatchSuccess"));
onDiscarded();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"));
} finally {
setDiscarding(false);
}
}
async function publish(): Promise<void> {
setPublishing(true);
try {
const res = await postAdminPublishResultBatch(drawId, batch.id);
toast.success(
t("publishSuccess", {
drawNo: res.draw_no,
status: drawStatusLabel(res.status, t),
}),
);
onPublished();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("publishFailed"));
} finally {
setPublishing(false);
}
}
return (
<div className="space-y-4">
<div className="admin-table-shell overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("prize")}</TableHead>
<TableHead>#</TableHead>
<TableHead className="font-mono">4D</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batch.items.map((it) => (
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
<TableCell className="text-xs">
{drawPrizeTypeLabel(it.prize_type, it.prize_index, t)}
</TableCell>
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell>
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{canPublish ? (
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={publishing || discarding}
onClick={() =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardBatch(),
})
}
>
{discarding ? t("discardingPendingBatch") : t("discardPendingBatch")}
</Button>
<Button
type="button"
size="sm"
disabled={publishing || discarding}
onClick={() =>
requestConfirm({
title: t("confirm.publishTitle"),
description: t("confirm.publishDescription"),
confirmVariant: "destructive",
onConfirm: () => publish(),
})
}
>
{publishing ? t("submitting") : t("confirmPublish")}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">{t("noPublishPermission")}</p>
)}
<ConfirmDialog />
</div>
);
}

View File

@@ -18,19 +18,14 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { canManageDrawResults } from "@/lib/draw-access";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawPrizeTypeLabel } from "./draw-display";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawResultsConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = canManageDrawResults(profile?.permissions);
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -72,55 +67,28 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
const published = data.batches.filter((b) => b.status === "published");
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">{t("resultsTitle")}</h2>
<p className="text-sm text-muted-foreground">
{t("drawNo")} {data.draw_no} · <DrawStatusBadge status={data.draw_status} />
</p>
</div>
<div className="space-y-4">
{published.length === 0 ? (
<Card>
<CardContent className="py-4">
<AdminNoResourceState />
</CardContent>
</Card>
<AdminNoResourceState message={t("noPublishedBatch")} />
) : (
published.map((batch) => (
<BatchTable key={batch.id} batch={batch} showOperationalMeta={canManageDraw} />
<BatchTable key={batch.id} batch={batch} />
))
)}
</div>
);
}
function BatchTable({
batch,
showOperationalMeta,
}: {
batch: AdminDrawBatchRow;
showOperationalMeta: boolean;
}) {
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
const { t } = useTranslation("draws");
const formatDt = useAdminDateTimeFormatter();
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("version", { version: batch.result_version })}</CardTitle>
{showOperationalMeta ? (
<p className="font-mono text-xs text-muted-foreground">
{t("sourceType", {
source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
})}{" "}
· {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
</p>
)}
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm">{t("version", { version: batch.result_version })}</CardTitle>
<p className="font-mono text-xs text-muted-foreground">
{formatDt(batch.confirmed_at)}
</p>
</CardHeader>
<CardContent className="overflow-x-auto pt-0">
<Table>

View File

@@ -1,6 +1,6 @@
"use client";
import { Dices, Rocket, Trash2 } from "lucide-react";
import { ChevronDown, ChevronRight, Dices, Rocket, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -14,10 +14,9 @@ import {
} from "@/api/admin-draws";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -28,13 +27,14 @@ import {
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
import { drawStatusLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
const RESULT_SLOTS = [
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" },
@@ -58,23 +58,22 @@ function randomDrawNumber4d(): string {
return String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
}
export function DrawReviewConsole({ drawId }: { drawId: string }) {
export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
]);
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const { refresh: refreshDraw } = useDrawDetail();
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [savingManual, setSavingManual] = useState(false);
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null);
const [manualOpen, setManualOpen] = useState(false);
const [publishBatch, setPublishBatch] = useState<AdminDrawBatchRow | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [manualNumbers, setManualNumbers] = useState<string[]>(
() => RESULT_SLOTS.map(() => ""),
);
const [manualNumbers, setManualNumbers] = useState<string[]>(() => RESULT_SLOTS.map(() => ""));
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
@@ -92,15 +91,13 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, tRef]);
useAsyncEffect(() => {
void load();
}, [idNum]);
}, [load]);
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
data,
]);
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [data]);
function fillRandomManualNumbers(): void {
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d()));
@@ -113,10 +110,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
await deleteAdminPendingResultBatch(idNum, batchId);
toast.success(t("discardPendingBatchSuccess"));
await load();
await refreshDraw();
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
);
toast.error(e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"));
} finally {
setDiscardingBatchId(null);
}
@@ -141,7 +137,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
});
toast.success(t("draftSaved", { version: res.batch.result_version }));
setManualNumbers(RESULT_SLOTS.map(() => ""));
setManualOpen(false);
await load();
await refreshDraw();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
@@ -149,6 +147,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
}
}
async function onPublishFlowDone(): Promise<void> {
await load();
await refreshDraw();
}
if (loading && !data) {
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
@@ -161,138 +164,160 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t("currentStatusLabel")}</span>
<DrawStatusBadge
status={data.draw_status}
label={drawStatusLabel(data.draw_status, t)}
/>
<span>· {t("currentStatusDraftHint")}</span>
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{RESULT_SLOTS.map((slot, i) => (
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
</span>
<Input
inputMode="numeric"
maxLength={4}
value={manualNumbers[i]}
disabled={!canManageDraw || savingManual}
placeholder="0000"
className="font-mono"
onChange={(e) => {
const next = e.target.value.replace(/\D/g, "").slice(0, 4);
setManualNumbers((old) => old.map((v, idx) => (idx === i ? next : v)));
}}
/>
</label>
))}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={!canManageDraw || savingManual}
onClick={fillRandomManualNumbers}
>
<Dices className="size-4" aria-hidden />
{t("fillRandomNumbers")}
</Button>
<Button
type="button"
variant="outline"
disabled={savingManual}
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
>
{t("clear")}
</Button>
<Button
type="button"
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
onClick={() =>
requestConfirm({
title: t("confirm.saveManualDraftTitle"),
description: t("confirm.saveManualDraftDescription"),
onConfirm: () => saveManualDraft(),
})
}
>
{savingManual ? t("saving") : t("saveDraft")}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t("pendingBatches")}</CardTitle>
</CardHeader>
<CardContent>
{pending.length === 0 ? (
<AdminNoResourceState className="py-6" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("batchId")}</TableHead>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageDraw ? (
<AdminRowActionsMenu
busy={discardingBatchId === b.id}
actions={[
{
key: "publish",
label: t("reviewAndPublishAction"),
icon: Rocket,
href: `/admin/draws/${drawId}/publish/${b.id}`,
},
{
key: "discard",
label: t("discardPendingBatch"),
icon: Trash2,
destructive: true,
disabled: discardingBatchId !== null,
onClick: () =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardPendingBatch(b.id),
}),
},
]}
/>
) : (
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span>
)}
</TableCell>
<div className="space-y-4">
<div className="rounded-lg border border-border/60">
<div className="border-b border-border/60 px-3 py-2.5">
<h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
</div>
<div className="p-3">
{pending.length === 0 ? (
<AdminNoResourceState className="py-6" message={t("noPendingBatches")} />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</TableHead>
<TableHead className="w-14 text-center">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TableHeader>
<TableBody>
{pending.map((b) => (
<TableRow key={b.id}>
<TableCell>v{b.result_version}</TableCell>
<TableCell className="tabular-nums">{b.items.length}</TableCell>
<TableCell className="text-center">
{canManageDraw ? (
<AdminRowActionsMenu
busy={discardingBatchId === b.id}
actions={[
{
key: "publish",
label: t("reviewAndPublishAction"),
icon: Rocket,
onClick: () => setPublishBatch(b),
},
{
key: "discard",
label: t("discardPendingBatch"),
icon: Trash2,
destructive: true,
disabled: discardingBatchId !== null,
onClick: () =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardPendingBatch(b.id),
}),
},
]}
/>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{canManageDraw ? (
<div className="rounded-lg border border-border/60">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold"
onClick={() => setManualOpen((open) => !open)}
>
{t("manualResultEntry")}
{manualOpen ? (
<ChevronDown className="size-4 text-muted-foreground" />
) : (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</button>
{manualOpen ? (
<div className="space-y-3 border-t border-border/60 px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{RESULT_SLOTS.map((slot, i) => (
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1">
<span className="text-xs text-muted-foreground">
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
</span>
<Input
inputMode="numeric"
maxLength={4}
value={manualNumbers[i]}
disabled={savingManual}
placeholder="0000"
className="h-8 font-mono"
onChange={(e) => {
const next = e.target.value.replace(/\D/g, "").slice(0, 4);
setManualNumbers((old) => old.map((v, idx) => (idx === i ? next : v)));
}}
/>
</label>
))}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={savingManual}
onClick={fillRandomManualNumbers}
>
<Dices className="size-3.5" aria-hidden />
{t("fillRandomNumbers")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={savingManual}
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
>
{t("clear")}
</Button>
<Button
type="button"
size="sm"
className="h-8"
disabled={savingManual || !["closed", "review"].includes(data.draw_status)}
onClick={() =>
requestConfirm({
title: t("confirm.saveManualDraftTitle"),
description: t("confirm.saveManualDraftDescription"),
onConfirm: () => saveManualDraft(),
})
}
>
{savingManual ? t("saving") : t("saveDraft")}
</Button>
</div>
</div>
) : null}
</div>
) : null}
<DrawPublishDialog
open={publishBatch != null}
onOpenChange={(open) => {
if (!open) {
setPublishBatch(null);
}
}}
drawId={idNum}
batch={publishBatch}
onPublished={() => void onPublishFlowDone()}
onDiscarded={() => void onPublishFlowDone()}
/>
<ConfirmDialog />
</div>
);
}
}

View File

@@ -5,9 +5,11 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { Badge } from "@/components/ui/badge";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
import { useAdminProfile } from "@/stores/admin-session";
const segments = [
@@ -62,6 +64,8 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
const canManageDraw = canManageDrawResults(perms);
const canViewFinance = canViewDrawFinance(perms);
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
const { draw } = useDrawDetail();
const pendingReview = draw?.result_batch_counts.pending_review ?? 0;
const visibleSegments = useMemo(
() =>
@@ -85,7 +89,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
);
return (
<AdminSubnav aria-label={t("subnav.aria", { defaultValue: "期号导航" })} className="mb-6">
<AdminSubnav aria-label={t("subnav.aria", { defaultValue: "期号导航" })} className="mb-4">
{visibleSegments.map(({ suffix, key, label }) => {
const href = `${base}${suffix}`;
const active =
@@ -99,7 +103,14 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
return (
<AdminSubnavLink key={key} href={href} active={active}>
{t(label)}
<span className="inline-flex items-center gap-1.5">
{t(label)}
{key === "review" && pendingReview > 0 ? (
<Badge variant="secondary" className="h-5 min-w-5 px-1 text-[11px] tabular-nums">
{pendingReview}
</Badge>
) : null}
</span>
</AdminSubnavLink>
);
})}

View File

@@ -18,6 +18,7 @@ import {
postAdminIntegrationSiteRotateSecrets,
putAdminIntegrationSite,
} from "@/api/admin-integration-sites";
import { validateAdminPassword } from "@/lib/admin-input-validation";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
@@ -337,7 +338,7 @@ export function IntegrationSitesConsole({
return;
}
if (mode === "create" && form.admin_password.trim().length < 8) {
if (mode === "create" && validateAdminPassword(form.admin_password) === "too_short") {
toast.error(t("integrationSites.form.adminPasswordRequired"));
return;
}
@@ -771,7 +772,7 @@ export function IntegrationSitesConsole({
type="password"
value={form.admin_password}
placeholder={t("integrationSites.placeholders.adminPassword", {
defaultValue: "至少 8 位",
defaultValue: "至少 6 位",
})}
onChange={(e) => updateForm("admin_password", e.target.value)}
/>

View File

@@ -1,52 +1,52 @@
"use client";
import { useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Info } from "lucide-react";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
type JackpotTab = "config" | "records";
function readTabFromHash(): JackpotTab {
return window.location.hash === "#records" ? "records" : "config";
}
/** 奖池:配置与流水分 Tab避免单页堆叠过长。 */
export function JackpotConfigScreen() {
const { t } = useTranslation("jackpot");
const [tab, setTab] = useState<JackpotTab>("config");
useEffect(() => {
const scrollToRecords = () => {
if (window.location.hash !== "#records") {
return;
}
document.getElementById("jackpot-records")?.scrollIntoView({ behavior: "smooth", block: "start" });
};
scrollToRecords();
window.addEventListener("hashchange", scrollToRecords);
return () => window.removeEventListener("hashchange", scrollToRecords);
const sync = () => setTab(readTabFromHash());
sync();
window.addEventListener("hashchange", sync);
return () => window.removeEventListener("hashchange", sync);
}, []);
const switchTab = useCallback((next: JackpotTab) => {
setTab(next);
const path = window.location.pathname;
if (next === "records") {
window.history.replaceState(null, "", `${path}#records`);
return;
}
window.history.replaceState(null, "", path);
}, []);
return (
<div className="flex w-full max-w-none flex-col gap-6">
<AdminPageCard title={t("poolsSectionTitle")}>
<Alert className="mb-4 border-primary/20 bg-primary/5 text-foreground">
<Info className="size-4" aria-hidden />
<AlertTitle>{t("rulesTitle")}</AlertTitle>
<AlertDescription className="space-y-1 text-xs leading-5">
<p>{t("rulesJoin")}</p>
<p>{t("rulesBurst")}</p>
<p>{t("rulesManual")}</p>
</AlertDescription>
</Alert>
<JackpotPoolsConsole embedded />
</AdminPageCard>
<div className="flex w-full max-w-none flex-col gap-4">
<AdminSubnav aria-label={t("pageTabs")}>
<AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
{t("tabConfig")}
</AdminSubnavButton>
<AdminSubnavButton active={tab === "records"} onClick={() => switchTab("records")}>
{t("tabRecords")}
</AdminSubnavButton>
</AdminSubnav>
<AdminPageCard
id="jackpot-records"
title={t("recordsSectionTitle")}
description={t("recordsSectionDescription")}
>
<JackpotRecordsConsole embedded />
</AdminPageCard>
{tab === "config" ? <JackpotPoolsConsole embedded /> : <JackpotRecordsConsole embedded />}
</div>
);
}
}

View File

@@ -16,9 +16,9 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -44,12 +44,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
formatRatioAsPercent,
percentUiToRatio,
ratioToPercentUi,
} from "@/lib/admin-rate-percent";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
type Draft = {
contribution_rate: string;
@@ -82,7 +78,6 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
}
type JackpotPoolsConsoleProps = {
/** 嵌入运营配置单页时去掉外层脚手架与重复标题 */
embedded?: boolean;
};
@@ -102,6 +97,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
const [adjustmentDrafts, setAdjustmentDrafts] = useState<Record<number, AdjustmentDraft>>({});
const [adjustmentRows, setAdjustmentRows] = useState<Record<number, AdminJackpotPoolAdjustmentRow[]>>({});
const [adjustingId, setAdjustingId] = useState<number | null>(null);
const [adjustmentOpenId, setAdjustmentOpenId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -110,31 +106,32 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
setItems(res.items);
const d: Record<number, Draft> = {};
const adjDrafts: Record<number, AdjustmentDraft> = {};
const adjRows: Record<number, AdminJackpotPoolAdjustmentRow[]> = {};
for (const p of res.items) {
d[p.id] = toDraft(p);
adjDrafts[p.id] = { direction: "increase", amount: "", reason: "" };
try {
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
adjRows[p.id] = ledger.items;
} catch {
adjRows[p.id] = [];
}
}
setDrafts(d);
setAdjustmentDrafts(adjDrafts);
setAdjustmentRows(adjRows);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
} finally {
setLoading(false);
}
}, []);
}, [tRef]);
useAsyncEffect(() => {
void load();
}, []);
const loadAdjustments = useCallback(async (poolId: number) => {
try {
const ledger = await getAdminJackpotPoolAdjustments(poolId, { per_page: 5 });
setAdjustmentRows((prev) => ({ ...prev, [poolId]: ledger.items }));
} catch {
setAdjustmentRows((prev) => ({ ...prev, [poolId]: [] }));
}
}, []);
const updateDraft = (id: number, patch: Partial<Draft>) => {
setDrafts((prev) => ({
...prev,
@@ -149,6 +146,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
}));
};
const toggleAdjustment = (poolId: number, open: boolean) => {
setAdjustmentOpenId(open ? poolId : null);
if (open && adjustmentRows[poolId] === undefined) {
void loadAdjustments(poolId);
}
};
const save = async (p: AdminJackpotPoolRow) => {
const d = drafts[p.id];
if (!d) return;
@@ -203,8 +207,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
),
);
updateAdjustmentDraft(p.id, { amount: "", reason: "" });
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
setAdjustmentRows((prev) => ({ ...prev, [p.id]: ledger.items }));
await loadAdjustments(p.id);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("adjustmentFailed"));
} finally {
@@ -237,121 +240,183 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
};
const poolList = (
<div className={embedded ? "space-y-4" : "space-y-8"}>
<div className={embedded ? "space-y-4" : "space-y-6"}>
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
{!loading && items.length === 0 ? (
<AdminNoResourceState />
) : null}
{!loading && items.length === 0 ? <AdminNoResourceState /> : null}
{items.map((p) => {
const d = drafts[p.id] ?? toDraft(p);
const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" };
const ledger = adjustmentRows[p.id] ?? [];
const currentAmount = formatAdminMinorDecimal(p.current_amount, p.currency_code);
const triggerThreshold = formatAdminMinorDecimal(p.trigger_threshold, p.currency_code);
const minBetAmount = formatAdminMinorDecimal(p.min_bet_amount, p.currency_code);
const statusOn = d.status === "1";
const adjustmentOpen = adjustmentOpenId === p.id;
return (
<div
<AdminPageCard
key={p.id}
className="space-y-3 rounded-xl border border-border/60 bg-background p-3 shadow-sm"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h3 className="text-base font-semibold">{p.currency_code}</h3>
<p className="text-muted-foreground text-xs">{t("configTitle")}</p>
</div>
<p className="text-muted-foreground text-sm font-medium">
{t("displayBalance", { amount: currentAmount })}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
<p className="text-muted-foreground text-xs">{t("currentAmount")}</p>
<p className="mt-1 text-2xl font-semibold leading-none tracking-tight">
{currentAmount}
</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
<p className="text-muted-foreground text-xs">{t("status")}</p>
<div className="mt-1">
<AdminStatusBadge status={statusOn ? "enabled" : "disabled"}>
{statusOn ? t("enabled") : t("disabled")}
</AdminStatusBadge>
title={p.currency_code}
actions={
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<Label htmlFor={`status-${p.id}`} className="text-sm text-muted-foreground">
{t("status")}
</Label>
<Switch
id={`status-${p.id}`}
checked={statusOn}
disabled={!canManageJackpot}
aria-label={t("status")}
onCheckedChange={(checked) => updateDraft(p.id, { status: checked ? "1" : "0" })}
/>
</div>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
<p className="text-muted-foreground text-xs">{t("payoutRate")}</p>
<p className="mt-1 text-lg font-semibold">
{formatRatioAsPercent(percentUiToRatio(d.payout_rate))}
</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
<p className="text-muted-foreground text-xs">{t("forceTriggerGap")}</p>
<p className="mt-1 text-lg font-semibold">{d.force_trigger_draw_gap}</p>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-12">
<div className="space-y-3 xl:col-span-8">
{canManageJackpot ? (
<div className="space-y-2 rounded-lg border border-border/60 bg-background p-3">
<p className="text-sm font-medium">{t("balanceAdjustmentTitle")}</p>
<p className="text-muted-foreground text-xs">{t("balanceAdjustmentHint")}</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("adjustmentDirection")}</Label>
<Select
value={adj.direction}
onValueChange={(value: "increase" | "decrease" | null) => {
if (value === null) return;
updateAdjustmentDraft(p.id, { direction: value });
}}
>
<SelectTrigger className="w-full min-w-0 sm:max-w-[12rem]">
<SelectValue>
{(value) =>
value === "increase"
? t("adjustmentIncrease")
: value === "decrease"
? t("adjustmentDecrease")
: value != null
? String(value)
: t("adjustmentIncrease")
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
<Input
id={`adj-amt-${p.id}`}
className="font-mono"
value={adj.amount}
placeholder={t("adjustmentAmountPlaceholder")}
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
<Textarea
id={`adj-reason-${p.id}`}
rows={1}
value={adj.reason}
placeholder={t("adjustmentReasonPlaceholder")}
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
/>
</div>
<Button
type="button"
size="sm"
disabled={savingId === p.id}
onClick={() =>
requestConfirm({
title: t("confirmSavePoolTitle"),
description: t("confirmSavePoolDescription"),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => save(p),
})
}
>
{savingId === p.id ? t("saving") : t("save")}
</Button>
) : null}
</div>
}
>
<p className="mb-4 text-2xl font-semibold tabular-nums tracking-tight">{currentAmount}</p>
<fieldset disabled={!canManageJackpot} className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
<Input
id={`cr-${p.id}`}
type="number"
min={0}
max={100}
step="0.01"
className="font-mono"
value={d.contribution_rate}
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
<Input
id={`th-${p.id}`}
className="font-mono"
value={d.trigger_threshold}
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
<Input
id={`pr-${p.id}`}
type="number"
min={0}
max={100}
step="0.01"
className="font-mono"
value={d.payout_rate}
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
<Input
id={`min-${p.id}`}
className="font-mono"
value={d.min_bet_amount}
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
/>
</div>
<div className="space-y-1.5 sm:col-span-2 lg:col-span-2">
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
<Input
id={`gap-${p.id}`}
className="font-mono"
value={d.force_trigger_draw_gap}
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
/>
</div>
<div className="space-y-1.5 sm:col-span-2 lg:col-span-3">
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
<Input
id={`combo-${p.id}`}
className="font-mono"
value={d.combo_trigger_play_codes}
placeholder={t("comboTriggerPlaysPlaceholder")}
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
/>
</div>
</fieldset>
{canManageJackpot ? (
<div className="mt-4 border-t border-border/60 pt-3">
<button
type="button"
className="text-sm font-medium text-foreground hover:underline"
onClick={() => toggleAdjustment(p.id, !adjustmentOpen)}
>
{t("balanceAdjustmentTitle")}
</button>
{adjustmentOpen ? (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("adjustmentDirection")}</Label>
<Select
value={adj.direction}
onValueChange={(value: "increase" | "decrease" | null) => {
if (value === null) return;
updateAdjustmentDraft(p.id, { direction: value });
}}
>
<SelectTrigger className="w-full min-w-0">
<SelectValue>
{(value) =>
value === "increase"
? t("adjustmentIncrease")
: value === "decrease"
? t("adjustmentDecrease")
: t("adjustmentIncrease")
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end">
<div className="space-y-1.5">
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
<Input
id={`adj-amt-${p.id}`}
className="font-mono"
value={adj.amount}
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
<Textarea
id={`adj-reason-${p.id}`}
rows={2}
value={adj.reason}
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={adjustingId === p.id}
onClick={() =>
requestConfirm({
@@ -365,177 +430,50 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
{adjustingId === p.id ? t("processing") : t("submitAdjustment")}
</Button>
</div>
</div>
) : null}
<fieldset
disabled={!canManageJackpot}
className="grid gap-2 rounded-lg border border-border/60 bg-background p-3 sm:grid-cols-2"
>
<div className="space-y-1.5">
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
<Input
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>
<div className="space-y-1.5">
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
<Input
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>
<div className="space-y-1.5">
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
<Input
id={`pr-${p.id}`}
type="number"
min={0}
max={100}
step="0.01"
className="font-mono"
value={d.payout_rate}
placeholder={t("payoutRatePlaceholder")}
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
<Input
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>
<div className="space-y-1.5">
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
<Input
id={`cr-${p.id}`}
type="number"
min={0}
max={100}
step="0.01"
className="font-mono"
value={d.contribution_rate}
placeholder={t("contributionRatePlaceholder")}
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
<Input
id={`combo-${p.id}`}
className="font-mono"
value={d.combo_trigger_play_codes}
placeholder={t("comboTriggerPlaysPlaceholder")}
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 sm:col-span-2">
<Label htmlFor={`status-${p.id}`} className="text-sm font-medium">
{t("status")}
</Label>
<Switch
id={`status-${p.id}`}
checked={statusOn}
disabled={!canManageJackpot}
aria-label={t("status")}
onCheckedChange={(checked) =>
updateDraft(p.id, { status: checked ? "1" : "0" })
}
/>
</div>
{canManageJackpot ? (
<div className="flex justify-end sm:col-span-2">
<Button
type="button"
disabled={savingId === p.id}
onClick={() =>
requestConfirm({
title: t("confirmSavePoolTitle"),
description: t("confirmSavePoolDescription"),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => save(p),
})
}
>
{savingId === p.id ? t("saving") : t("save")}
</Button>
</div>
) : null}
</fieldset>
</div>
<div className="space-y-3 xl:col-span-4">
<div className="rounded-lg border border-border/60 bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium">{t("recentAdjustments")}</p>
<span className="text-muted-foreground text-xs">{ledger.length}</span>
</div>
{ledger.length > 0 ? (
<ul className="max-h-60 space-y-2 overflow-y-auto pr-1">
{ledger.map((row) => (
<li key={row.id} className="rounded-md border border-border/60 bg-muted/20 p-2">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs">{row.adjustment_no}</span>
<span className="text-sm font-semibold">
{ledger.length > 0 ? (
<ul className="space-y-1.5 sm:col-span-2">
{ledger.map((row) => (
<li
key={row.id}
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-xs"
>
<span className="truncate text-muted-foreground">{row.reason}</span>
<span className="shrink-0 font-mono font-medium">
{row.amount_delta > 0 ? "+" : ""}
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
</span>
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{row.reason}</p>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground text-xs"></p>
)}
</div>
<div className="rounded-lg border border-border/60 bg-background p-3">
<p className="text-muted-foreground text-xs">{t("triggerThreshold")}</p>
<p className="mt-1 text-lg font-semibold">{triggerThreshold}</p>
<p className="text-muted-foreground mt-2 text-xs">{t("minBetAmount")}</p>
<p className="mt-1 text-lg font-semibold">{minBetAmount}</p>
</div>
</div>
</div>
{canManualBurst ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<p className="mb-1 text-sm font-medium text-destructive">{t("manualBurst")}</p>
<p className="mb-2 text-xs text-muted-foreground">{t("manualBurstHint")}</p>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
<Input
id={`burst-draw-${p.id}`}
className="font-mono"
value={d.manual_burst_draw_id}
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
/>
</li>
))}
</ul>
) : null}
</div>
<Button
type="button"
variant="destructive"
className="shrink-0 sm:ml-auto"
disabled={burstingId === p.id}
onClick={() => setConfirmBurstPoolId(p.id)}
>
{burstingId === p.id ? t("processing") : t("manualBurst")}
</Button>
</div>
) : null}
</div>
) : null}
</div>
{canManualBurst ? (
<div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
<Input
id={`burst-draw-${p.id}`}
className="font-mono"
value={d.manual_burst_draw_id}
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
/>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={burstingId === p.id}
onClick={() => setConfirmBurstPoolId(p.id)}
>
{burstingId === p.id ? t("processing") : t("manualBurst")}
</Button>
</div>
) : null}
</AdminPageCard>
);
})}
</div>
@@ -594,4 +532,4 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
<ConfirmActionDialog />
</ModuleScaffold>
);
}
}

View File

@@ -14,7 +14,7 @@ import { AdminTableExportButton } from "@/components/admin/admin-table-export-bu
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
@@ -167,13 +167,15 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
return translated === key ? value : translated;
};
const filterFields = (
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex w-full min-w-0 max-w-sm flex-col gap-1.5">
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
const filterBlock = (
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1.5">
<Label htmlFor="jk-draw" className="text-xs text-muted-foreground">
{t("drawNo")}
</Label>
<Input
id="jk-draw"
className="font-mono"
className="h-9 w-40 font-mono"
value={drawNo}
onChange={(e) => setDrawNo(e.target.value)}
placeholder={t("optional")}
@@ -184,23 +186,12 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
}}
/>
</div>
<Button type="button" className="shrink-0 sm:self-end" onClick={applyDraw}>
<Button type="button" size="sm" className="h-9" onClick={applyDraw}>
{t("apply")}
</Button>
</div>
);
const filterBlock = embedded ? (
filterFields
) : (
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("filter")}</CardTitle>
</CardHeader>
<CardContent>{filterFields}</CardContent>
</Card>
);
const payoutFooter = payouts ? (
<AdminListPaginationFooter
selectId="jk-payout-per"
@@ -325,10 +316,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
const content = (
<>
{filterBlock}
{err ? <p className="text-destructive text-sm">{err}</p> : null}
<AdminSubnav aria-label={t("recordTabs", { defaultValue: "奖池记录" })}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<AdminSubnav aria-label={t("pageTabs")}>
<AdminSubnavButton
active={recordTab === "payout"}
onClick={() => setRecordTab("payout")}
@@ -341,7 +330,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
>
{t("contributionRecords")}
</AdminSubnavButton>
</AdminSubnav>
</AdminSubnav>
{filterBlock}
</div>
{err ? <p className="text-destructive text-sm">{err}</p> : null}
<div className="space-y-6">
{recordTab === "payout" ? payoutTable : contributionTable}

View File

@@ -1,6 +1,7 @@
"use client";
import { Eye, ShieldAlert } from "lucide-react";
import Link from "next/link";
import { ArrowUpRight, ChevronDown, ChevronRight, RefreshCw, Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -17,19 +18,18 @@ import {
manuallyProcessTransferOrder,
reverseTransferOrder,
} from "@/api/admin-wallet";
import { ReconcileItemActions } from "@/modules/reconcile/reconcile-item-actions";
import { getAdminPlayers } from "@/api/admin-player";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { ReconcileJobDetailPanel, type ReconcileItemsFilter } from "@/modules/reconcile/reconcile-job-detail-panel";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
@@ -51,103 +51,15 @@ import type {
AdminReconcileItemsData,
AdminReconcileJobListData,
} from "@/types/api/admin-reconcile";
import {
getJobSummaryValue,
jobStatusLabel,
renderPeriodRange,
} from "@/modules/reconcile/reconcile-labels";
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
/** 与后端 reconcile_type 对齐;扩展时在 API 与下拉同步增加 */
const RECONCILE_TYPE = "wallet_transfer" as const;
function jobStatusLabel(status: string, t: (key: string) => string): string {
switch (status) {
case "completed":
return t("statusCompleted");
case "running":
return t("statusRunning");
case "failed":
return t("statusFailed");
default:
return status;
}
}
function itemStatusLabel(status: string, t: (key: string) => string): string {
switch (status) {
case "mismatch":
return t("itemMismatch");
case "matched":
return t("itemMatched");
case "pending_check":
return t("itemPendingCheck");
case "stale_processing":
return t("itemStaleProcessing");
case "pending_reconcile":
return t("itemPendingReconcile");
case "missing_wallet_txn":
return t("itemMissingWalletTxn");
case "unexpected_wallet_txn":
return t("itemUnexpectedWalletTxn");
case "missing_refund":
return t("itemMissingRefund");
case "missing_reversal":
return t("itemMissingReversal");
case "main_site_record_missing":
return t("itemMainSiteRecordMissing");
case "main_site_failed":
return t("itemMainSiteFailed");
default:
return status;
}
}
function reconcileTypeLabel(type: string, t: (key: string) => string): string {
switch (type) {
case "wallet_transfer":
return t("reconcileTypeFixed");
default:
return type;
}
}
function mainSiteCheckLabel(status: string | null | undefined, t: (key: string) => string): string {
switch (status) {
case "matched":
return t("mainSiteMatched");
case "not_found":
return t("mainSiteNotFound");
case "failed_on_main":
return t("mainSiteFailed");
case "unavailable":
return t("mainSiteUnavailable");
default:
return t("mainSiteSkipped");
}
}
function itemResolutionLabel(
row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">,
t: (key: string) => string,
): string {
return row.is_resolved === true || row.resolved_at ? t("itemResolved") : t("itemUnresolved");
}
function itemResolutionTone(row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">): "success" | "warning" {
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
}
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
const raw = summary?.[key];
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
}
function renderPeriodRange(
row: Pick<AdminReconcileJobRow, "period_start" | "period_end">,
formatTs: (value: string | null | undefined) => string,
): string {
const from = row.period_start ? formatTs(row.period_start) : "—";
const to = row.period_end ? formatTs(row.period_end) : "—";
return `${from} ~ ${to}`;
}
export function ReconcileConsole(): React.ReactElement {
const { t } = useTranslation(["reconcile", "common"]);
const tRef = useTranslationRef(["reconcile", "common"]);
@@ -163,18 +75,20 @@ export function ReconcileConsole(): React.ReactElement {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedJob, setSelectedJob] = useState<AdminReconcileJobRow | null>(null);
const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
const [itemsPage, setItemsPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [itemsPerPage, setItemsPerPage] = useState(20);
const [itemsLoading, setItemsLoading] = useState(false);
const [itemsFilter, setItemsFilter] = useState<ReconcileItemsFilter>("open");
const [scanOpen, setScanOpen] = useState(false);
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [playerSearch, setPlayerSearch] = useState("");
const [playerResults, setPlayerResults] = useState<AdminPlayerRow[]>([]);
const [playerLoading, setPlayerLoading] = useState(false);
const [playerPickerOpen, setPlayerPickerOpen] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
const [submitting, setSubmitting] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
@@ -191,20 +105,20 @@ export function ReconcileConsole(): React.ReactElement {
} finally {
setJobsLoading(false);
}
}, [page, perPage]);
}, [page, perPage, tRef]);
useAsyncEffect(() => {
void loadJobs();
}, [page, perPage]);
const loadItems = useCallback(async () => {
if (selectedId == null) {
if (selectedJob == null) {
setItems(null);
return;
}
setItemsLoading(true);
try {
const d = await getAdminReconcileJobItems(selectedId, {
const d = await getAdminReconcileJobItems(selectedJob.id, {
page: itemsPage,
per_page: itemsPerPage,
});
@@ -215,11 +129,11 @@ export function ReconcileConsole(): React.ReactElement {
} finally {
setItemsLoading(false);
}
}, [selectedId, itemsPage, itemsPerPage]);
}, [selectedJob, itemsPage, itemsPerPage, tRef]);
useAsyncEffect(() => {
void loadItems();
}, [selectedId, itemsPage, itemsPerPage]);
}, [selectedJob, itemsPage, itemsPerPage]);
const loadPlayers = useCallback(async (keyword: string) => {
const q = keyword.trim();
@@ -239,15 +153,14 @@ export function ReconcileConsole(): React.ReactElement {
}, []);
useEffect(() => {
const q = playerSearch.trim();
if (q === "") {
if (!playerPickerOpen) {
return;
}
const timer = window.setTimeout(() => {
void loadPlayers(q);
void loadPlayers(playerSearch);
}, 250);
return () => window.clearTimeout(timer);
}, [loadPlayers, playerSearch]);
}, [loadPlayers, playerPickerOpen, playerSearch]);
async function onCreate(): Promise<void> {
if (!dateFrom.trim() || !dateTo.trim()) {
@@ -273,6 +186,7 @@ export function ReconcileConsole(): React.ReactElement {
: resp.item_count ?? 0;
toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty"));
setPage(1);
setScanOpen(false);
setDateFrom("");
setDateTo("");
setPlayerSearch("");
@@ -328,36 +242,50 @@ export function ReconcileConsole(): React.ReactElement {
});
}
function openJobDetail(job: AdminReconcileJobRow): void {
setSelectedJob(job);
setItemsPage(1);
setItemsFilter("open");
}
function closeJobDetail(): void {
setSelectedJob(null);
setItems(null);
}
const jm = jobs?.meta;
const im = items?.meta;
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
return (
<div className="flex w-full max-w-none flex-col gap-6">
<div className="flex w-full flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">{t("workflowHint")}</p>
<Link
href="/admin/wallet/transfer-orders?abnormal=1"
title={t("shortcutAbnormalTransfers")}
className="inline-flex shrink-0 items-center gap-1 text-sm font-medium hover:underline"
>
{t("shortcutAbnormalTransfers")}
<ArrowUpRight className="size-3.5" />
</Link>
</div>
{canCreate ? (
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("createHint")}</p>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
<div className="admin-list-field">
<span className="text-sm font-medium leading-none sm:shrink-0">{t("reconcileType")}</span>
<span className="inline-flex h-8 min-h-8 min-w-0 items-center rounded-md border border-border/60 bg-muted/30 px-2.5 text-sm text-foreground">
{t("reconcileTypeFixed")}
</span>
</div>
<div className="admin-list-field">
<Label htmlFor="rc-date-range" className="sm:shrink-0">
{t("dateRange")}
</Label>
<div className="min-w-0 w-full sm:w-60">
<div className="rounded-lg border border-border/60">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-medium"
onClick={() => setScanOpen((open) => !open)}
>
{t("createTitle")}
{scanOpen ? <ChevronDown className="size-4 text-muted-foreground" /> : <ChevronRight className="size-4 text-muted-foreground" />}
</button>
{scanOpen ? (
<div className="space-y-3 border-t border-border/60 px-3 py-3">
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[14rem] flex-1">
<AdminDateRangeField
id="rc-date-range"
label={t("dateRange")}
from={dateFrom}
to={dateTo}
onRangeChange={({ from, to }) => {
@@ -366,23 +294,66 @@ export function ReconcileConsole(): React.ReactElement {
}}
/>
</div>
</div>
<div className="admin-list-field min-w-0 flex-1">
<Label htmlFor="rc-player-search" className="sm:shrink-0">
{t("playerSearch")}
</Label>
<Input
id="rc-player-search"
className="w-full sm:w-52"
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
placeholder={t("playerSearchPlaceholder")}
/>
</div>
<div className="admin-list-actions">
<div className="min-w-[12rem] flex-1">
<Label htmlFor="rc-player-search" className="mb-1.5 block text-sm">
{t("playerSearch")}
</Label>
<Popover open={playerPickerOpen} onOpenChange={setPlayerPickerOpen} modal={false}>
<div className="flex gap-1.5">
<Input
id="rc-player-search"
className="h-8"
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
placeholder={t("playerSearchPlaceholder")}
/>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2"
aria-label={t("searchPicker.open", { ns: "reports", defaultValue: "搜索" })}
/>
}
>
<Search className="size-3.5" />
</PopoverTrigger>
</div>
<PopoverContent align="start" className="w-[var(--anchor-width)] min-w-[16rem] p-2">
<div className="max-h-48 overflow-y-auto">
{playerLoading ? (
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
) : playerResults.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">{t("playerEmpty", { defaultValue: "无匹配玩家" })}</p>
) : (
playerResults.map((player) => (
<button
key={player.id}
type="button"
className="flex w-full rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
onClick={() => {
setSelectedPlayer(player);
setPlayerSearch(player.site_player_id);
setPlayerPickerOpen(false);
}}
>
<span className="truncate">
{player.site_player_id}
{player.nickname ? ` · ${player.nickname}` : ""}
</span>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
className="w-full sm:w-auto"
size="sm"
className="h-8"
disabled={submitting}
onClick={() =>
requestConfirm({
@@ -399,353 +370,161 @@ export function ReconcileConsole(): React.ReactElement {
{submitting ? t("submitting") : t("createTask")}
</Button>
</div>
{selectedPlayer ? (
<div className="flex items-center justify-between gap-2 rounded-md bg-muted/40 px-2.5 py-1.5 text-sm">
<span className="min-w-0 truncate">
{selectedPlayer.site_player_id}
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
</span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => {
setSelectedPlayer(null);
setPlayerSearch("");
}}
>
{t("playerClear")}
</Button>
</div>
) : null}
</div>
{selectedPlayer ? (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="min-w-0 truncate font-medium text-foreground">
{selectedPlayer.site_player_id}
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
{` · ${selectedPlayer.site_code}`}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedPlayer(null);
setPlayerSearch("");
setPlayerResults([]);
}}
>
{t("playerClear")}
</Button>
</div>
) : null}
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
<div className="rounded-lg border bg-background">
<div className="max-h-56 overflow-y-auto">
{playerLoading ? (
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
) : playerResults.length === 0 ? (
<AdminNoResourceState compact className="px-3 py-4" />
) : (
<div className="divide-y">
{playerResults.map((player) => {
const active = selectedPlayer?.id === player.id;
return (
<button
key={player.id}
type="button"
className={cn(
"flex w-full px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
active && "bg-muted/30 font-medium",
)}
onClick={() => {
setSelectedPlayer(player);
setPlayerSearch(player.site_player_id);
}}
>
<span className="min-w-0 truncate">
{player.site_player_id}
{player.nickname ? ` · ${player.nickname}` : ""}
{player.username ? ` · ${player.username}` : ""}
{` · ${player.site_code}`}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
) : null}
</CardContent>
</Card>
) : null}
</div>
) : (
<p className="text-muted-foreground text-sm">{t("noCreatePermission")}</p>
<p className="text-sm text-muted-foreground">{t("viewOnlyHint")}</p>
)}
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
<div className="rounded-lg border border-border/60">
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-3 py-2.5">
<h2 className="text-sm font-semibold">{t("jobsTitle")}</h2>
<Button type="button" variant="ghost" size="sm" className="h-8" disabled={jobsLoading} onClick={() => void loadJobs()}>
<RefreshCw className={cn("size-3.5", jobsLoading && "animate-spin")} />
{t("refresh")}
</Button>
</CardHeader>
<CardContent className="admin-list-content pt-4">
{jobsErr ? <p className="text-sm text-destructive">{jobsErr}</p> : null}
</div>
<div className="p-3 pt-2">
{jobsErr ? <p className="mb-2 text-sm text-destructive">{jobsErr}</p> : null}
{jobs ? (
<>
<div className="admin-table-shell">
<Table id="reconcile-jobs-table">
<TableHeader>
<TableRow>
<TableHead className="sticky left-0 z-20 w-24 bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
{t("table.id", { ns: "common" })}
</TableHead>
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
{t("jobNo")}
</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-center">{t("itemCount")}</TableHead>
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
<TableHead>{t("period")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead>
<TableHead>{t("createdAt")}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("operate")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobsLoading && !jobs ? (
<AdminTableLoadingRow colSpan={10} />
) : jobs.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
) : (
jobs.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="sticky left-0 z-10 bg-card tabular-nums shadow-[1px_0_0_rgba(226,232,240,0.9)]">
{row.id}
</TableCell>
<TableCell className="sticky left-24 z-10 min-w-[14rem] bg-card font-mono text-xs shadow-[1px_0_0_rgba(226,232,240,0.9)]">
{row.job_no}
</TableCell>
<TableCell className="text-sm">{reconcileTypeLabel(row.reconcile_type, t)}</TableCell>
<Table id="reconcile-jobs-table">
<TableHeader>
<TableRow>
<TableHead>{t("jobNo")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
<TableHead>{t("period")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead>
<TableHead className="w-[6.5rem] text-center">{t("operate")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobsLoading && jobs.items.length === 0 ? (
<AdminTableLoadingRow colSpan={6} />
) : jobs.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={6} />
) : (
jobs.items.map((row) => {
const selected = selectedJob?.id === row.id;
const mismatchCount = getJobSummaryValue(row.summary_json, "mismatch_count");
return (
<TableRow key={row.id} className={cn(selected && "bg-primary/[0.04]")}>
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
<TableCell>
<AdminStatusBadge status={row.status}>
{jobStatusLabel(row.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-center tabular-nums">
{getJobSummaryValue(row.summary_json, "item_count")}
</TableCell>
<TableCell className="text-center tabular-nums">
<span
className={cn(
getJobSummaryValue(row.summary_json, "mismatch_count") > 0
? "font-medium text-amber-700"
: "text-muted-foreground",
mismatchCount > 0 ? "font-medium text-amber-700" : "text-muted-foreground",
)}
>
{getJobSummaryValue(row.summary_json, "mismatch_count")}
{mismatchCount}
</span>
</TableCell>
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
<span className="line-clamp-2">
{renderPeriodRange(row, formatTs)}
</span>
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
{renderPeriodRange(row, formatTs)}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatTs(row.finished_at)}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(226,232,240,0.9)]">
<AdminRowActionsMenu
actions={[
{
key: "view-details",
label: t("viewDetails"),
icon: Eye,
onClick: () => {
setSelectedId(row.id);
setItemsPage(1);
setDetailOpen(true);
},
},
]}
/>
<TableCell className="text-center">
<Button
type="button"
size="sm"
variant={selected ? "default" : "outline"}
className="h-8"
onClick={() => openJobDetail(row)}
>
{t("viewDetails")}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
})
)}
</TableBody>
</Table>
{jm ? (
<AdminListPaginationFooter
selectId="reconcile-jobs-per-page"
total={jm.total}
page={jm.current_page}
lastPage={Math.max(1, jm.last_page)}
perPage={jm.per_page}
loading={jobsLoading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
<div className="mt-3 border-t border-border/60 pt-2">
<AdminListPaginationFooter
selectId="reconcile-jobs-per-page"
total={jm.total}
page={jm.current_page}
lastPage={Math.max(1, jm.last_page)}
perPage={jm.per_page}
loading={jobsLoading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
</div>
) : null}
</>
) : jobsLoading ? (
<AdminNoResourceState message={t("loadFailed")} />
) : null}
</CardContent>
</Card>
</div>
</div>
<Dialog
open={detailOpen}
open={selectedJob != null}
onOpenChange={(open) => {
setDetailOpen(open);
if (!open) {
setSelectedId(null);
setItems(null);
closeJobDetail();
}
}}
>
<DialogContent
className="flex max-h-[min(90vh,48rem)] w-full max-w-[min(72rem,calc(100%-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,calc(100%-2rem))]"
showCloseButton
className="flex h-[min(86vh,780px)] !max-w-[min(920px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
<DialogTitle className="text-base">{t("detailsTitle")}</DialogTitle>
<DialogDescription className="font-mono text-xs">
{selectedJob ? `${selectedJob.job_no} · #${selectedJob.id}` : selectedId != null ? `#${selectedId}` : ""}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
{itemsLoading && !items ? (
<AdminLoadingState minHeight="6rem" className="py-6" />
) : null}
{items ? (
<>
<div className="mb-4 grid gap-3 md:grid-cols-3">
<div className="rounded-lg border bg-background px-4 py-3">
<div className="text-xs text-muted-foreground">{t("itemCount")}</div>
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobItemCount}</div>
</div>
<div className="rounded-lg border bg-background px-4 py-3">
<div className="text-xs text-muted-foreground">{t("mismatchCount")}</div>
<div className="mt-1 flex items-center gap-2 text-xl font-semibold tabular-nums text-amber-700">
<ShieldAlert className="size-4" />
{selectedJobMismatchCount}
</div>
</div>
<div className="rounded-lg border bg-background px-4 py-3">
<div className="text-xs text-muted-foreground">{t("matchedCount")}</div>
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobMatchedCount}</div>
</div>
</div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t("jobNo")} {items.job_no}</span>
<span>·</span>
<span className="inline-flex items-center gap-1.5">
{t("status")}
{selectedJob ? (
<AdminStatusBadge status={selectedJob.status}>
{jobStatusLabel(selectedJob.status, t)}
</AdminStatusBadge>
) : (
"—"
)}
</span>
<span>·</span>
<span>{t("period")} {selectedJob ? renderPeriodRange(selectedJob, formatTs) : "—"}</span>
</div>
<div className="rounded-lg border bg-background">
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
<TableHeader>
<TableRow>
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="min-w-[10rem]">{t("transferNo")}</TableHead>
<TableHead className="min-w-[10rem]">{t("walletTxnNo")}</TableHead>
<TableHead className="min-w-[10rem]">{t("mainSiteRef")}</TableHead>
<TableHead className="w-28">{t("mainSiteCheck")}</TableHead>
<TableHead className="w-28 text-right">{t("differenceAmount")}</TableHead>
<TableHead className="w-32">{t("itemResult")}</TableHead>
<TableHead className="w-28">{t("processingStatus")}</TableHead>
<TableHead className="min-w-[12rem]">{t("actions")}</TableHead>
<TableHead className="w-36">{t("detectedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={10} />
) : (
items.items.map((r) => (
<TableRow
key={r.id}
className={cn(
r.is_resolved !== true && "bg-amber-500/5",
)}
>
<TableCell className="align-top">{r.id}</TableCell>
<TableCell className="align-top font-mono text-xs break-all">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="align-top font-mono text-xs break-all">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="align-top font-mono text-xs break-all">
{r.main_site_external_ref_no ?? r.external_ref_no ?? "—"}
</TableCell>
<TableCell className="align-top text-xs">
{mainSiteCheckLabel(r.main_site_check, t)}
</TableCell>
<TableCell className="align-top text-right tabular-nums">
<span
className={cn(
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
)}
>
{r.difference_amount}
</span>
</TableCell>
<TableCell className="align-top">
<AdminStatusBadge status={r.status}>
{itemStatusLabel(r.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="align-top">
<AdminStatusBadge status={r.resolved_at ? "resolved" : "unresolved"} tone={itemResolutionTone(r)}>
{itemResolutionLabel(r, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="align-top min-w-[12rem]">
<ReconcileItemActions
row={r}
canWriteWallet={canWriteWallet}
busy={actionBusy}
onCompleteCredit={(transferNo) => void runTransferAction(transferNo, "complete_credit")}
onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")}
onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")}
/>
</TableCell>
<TableCell className="align-top whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(r.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{im ? (
<div className="pt-4">
<AdminListPaginationFooter
selectId="reconcile-items-per-page"
total={im.total}
page={im.current_page}
lastPage={Math.max(1, im.last_page)}
perPage={im.per_page}
loading={itemsLoading}
onPerPageChange={(n) => {
setItemsPerPage(n);
setItemsPage(1);
}}
onPageChange={setItemsPage}
/>
</div>
) : null}
</>
) : null}
</div>
{selectedJob ? (
<ReconcileJobDetailPanel
job={selectedJob}
items={items}
itemsLoading={itemsLoading}
itemsFilter={itemsFilter}
onItemsFilterChange={setItemsFilter}
canWriteWallet={canWriteWallet}
actionBusy={actionBusy}
onCompleteCredit={(transferNo) => void runTransferAction(transferNo, "complete_credit")}
onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")}
onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")}
onItemsPageChange={setItemsPage}
onItemsPerPageChange={setItemsPerPage}
/>
) : null}
</DialogContent>
</Dialog>
<ConfirmDialog />
</div>
);
}
}

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { RotateCcw, Wrench } from "lucide-react";
import { ExternalLink, RotateCcw, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
@@ -19,6 +19,7 @@ type ReconcileItemActionsProps = {
row: AdminReconcileItemRow;
canWriteWallet: boolean;
busy: boolean;
compact?: boolean;
onCompleteCredit: (transferNo: string) => void;
onReverse: (transferNo: string) => void;
onManualProcess: (transferNo: string) => void;
@@ -28,6 +29,7 @@ export function ReconcileItemActions({
row,
canWriteWallet,
busy,
compact = false,
onCompleteCredit,
onReverse,
onManualProcess,
@@ -45,36 +47,60 @@ export function ReconcileItemActions({
can_manually_process: row.can_manually_process,
};
const hasWalletAction = transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet);
const menuActions = [
{
key: "complete",
label: t("completeCredit", { ns: "wallet" }),
hidden: !canCompleteTransferInCredit(actionRow, canWriteWallet),
onClick: () => onCompleteCredit(transferNo),
},
{
key: "manual",
label: t("markCaseClosed", { ns: "wallet" }),
icon: Wrench,
hidden: !canManuallyProcessTransferOrder(actionRow, canWriteWallet),
onClick: () => onManualProcess(transferNo),
},
{
key: "reverse",
label: t("reverse", { ns: "wallet" }),
icon: RotateCcw,
destructive: true,
hidden: !canReverseTransferOrder(actionRow, canWriteWallet),
onClick: () => onReverse(transferNo),
},
{
key: "transfer",
label: t("openTransferOrder"),
icon: ExternalLink,
hidden: transferNo === "",
onClick: () => {
window.location.href = `/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`;
},
},
{
key: "txn",
label: t("openWalletTxn"),
icon: ExternalLink,
hidden: !row.side_b_ref,
onClick: () => {
window.location.href = `/admin/wallet/transactions?txn_no=${encodeURIComponent(row.side_b_ref ?? "")}`;
},
},
];
if (compact) {
if (!hasWalletAction && !transferNo && !row.side_b_ref) {
return <span></span>;
}
return <AdminRowActionsMenu busy={busy} actions={menuActions} />;
}
return (
<div className="flex flex-wrap gap-2">
{transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet) ? (
<AdminRowActionsMenu
busy={busy}
actions={[
{
key: "complete",
label: t("completeCredit", { ns: "wallet" }),
hidden: !canCompleteTransferInCredit(actionRow, canWriteWallet),
onClick: () => onCompleteCredit(transferNo),
},
{
key: "manual",
label: t("markCaseClosed", { ns: "wallet" }),
icon: Wrench,
hidden: !canManuallyProcessTransferOrder(actionRow, canWriteWallet),
onClick: () => onManualProcess(transferNo),
},
{
key: "reverse",
label: t("reverse", { ns: "wallet" }),
icon: RotateCcw,
destructive: true,
hidden: !canReverseTransferOrder(actionRow, canWriteWallet),
onClick: () => onReverse(transferNo),
},
]}
/>
) : null}
{hasWalletAction ? <AdminRowActionsMenu busy={busy} actions={menuActions.filter((a) => a.key !== "transfer" && a.key !== "txn")} /> : null}
{transferNo !== "" ? (
<Link
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`}
@@ -94,4 +120,4 @@ export function ReconcileItemActions({
{!transferNo && !row.side_b_ref ? <span></span> : null}
</div>
);
}
}

View File

@@ -0,0 +1,135 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { ReconcileItemActions } from "@/modules/reconcile/reconcile-item-actions";
import {
itemCurrentStatusLabel,
itemStatusLabel,
mainSiteCheckLabel,
} from "@/modules/reconcile/reconcile-labels";
import { TableCell, TableRow } from "@/components/ui/table";
import type { AdminReconcileItemRow } from "@/types/api/admin-reconcile";
type ReconcileItemRowProps = {
row: AdminReconcileItemRow;
canWriteWallet: boolean;
actionBusy: boolean;
onCompleteCredit: (transferNo: string) => void;
onReverse: (transferNo: string) => void;
onManualProcess: (transferNo: string) => void;
};
function resolveDisplayCurrency(): string {
const fallback = getCachedAdminCurrencies().find((row) => row.is_enabled && row.is_bettable)?.code;
return fallback?.trim() || "NPR";
}
function isResolved(row: AdminReconcileItemRow): boolean {
return row.is_resolved === true || row.resolved_at != null;
}
export function ReconcileItemRow({
row,
canWriteWallet,
actionBusy,
onCompleteCredit,
onReverse,
onManualProcess,
}: ReconcileItemRowProps): React.ReactElement {
const { t } = useTranslation(["reconcile", "wallet"]);
const formatTs = useAdminDateTimeFormatter();
useAdminCurrencyCatalog();
const currencyCode = resolveDisplayCurrency();
const transferNo = row.side_a_ref ?? "";
const resolved = isResolved(row);
return (
<TableRow className={cn(!resolved && "bg-amber-500/[0.04]")}>
<TableCell className="min-w-[11rem] align-middle py-3">
{transferNo ? (
<Link
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`}
className="font-mono text-sm font-medium hover:underline"
>
{transferNo}
</Link>
) : (
<span className="text-sm"></span>
)}
</TableCell>
<TableCell className="min-w-[10rem] align-middle py-3">
{row.side_b_ref ? (
<Link
href={`/admin/wallet/transactions?txn_no=${encodeURIComponent(row.side_b_ref)}`}
className="font-mono text-xs break-all hover:underline"
>
{row.side_b_ref}
</Link>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="min-w-[7rem] align-middle py-3 text-sm">
<span className="break-words">
{mainSiteCheckLabel(row.main_site_check, t)}
</span>
{row.main_site_external_ref_no ? (
<p className="mt-1 font-mono text-xs text-muted-foreground break-all">
{row.main_site_external_ref_no}
</p>
) : null}
</TableCell>
<TableCell className="min-w-[9rem] whitespace-nowrap align-middle py-3 font-mono text-xs">
{formatTs(row.created_at)}
</TableCell>
<TableCell className="min-w-[7.5rem] align-middle py-3">
<div className="space-y-1">
<AdminStatusBadge
status={row.status}
className={cn(resolved && "border-border/60 bg-muted/40 text-muted-foreground")}
>
{itemStatusLabel(row.status, t)}
</AdminStatusBadge>
{resolved ? (
<p className="text-[11px] leading-snug text-muted-foreground">{t("itemScanFindingResolvedHint")}</p>
) : null}
</div>
</TableCell>
<TableCell className="min-w-[5.5rem] align-middle py-3">
<AdminStatusBadge status={row.current_transfer_status ?? row.status}>
{itemCurrentStatusLabel(row, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="min-w-[5.5rem] align-middle py-3 text-right tabular-nums">
<span
className={cn(
"text-sm font-semibold",
row.difference_amount !== 0 ? "text-amber-700" : "text-muted-foreground",
)}
>
{formatAdminMinorUnits(row.difference_amount, currencyCode)}
</span>
</TableCell>
<TableCell className="w-[3.5rem] align-middle py-3">
<div className="flex justify-center">
<ReconcileItemActions
row={row}
canWriteWallet={canWriteWallet}
busy={actionBusy}
compact
onCompleteCredit={onCompleteCredit}
onReverse={onReverse}
onManualProcess={onManualProcess}
/>
</div>
</TableCell>
</TableRow>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { ReconcileItemRow } from "@/modules/reconcile/reconcile-item-row";
import {
getJobSummaryValue,
isOpenReconcileItem,
jobStatusLabel,
renderPeriodRange,
} from "@/modules/reconcile/reconcile-labels";
import type {
AdminReconcileItemsData,
AdminReconcileJobRow,
} from "@/types/api/admin-reconcile";
export type ReconcileItemsFilter = "open" | "all";
type ReconcileJobDetailPanelProps = {
job: AdminReconcileJobRow;
items: AdminReconcileItemsData | null;
itemsLoading: boolean;
itemsFilter: ReconcileItemsFilter;
onItemsFilterChange: (filter: ReconcileItemsFilter) => void;
canWriteWallet: boolean;
actionBusy: boolean;
onCompleteCredit: (transferNo: string) => void;
onReverse: (transferNo: string) => void;
onManualProcess: (transferNo: string) => void;
onItemsPageChange: (page: number) => void;
onItemsPerPageChange: (perPage: number) => void;
};
export function ReconcileJobDetailPanel({
job,
items,
itemsLoading,
itemsFilter,
onItemsFilterChange,
canWriteWallet,
actionBusy,
onCompleteCredit,
onReverse,
onManualProcess,
onItemsPageChange,
onItemsPerPageChange,
}: ReconcileJobDetailPanelProps): React.ReactElement {
const { t } = useTranslation("reconcile");
const formatTs = useAdminDateTimeFormatter();
const mismatchCount = getJobSummaryValue(job.summary_json, "mismatch_count");
const itemCount = getJobSummaryValue(job.summary_json, "item_count");
const displayedItems = useMemo(() => {
if (!items) {
return [];
}
if (itemsFilter === "all") {
return items.items;
}
return items.items.filter(isOpenReconcileItem);
}, [items, itemsFilter]);
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 flex-col gap-3 border-b border-border/60 px-5 py-4 pr-12 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="space-y-1">
<h3 className="text-base font-semibold leading-none">{t("detailsTitle")}</h3>
<p className="font-mono text-xs text-muted-foreground break-all">{job.job_no}</p>
</div>
<dl className="grid grid-cols-[auto_auto] gap-x-4 gap-y-1 text-xs text-muted-foreground sm:grid-cols-[repeat(4,auto)]">
<div className="flex items-center gap-1.5">
<dt className="shrink-0">{t("status")}</dt>
<dd>
<AdminStatusBadge status={job.status}>{jobStatusLabel(job.status, t)}</AdminStatusBadge>
</dd>
</div>
<div className="flex items-center gap-1.5">
<dt className="shrink-0">{t("mismatchCount")}</dt>
<dd className="font-medium tabular-nums text-foreground">{mismatchCount}</dd>
</div>
<div className="flex items-center gap-1.5">
<dt className="shrink-0">{t("itemCount")}</dt>
<dd className="font-medium tabular-nums text-foreground">{itemCount}</dd>
</div>
<div className="col-span-2 flex min-w-0 items-start gap-1.5 sm:col-span-1">
<dt className="shrink-0 pt-0.5">{t("period")}</dt>
<dd className="min-w-0 font-mono leading-snug">{renderPeriodRange(job, formatTs)}</dd>
</div>
</dl>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant={itemsFilter === "open" ? "default" : "outline"}
className="h-8"
onClick={() => onItemsFilterChange("open")}
>
{t("filterOpen")}
</Button>
<Button
type="button"
size="sm"
variant={itemsFilter === "all" ? "default" : "outline"}
className="h-8"
onClick={() => onItemsFilterChange("all")}
>
{t("filterAll")}
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{itemsLoading && !items ? (
<AdminLoadingState minHeight="8rem" className="py-8" />
) : (
<>
{items ? (
<p className="mb-3 text-xs text-muted-foreground">
{t("filterCount", {
shown: displayedItems.length,
total: items.meta.total,
})}
</p>
) : null}
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[11rem]">{t("transferNo")}</TableHead>
<TableHead className="min-w-[10rem]">{t("walletTxnNo")}</TableHead>
<TableHead className="min-w-[7rem]">{t("mainSiteCheck")}</TableHead>
<TableHead className="min-w-[9rem]">{t("detectedAt")}</TableHead>
<TableHead className="min-w-[7.5rem]">{t("itemScanFindingColumn")}</TableHead>
<TableHead className="min-w-[5.5rem]">{t("itemCurrentStatusColumn")}</TableHead>
<TableHead className="min-w-[5.5rem] text-right">{t("differenceAmount")}</TableHead>
<TableHead className="w-[3.5rem] text-center">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemsLoading && items ? (
<AdminTableLoadingRow colSpan={8} />
) : displayedItems.length === 0 ? (
<AdminTableNoResourceRow
colSpan={8}
message={
itemsFilter === "open" ? t("filterOpenEmpty") : t("detailsEmpty")
}
/>
) : (
displayedItems.map((item) => (
<ReconcileItemRow
key={item.id}
row={item}
canWriteWallet={canWriteWallet}
actionBusy={actionBusy}
onCompleteCredit={onCompleteCredit}
onReverse={onReverse}
onManualProcess={onManualProcess}
/>
))
)}
</TableBody>
</Table>
</div>
{items?.meta ? (
<div className="mt-3 border-t border-border/60 pt-2">
<AdminListPaginationFooter
selectId={`reconcile-items-per-page-${job.id}`}
total={items.meta.total}
page={items.meta.current_page}
lastPage={Math.max(1, items.meta.last_page)}
perPage={items.meta.per_page}
loading={itemsLoading}
onPerPageChange={(n) => {
onItemsPerPageChange(n);
onItemsPageChange(1);
}}
onPageChange={onItemsPageChange}
/>
</div>
) : null}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import type { TFunction } from "i18next";
export function jobStatusLabel(status: string, t: TFunction): string {
switch (status) {
case "completed":
return t("statusCompleted");
case "running":
return t("statusRunning");
case "failed":
return t("statusFailed");
default:
return status;
}
}
export function itemStatusLabel(status: string, t: TFunction): string {
switch (status) {
case "mismatch":
return t("itemMismatch");
case "matched":
return t("itemMatched");
case "pending_check":
return t("itemPendingCheck");
case "stale_processing":
return t("itemStaleProcessing");
case "pending_reconcile":
return t("itemPendingReconcile");
case "missing_wallet_txn":
return t("itemMissingWalletTxn");
case "unexpected_wallet_txn":
return t("itemUnexpectedWalletTxn");
case "missing_refund":
return t("itemMissingRefund");
case "missing_reversal":
return t("itemMissingReversal");
case "main_site_record_missing":
return t("itemMainSiteRecordMissing");
case "main_site_failed":
return t("itemMainSiteFailed");
default:
return status;
}
}
export function mainSiteCheckLabel(status: string | null | undefined, t: TFunction): string {
switch (status) {
case "matched":
return t("mainSiteMatched");
case "not_found":
return t("mainSiteNotFound");
case "failed_on_main":
return t("mainSiteFailed");
case "unavailable":
return t("mainSiteUnavailable");
default:
return t("mainSiteSkipped");
}
}
/** 与钱包转账单列表 {@see wallet-console statusLabelT} 一致 */
export function transferOrderStatusLabel(status: string | null | undefined, t: TFunction): 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" });
default:
return status?.trim() ? status : "—";
}
}
export function itemCurrentStatusLabel(
row: {
current_transfer_status?: string | null;
is_resolved?: boolean;
resolved_at: string | null;
status: string;
},
t: TFunction,
): string {
const current = row.current_transfer_status?.trim();
if (current) {
return transferOrderStatusLabel(current, t);
}
if (row.is_resolved === true || row.resolved_at) {
return t("itemResolved");
}
return itemStatusLabel(row.status, t);
}
export function itemResolutionLabel(
row: { resolved_at: string | null; is_resolved?: boolean },
t: TFunction,
): string {
return row.is_resolved === true || row.resolved_at ? t("itemResolved") : t("itemUnresolved");
}
export function itemResolutionTone(
row: { resolved_at: string | null; is_resolved?: boolean },
): "success" | "warning" {
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
}
export function getJobSummaryValue(
summary: Record<string, unknown> | null | undefined,
key: string,
): number {
const raw = summary?.[key];
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
}
export function renderPeriodRange(
row: { period_start: string | null; period_end: string | null },
formatTs: (value: string | null | undefined) => string,
): string {
const from = row.period_start ? formatTs(row.period_start) : "—";
const to = row.period_end ? formatTs(row.period_end) : "—";
return `${from} ~ ${to}`;
}
export function isOpenReconcileItem(row: { is_resolved?: boolean; resolved_at: string | null }): boolean {
return row.is_resolved !== true && !row.resolved_at;
}

View File

@@ -0,0 +1,420 @@
"use client";
import { useTranslation } from "react-i18next";
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { drawStatusLabel } from "@/modules/draws/draw-display";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
formatPlainMoney,
formatUsagePercent,
optionText,
signedProfitCell,
type ReportResult,
} from "@/modules/reports/reports-utils";
import type { ReportDefinition } from "@/modules/reports/reports-definitions";
type ReportPreviewTableProps = {
report: ReportDefinition;
result: ReportResult | null;
loading: boolean;
error: string | null;
drawNoLabel?: string;
displayCurrency: string;
};
export function ReportPreviewTable({
report,
result,
loading,
error,
drawNoLabel = "",
displayCurrency,
}: ReportPreviewTableProps) {
const { t } = useTranslation(["reports", "common"]);
const playCodeLabel = useAdminPlayCodeLabel();
const formatTs = useAdminDateTimeFormatter();
if (!report.connected) {
return <AdminNoResourceState message={t("backendPending")} />;
}
if (loading) {
return (
<Table>
<TableBody>
<AdminTableLoadingRow colSpan={8} />
</TableBody>
</Table>
);
}
if (error) {
return (
<Table>
<TableBody>
<TableRow>
<TableCell colSpan={8} className="text-destructive">
{error}
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
if (!result || result.key !== report.key || result.rows.length === 0) {
return (
<Table>
<TableBody>
<AdminTableNoResourceRow colSpan={8} message={t("preview.empty")} />
</TableBody>
</Table>
);
}
switch (result.key) {
case "draw_profit":
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.drawProfit.primary")}</TableHead>
<TableHead>{t("preview.columns.drawProfit.secondary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.drawProfit.metricA")}</TableHead>
<TableHead className="text-right">{t("preview.columns.drawProfit.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.drawProfit.metricC")}</TableHead>
<TableHead className="text-right">{t("preview.columns.drawProfit.status")}</TableHead>
<TableHead className="text-right">{t("preview.columns.drawProfit.extra")}</TableHead>
<TableHead>{t("preview.columns.drawProfit.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">{result.raw.draw_no}</TableCell>
<TableCell>
<DrawStatusBadge
status={result.raw.draw_status}
label={drawStatusLabel(result.raw.draw_status, t)}
/>
</TableCell>
<TableCell className="text-right">{result.raw.order_count}</TableCell>
<TableCell className="text-right">{result.raw.ticket_item_count}</TableCell>
<TableCell className="text-right">
{formatPlainMoney(result.raw.total_bet_minor, result.raw.currency_code)}
</TableCell>
<TableCell className={signedProfitCell(result.raw.approx_house_gross_minor, result.raw.currency_code)}>
{formatPlainMoney(result.raw.approx_house_gross_minor, result.raw.currency_code)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(result.raw.total_payout_minor, result.raw.currency_code)}
</TableCell>
<TableCell></TableCell>
</TableRow>
{result.raw.settlement_batches.length > 0 ? (
<TableRow className="bg-muted/20">
<TableCell colSpan={8} className="py-2 text-xs font-medium text-muted-foreground">
{t("preview.sections.settlementBatches")}
</TableCell>
</TableRow>
) : null}
{result.raw.settlement_batches.map((batch) => (
<TableRow key={batch.id} className="bg-muted/10">
<TableCell>#{batch.id}</TableCell>
<TableCell>
<AdminStatusBadge status={batch.status}>{batch.status}</AdminStatusBadge>
</TableCell>
<TableCell className="text-right">{batch.total_ticket_count}</TableCell>
<TableCell className="text-right">{batch.total_win_count}</TableCell>
<TableCell className="text-right"></TableCell>
<TableCell className="text-right">
{formatPlainMoney(batch.total_payout_amount, result.raw.currency_code)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(batch.total_jackpot_payout_amount, result.raw.currency_code)}
</TableCell>
<TableCell>{formatTs(batch.finished_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
case "daily_profit":
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.dailyProfit.primary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.dailyProfit.metricA")}</TableHead>
<TableHead className="text-right">{t("preview.columns.dailyProfit.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.dailyProfit.metricC")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.raw.map((item) => (
<TableRow key={item.business_date}>
<TableCell className="font-medium">{item.business_date}</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.total_bet_minor, displayCurrency)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.total_payout_minor, displayCurrency)}
</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
case "player_win_loss":
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.playerWinLoss.primary")}</TableHead>
<TableHead>{t("preview.columns.playerWinLoss.secondary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.playerWinLoss.metricA")}</TableHead>
<TableHead className="text-right">{t("preview.columns.playerWinLoss.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.playerWinLoss.metricC")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.raw.map((item) => (
<TableRow key={item.player_id}>
<TableCell className="font-medium">{item.username}</TableCell>
<TableCell className="text-xs">
{adminAgentDisplayLabel(item)}
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.total_bet_minor, displayCurrency)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.total_payout_minor, displayCurrency)}
</TableCell>
<TableCell className={signedProfitCell(item.net_win_loss_minor, displayCurrency)}>
{formatPlainMoney(item.net_win_loss_minor, displayCurrency)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
case "play_dimension":
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.playDimension.primary")}</TableHead>
<TableHead>{t("preview.columns.playDimension.secondary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.playDimension.metricA")}</TableHead>
<TableHead className="text-right">{t("preview.columns.playDimension.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.playDimension.metricC")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.raw.map((item) => (
<TableRow key={`${item.play_code}-${item.dimension}`}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.dimension}D</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.total_bet_minor, displayCurrency)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.total_payout_minor, displayCurrency)}
</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
case "player_transfer":
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.playerTransfer.primary")}</TableHead>
<TableHead>{t("preview.columns.playerTransfer.secondary")}</TableHead>
<TableHead>{t("preview.columns.playerTransfer.metricA")}</TableHead>
<TableHead>{t("preview.columns.playerTransfer.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.playerTransfer.metricC")}</TableHead>
<TableHead>{t("preview.columns.playerTransfer.status")}</TableHead>
<TableHead>{t("preview.columns.playerTransfer.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.raw.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">{item.transfer_no}</TableCell>
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
<TableCell>{item.direction}</TableCell>
<TableCell>
<AdminStatusBadge status={item.status}>{item.status}</AdminStatusBadge>
</TableCell>
<TableCell className="text-right">
{item.currency_code} {item.amount}
</TableCell>
<TableCell className="max-w-[12rem] truncate">{item.external_ref_no || "—"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
case "hot_number_risk":
return (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.hotNumberRisk.primary")}</TableHead>
<TableHead>{t("preview.columns.hotNumberRisk.secondary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.hotNumberRisk.metricA")}</TableHead>
<TableHead className="text-right">{t("preview.columns.hotNumberRisk.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.hotNumberRisk.metricC")}</TableHead>
<TableHead>{t("preview.columns.hotNumberRisk.status")}</TableHead>
<TableHead>{t("preview.columns.hotNumberRisk.extra")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
<TableCell>{result.raw.draw_no}</TableCell>
<TableCell className="text-right">
{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}
</TableCell>
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
</TableRow>
</TableBody>
</Table>
{result.raw.logs.items.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.sections.lockLogs")}</TableHead>
<TableHead>{t("preview.columns.hotNumberRisk.secondary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.hotNumberRisk.metricA")}</TableHead>
<TableHead>{t("preview.columns.hotNumberRisk.metricB")}</TableHead>
<TableHead>{t("preview.columns.hotNumberRisk.metricC")}</TableHead>
<TableHead>{t("preview.columns.hotNumberRisk.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.raw.logs.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>{item.action_type}</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.amount, result.raw.currency_code)}
</TableCell>
<TableCell>{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.ticket_no || "—"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</div>
);
case "sold_out_number":
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.soldOut.primary")}</TableHead>
<TableHead>{t("preview.columns.soldOut.secondary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.soldOut.metricA")}</TableHead>
<TableHead className="text-right">{t("preview.columns.soldOut.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.soldOut.metricC")}</TableHead>
<TableHead>{t("preview.columns.soldOut.status")}</TableHead>
<TableHead>{t("preview.columns.soldOut.extra")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.raw.map((item) => (
<TableRow key={item.normalized_number}>
<TableCell className="font-medium">{item.normalized_number}</TableCell>
<TableCell>{drawNoLabel}</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.total_cap_amount, displayCurrency)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.locked_amount, displayCurrency)}
</TableCell>
<TableCell className="text-right">
{formatPlainMoney(item.remaining_amount, displayCurrency)}
</TableCell>
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
case "admin_audit":
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.adminAudit.primary")}</TableHead>
<TableHead>{t("preview.columns.adminAudit.secondary")}</TableHead>
<TableHead>{t("preview.columns.adminAudit.metricA")}</TableHead>
<TableHead>{t("preview.columns.adminAudit.metricB")}</TableHead>
<TableHead>{t("preview.columns.adminAudit.metricC")}</TableHead>
<TableHead>{t("preview.columns.adminAudit.status")}</TableHead>
<TableHead>{t("preview.columns.adminAudit.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.raw.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>{item.operator_type}</TableCell>
<TableCell>{item.operator_id}</TableCell>
<TableCell>{item.module_code}</TableCell>
<TableCell>{item.action_code}</TableCell>
<TableCell>{item.target_type || "—"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
default:
return <AdminLoadingInline />;
}
}

View File

@@ -1,29 +1,17 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
CalendarDays,
Database,
FileDown,
FileSpreadsheet,
ListFilter,
Search,
ShieldAlert,
ShieldCheck,
Ticket,
Users,
WalletCards,
} from "lucide-react";
import { ArrowUpRight, Database, FileDown, FileSpreadsheet, Search } from "lucide-react";
import { getAdminAuditLogs } from "@/api/admin-audit";
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { drawStatusLabel } from "@/modules/draws/draw-display";
import { getAdminPlayers } from "@/api/admin-player";
@@ -42,25 +30,17 @@ import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
import { getAdminUsers } from "@/api/admin-users";
import { getAdminTransferOrders } from "@/api/admin-wallet";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AUDIT_VIEW,
PRD_REPORT_EXPORT,
PRD_REPORT_VIEW,
PRD_RISK_ACCESS_ANY,
PRD_WALLET_TRANSFER_ACCESS_ANY,
} from "@/lib/admin-prd";
import { PRD_REPORT_EXPORT } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
@@ -68,559 +48,87 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminAuditLogRow } from "@/types/api/admin-audit";
import type { AdminDrawListItem } from "@/types/api/admin-draws";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminPlayerRow } from "@/types/api/admin-player";
import type { AdminRiskPoolRow, AdminRiskPoolShowData } from "@/types/api/admin-risk";
import type { AdminUserPermissionRow } from "@/types/api/admin-user";
import type { AdminTransferOrderItem } from "@/types/api/admin-wallet";
import type {
AdminReportDailyProfitRow,
AdminReportPlayDimensionRow,
AdminReportPlayerWinLossRow,
} from "@/types/api/admin-reports";
import { getAdminDraws } from "@/api/admin-draws";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
type ExportFormat = "csv" | "excel";
type ExportCell = string | number | null;
type ExportRow = Record<string, ExportCell>;
type SearchKind = "draw" | "player" | "operator";
import { ReportPreviewTable } from "@/modules/reports/report-preview-tables";
import {
CATEGORY_SHORTCUT_HREF,
REPORT_CATEGORY_ORDER,
REPORT_DEFINITIONS,
type FieldKey,
type ReportCategory,
type ReportKey,
} from "@/modules/reports/reports-definitions";
import {
buildDailyProfitRowsAndSummary,
buildPlayDimensionRowsAndSummary,
buildPlayerTransferRowsAndSummary,
createDefaultFilters,
downloadBlob,
drawRowsFromSummary,
emptyFilters,
emptySearch,
formatExportInstant,
formatPlainMoney,
formatUsagePercent,
headlineStats,
metaFromList,
normalizeFilenamePart,
optionText,
parsePositiveInteger,
reportHasPeriodField,
reportListParams,
resolveDisplayCurrency,
resolveDraw,
type ExportFormat,
type ReportFilters,
type ReportResult,
type SearchKind,
type SearchState,
} from "@/modules/reports/reports-utils";
type ReportKey =
| "draw_profit"
| "daily_profit"
| "player_win_loss"
| "player_transfer"
| "hot_number_risk"
| "play_dimension"
| "sold_out_number"
| "admin_audit";
export type { ReportCategory } from "@/modules/reports/reports-definitions";
type ReportDefinition = {
key: ReportKey;
category: ReportCategory;
icon: typeof FileSpreadsheet;
filterKind: FilterKind;
scope: string;
fields: FieldKey[];
connected: boolean;
requiredAny: readonly string[];
type ReportsConsoleProps = {
initialCategory?: ReportCategory;
};
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
const REPORTS: ReportDefinition[] = [
{ key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
{ key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
{ key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
{ key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_WALLET_TRANSFER_ACCESS_ANY },
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true, requiredAny: [PRD_AUDIT_VIEW] },
];
type PreviewColumns = {
primary: string;
secondary: string;
metricA: string;
metricB: string;
metricC: string;
status: string;
extra: string;
time: string;
};
type ReportFilters = {
drawNo: string;
drawId: number | null;
number: string;
player: string;
playerId: number | null;
play: string;
operator: string;
operatorId: number | null;
dateFrom: string;
dateTo: string;
};
type ReportMeta = {
total: number;
page: number;
perPage: number;
lastPage: number;
};
type ReportResult =
| { key: "draw_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminDrawFinanceSummaryData }
| { key: "daily_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportDailyProfitRow[] }
| { key: "player_win_loss"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayerWinLossRow[] }
| { key: "player_transfer"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminTransferOrderItem[] }
| { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData }
| { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] }
| { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] }
| { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
type StatCard = {
label: string;
value: string;
tone?: "default" | "good" | "warn" | "bad";
};
type SearchState = {
open: SearchKind | null;
query: string;
loading: boolean;
draws: AdminDrawListItem[];
players: AdminPlayerRow[];
operators: AdminUserPermissionRow[];
};
type PlayOption = {
code: string;
label: string;
};
const emptyFilters: ReportFilters = {
drawNo: "",
drawId: null,
number: "",
player: "",
playerId: null,
play: "",
operator: "",
operatorId: null,
dateFrom: "",
dateTo: "",
};
function isoDateLocal(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function defaultReportPeriod(): Pick<ReportFilters, "dateFrom" | "dateTo"> {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 29);
return { dateFrom: isoDateLocal(from), dateTo: isoDateLocal(to) };
}
function createDefaultFilters(): ReportFilters {
return { ...emptyFilters, ...defaultReportPeriod() };
}
function reportHasPeriodField(report: ReportDefinition): boolean {
return report.fields.includes("period");
}
function resolveDisplayCurrency(apiCode?: string | null): string {
const trimmed = apiCode?.trim();
if (trimmed) {
return trimmed;
}
const fallback = getCachedAdminCurrencies().find((row) => row.is_enabled && row.is_bettable)?.code;
return fallback?.trim() || "NPR";
}
const emptySearch: SearchState = {
open: null,
query: "",
loading: false,
draws: [],
players: [],
operators: [],
};
function categoryTone(category: ReportCategory): string {
switch (category) {
case "wallet":
return "border-emerald-200 bg-emerald-50 text-emerald-700";
case "risk":
return "border-red-200 bg-red-50 text-red-700";
case "audit":
return "border-slate-200 bg-slate-50 text-slate-700";
default:
return "border-blue-200 bg-blue-50 text-blue-700";
}
}
function statTone(tone: StatCard["tone"]): string {
switch (tone) {
case "good":
return "border-emerald-200 bg-emerald-50/70 text-emerald-900";
case "warn":
return "border-amber-200 bg-amber-50/70 text-amber-950";
case "bad":
return "border-red-200 bg-red-50/70 text-red-950";
default:
return "border-border/70 bg-card text-foreground";
}
}
function formatKind(kind: FilterKind, t: (key: string) => string): string {
return t(`filters.${kind}`);
}
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function normalizeFilenamePart(value: string): string {
return value.trim().replace(/[\\/:*?"<>|\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
function formatExportInstant(iso: string | null | undefined): ExportCell {
return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
}
function buildDailyProfitRowsAndSummary(
items: AdminReportDailyProfitRow[],
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "rows" | "summary"> {
let totalBet = 0;
let totalPayout = 0;
let totalGross = 0;
const rows = items.map((item) => {
totalBet += item.total_bet_minor;
totalPayout += item.total_payout_minor;
totalGross += item.approx_house_gross_minor;
return {
business_date: item.business_date,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
approx_house_gross_minor: item.approx_house_gross_minor,
};
});
return {
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
{
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(totalGross, currencyCode),
tone: totalGross >= 0 ? "good" : "bad",
},
],
};
}
function buildPlayerTransferRowsAndSummary(
items: AdminTransferOrderItem[],
total: number,
page: number,
perPage: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
): Pick<Extract<ReportResult, { key: "player_transfer" }>, "rows" | "summary" | "meta"> {
let transferInCount = 0;
let transferOutCount = 0;
const rows = items.map((item) => {
if (item.direction === "in") {
transferInCount += 1;
} else if (item.direction === "out") {
transferOutCount += 1;
}
return {
id: item.id,
transfer_no: item.transfer_no,
player_id: item.player_id,
username: item.username,
nickname: item.nickname,
direction: item.direction,
currency_code: item.currency_code,
amount: item.amount,
status: item.status,
external_ref_no: item.external_ref_no,
fail_reason: item.fail_reason,
created_at: formatExportInstant(item.created_at),
finished_at: formatExportInstant(item.finished_at),
};
});
return {
rows,
meta: { total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)) },
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
{ label: pageScopedLabel("transferIn"), value: String(transferInCount), tone: "good" },
{ label: pageScopedLabel("transferOut"), value: String(transferOutCount), tone: "warn" },
],
};
}
function buildPlayDimensionRowsAndSummary(
items: AdminReportPlayDimensionRow[],
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
let totalBet = 0;
let totalPayout = 0;
const rows = items.map((item) => {
totalBet += item.total_bet_minor;
totalPayout += item.total_payout_minor;
return {
play_code: item.play_code,
dimension: item.dimension,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
approx_house_gross_minor: item.approx_house_gross_minor,
};
});
return {
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
],
};
}
function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
return {
total: meta.total,
page: meta.current_page,
perPage: meta.per_page,
lastPage: meta.last_page,
};
}
function formatPlainMoney(value: number, currencyCode: string | null | undefined): string {
return formatAdminMinorUnits(value, currencyCode || "NPR");
}
function signedProfitCell(amount: number, currencyCode: string | null | undefined): string {
return cn("text-center tabular-nums", signedMoneyClass(amount, true));
}
function formatUsagePercent(ratio: number | null | undefined): string {
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
}
function optionText(...parts: Array<string | number | null | undefined>): string {
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
}
function reportListParams(
filters: ReportFilters,
page: number,
perPage: number,
) {
return {
page,
per_page: perPage,
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
player_id: filters.playerId ?? undefined,
play_code: filters.play.trim() || undefined,
};
}
function parsePositiveInteger(value: string): number | null {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
async function resolveDraw(
filters: ReportFilters,
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
): Promise<{ id: number; draw_no: string }> {
if (filters.drawId != null && filters.drawId > 0) {
const drawNo = filters.drawNo.trim();
return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) };
}
const drawNo = filters.drawNo.trim();
if (!drawNo) {
throw new LotteryApiBizError(messages.drawNoRequired, -1, null);
}
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
if (!matched) {
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
}
return { id: matched.id, draw_no: matched.draw_no };
}
function drawRowsFromSummary(summary: AdminDrawFinanceSummaryData): ExportRow[] {
return [
{
row_type: "summary",
draw_id: summary.draw_id,
draw_no: summary.draw_no,
draw_status: summary.draw_status,
currency_code: summary.currency_code,
order_count: summary.order_count,
ticket_item_count: summary.ticket_item_count,
total_bet_minor: summary.total_bet_minor,
total_win_payout_minor: summary.total_win_payout_minor,
total_jackpot_win_minor: summary.total_jackpot_win_minor,
total_payout_minor: summary.total_payout_minor,
approx_house_gross_minor: summary.approx_house_gross_minor,
},
...summary.settlement_batches.map((batch) => ({
row_type: "settlement_batch",
draw_id: summary.draw_id,
draw_no: summary.draw_no,
settlement_batch_id: batch.id,
settlement_status: batch.status,
total_ticket_count: batch.total_ticket_count,
total_win_count: batch.total_win_count,
total_payout_amount: batch.total_payout_amount,
total_jackpot_payout_amount: batch.total_jackpot_payout_amount,
finished_at: formatExportInstant(batch.finished_at),
})),
];
}
function resultRowCount(result: ReportResult | null): number {
return result?.rows.length ?? 0;
}
function defaultSummaryCards(
reportKey: ReportKey,
filters: ReportFilters,
t: (key: string) => string,
): StatCard[] {
const periodLabel =
filters.dateFrom && filters.dateTo
? `${filters.dateFrom} ~ ${filters.dateTo}`
: filters.dateFrom || filters.dateTo || t("preview.stats.notQueried");
switch (reportKey) {
case "draw_profit":
return [
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
];
case "daily_profit":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.period"), value: periodLabel },
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
];
case "player_win_loss":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
{ label: t("preview.stats.players"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
];
case "player_transfer":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
{ label: t("preview.stats.transferIn"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.transferOut"), value: t("preview.stats.notQueried") },
];
case "hot_number_risk":
return [
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
{ label: t("fields.number"), value: filters.number || t("preview.stats.notSet") },
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.logs"), value: t("preview.stats.notQueried") },
];
case "play_dimension":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.play"), value: filters.play || t("filterAll") },
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
];
case "sold_out_number":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
{ label: t("preview.stats.currency"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
];
case "admin_audit":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") },
{ label: t("preview.stats.modules"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.operators"), value: t("preview.stats.notQueried") },
];
default:
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.exportRows"), value: "0" },
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
];
}
}
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
const { t, i18n } = useTranslation(["reports", "common"]);
export function ReportsConsole({ initialCategory = "profit" }: ReportsConsoleProps) {
const { t } = useTranslation(["reports", "common"]);
const profile = useAdminProfile();
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]);
const permissionSlugs = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const canExportReports = adminHasAnyPermission(permissionSlugs, [PRD_REPORT_EXPORT]);
useAdminCurrencyCatalog();
useAdminPlayTypeCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const formatTs = useAdminDateTimeFormatter();
const filteredReports = useMemo(() => {
const visible = REPORTS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny));
return initialCategory ? visible.filter((report) => report.category === initialCategory) : visible;
}, [initialCategory, permissionSlugs]);
const [selectedKey, setSelectedKey] = useState<ReportKey>(
filteredReports[0]?.key ?? REPORTS[0].key,
const playOptions = useCachedPlayTypeOptions();
const tRef = useTranslationRef(["reports", "common"]);
const searchParams = useSearchParams();
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
const visibleReports = useMemo(
() => REPORT_DEFINITIONS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny)),
[permissionSlugs],
);
const categoriesWithReports = useMemo(
() => REPORT_CATEGORY_ORDER.filter((cat) => visibleReports.some((report) => report.category === cat)),
[visibleReports],
);
const activeCategory = categoriesWithReports.includes(initialCategory)
? initialCategory
: (categoriesWithReports[0] ?? "profit");
const categoryReports = useMemo(
() => visibleReports.filter((report) => report.category === activeCategory),
[activeCategory, visibleReports],
);
const [selectedKey, setSelectedKey] = useState<ReportKey>(categoryReports[0]?.key ?? "daily_profit");
const [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
const [result, setResult] = useState<ReportResult | null>(null);
@@ -630,128 +138,26 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const [perPage, setPerPage] = useState(20);
const [exporting, setExporting] = useState<ExportFormat | null>(null);
const [search, setSearch] = useState<SearchState>(emptySearch);
const playOptions = useCachedPlayTypeOptions();
const tRef = useTranslationRef(["reports", "common"]);
const searchParams = useSearchParams();
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
const selectedReport = filteredReports.find((report) => report.key === selectedKey) ?? filteredReports[0] ?? REPORTS[0];
const selectedReport =
categoryReports.find((report) => report.key === selectedKey) ?? categoryReports[0] ?? REPORT_DEFINITIONS[0];
const canQuerySelected = adminHasAnyPermission(permissionSlugs, selectedReport.requiredAny);
useEffect(() => {
if (!filteredReports.some((report) => report.key === selectedKey)) {
setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key);
if (categoryReports.length === 0) {
return;
}
}, [filteredReports, selectedKey]);
if (!categoryReports.some((report) => report.key === selectedKey)) {
setSelectedKey(categoryReports[0].key);
}
}, [categoryReports, selectedKey]);
const pageScopedLabel = useCallback(
(statKey: string) => t(`preview.stats.${statKey}`),
[t],
);
const previewColumns = useMemo<PreviewColumns>(() => {
switch (selectedReport.key) {
case "draw_profit":
return {
primary: t("preview.columns.drawProfit.primary"),
secondary: t("preview.columns.drawProfit.secondary"),
metricA: t("preview.columns.drawProfit.metricA"),
metricB: t("preview.columns.drawProfit.metricB"),
metricC: t("preview.columns.drawProfit.metricC"),
status: t("preview.columns.drawProfit.status"),
extra: t("preview.columns.drawProfit.extra"),
time: t("preview.columns.drawProfit.time"),
};
case "daily_profit":
return {
primary: t("preview.columns.dailyProfit.primary"),
secondary: t("preview.columns.dailyProfit.secondary"),
metricA: t("preview.columns.dailyProfit.metricA"),
metricB: t("preview.columns.dailyProfit.metricB"),
metricC: t("preview.columns.dailyProfit.metricC"),
status: t("preview.columns.dailyProfit.status"),
extra: t("preview.columns.dailyProfit.extra"),
time: t("preview.columns.dailyProfit.time"),
};
case "player_win_loss":
return {
primary: t("preview.columns.playerWinLoss.primary"),
secondary: t("agentColumns.agent", { ns: "common" }),
metricA: t("preview.columns.playerWinLoss.metricA"),
metricB: t("preview.columns.playerWinLoss.metricB"),
metricC: t("preview.columns.playerWinLoss.metricC"),
status: t("preview.columns.playerWinLoss.status"),
extra: t("preview.columns.playerWinLoss.extra"),
time: t("preview.columns.playerWinLoss.time"),
};
case "player_transfer":
return {
primary: t("preview.columns.playerTransfer.primary"),
secondary: t("preview.columns.playerTransfer.secondary"),
metricA: t("preview.columns.playerTransfer.metricA"),
metricB: t("preview.columns.playerTransfer.metricB"),
metricC: t("preview.columns.playerTransfer.metricC"),
status: t("preview.columns.playerTransfer.status"),
extra: t("preview.columns.playerTransfer.extra"),
time: t("preview.columns.playerTransfer.time"),
};
case "hot_number_risk":
return {
primary: t("preview.columns.hotNumberRisk.primary"),
secondary: t("preview.columns.hotNumberRisk.secondary"),
metricA: t("preview.columns.hotNumberRisk.metricA"),
metricB: t("preview.columns.hotNumberRisk.metricB"),
metricC: t("preview.columns.hotNumberRisk.metricC"),
status: t("preview.columns.hotNumberRisk.status"),
extra: t("preview.columns.hotNumberRisk.extra"),
time: t("preview.columns.hotNumberRisk.time"),
};
case "play_dimension":
return {
primary: t("preview.columns.playDimension.primary"),
secondary: t("preview.columns.playDimension.secondary"),
metricA: t("preview.columns.playDimension.metricA"),
metricB: t("preview.columns.playDimension.metricB"),
metricC: t("preview.columns.playDimension.metricC"),
status: t("preview.columns.playDimension.status"),
extra: t("preview.columns.playDimension.extra"),
time: t("preview.columns.playDimension.time"),
};
case "sold_out_number":
return {
primary: t("preview.columns.soldOut.primary"),
secondary: t("preview.columns.soldOut.secondary"),
metricA: t("preview.columns.soldOut.metricA"),
metricB: t("preview.columns.soldOut.metricB"),
metricC: t("preview.columns.soldOut.metricC"),
status: t("preview.columns.soldOut.status"),
extra: t("preview.columns.soldOut.extra"),
time: t("preview.columns.soldOut.time"),
};
case "admin_audit":
return {
primary: t("preview.columns.adminAudit.primary"),
secondary: t("preview.columns.adminAudit.secondary"),
metricA: t("preview.columns.adminAudit.metricA"),
metricB: t("preview.columns.adminAudit.metricB"),
metricC: t("preview.columns.adminAudit.metricC"),
status: t("preview.columns.adminAudit.status"),
extra: t("preview.columns.adminAudit.extra"),
time: t("preview.columns.adminAudit.time"),
};
default:
return {
primary: t("preview.columns.primary"),
secondary: t("preview.columns.secondary"),
metricA: t("preview.columns.metricA"),
metricB: t("preview.columns.metricB"),
metricC: t("preview.columns.metricC"),
status: t("preview.columns.status"),
extra: t("preview.columns.extra"),
time: t("preview.columns.time"),
};
}
}, [selectedReport.key, t]);
const exportFileBase = useMemo(() => {
const segments: string[] = [selectedReport.key];
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
@@ -795,7 +201,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}, [search.open, search.query, loadSearchOptions]);
const queryReport = useCallback(async () => {
if (!canViewReports) {
if (!canQuerySelected) {
return;
}
setLoading(true);
@@ -816,8 +222,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
rows: drawRowsFromSummary(summary),
meta: null,
summary: [
{ label: t("preview.stats.bet"), value: formatPlainMoney(summary.total_bet_minor, summary.currency_code) },
{ label: t("preview.stats.payout"), value: formatPlainMoney(summary.total_payout_minor, summary.currency_code) },
{
label: t("preview.stats.houseGross"),
value: formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code),
@@ -829,12 +233,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
break;
}
case "daily_profit": {
const payload = await getAdminReportDailyProfit(
reportListParams(filters, page, perPage),
);
const payload = await getAdminReportDailyProfit(reportListParams(filters, page, perPage));
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
const next = buildDailyProfitRowsAndSummary(
payload.items,
payload.meta.total,
t,
pageScopedLabel,
currencyCode,
);
setResult({
key: "daily_profit",
raw: payload.items,
@@ -845,38 +253,28 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
break;
}
case "player_win_loss": {
const payload = await getAdminReportPlayerWinLoss(
reportListParams(filters, page, perPage),
);
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const rows = payload.items.map((item) => ({
player_id: item.player_id,
username: item.username,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
net_win_loss_minor: item.net_win_loss_minor,
}));
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
setResult({
key: "player_win_loss",
raw: payload.items,
rows,
rows: payload.items.map((item) => ({
player_id: item.player_id,
username: item.username,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
net_win_loss_minor: item.net_win_loss_minor,
})),
meta: metaFromList(payload.meta),
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
currencyCode,
),
tone: (() => {
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
return houseGross >= 0 ? "good" : "bad";
})(),
value: formatPlainMoney(houseGross, currencyCode),
tone: houseGross >= 0 ? "good" : "bad",
},
{ label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) },
],
});
break;
@@ -897,7 +295,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
payload.page,
payload.per_page,
t,
pageScopedLabel,
);
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
setResult({
@@ -920,45 +317,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
});
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
setDisplayCurrency(resolveDisplayCurrency(detail.currency_code));
const rows: ExportRow[] = [
{
row_type: "risk_pool",
draw_id: detail.draw_id,
draw_no: detail.draw_no,
number: detail.pool.normalized_number,
total_cap_amount: detail.pool.total_cap_amount,
locked_amount: detail.pool.locked_amount,
remaining_amount: detail.pool.remaining_amount,
sold_out_status: detail.pool.sold_out_status,
is_sold_out: detail.pool.is_sold_out ? 1 : 0,
usage_ratio: detail.pool.usage_ratio,
version: detail.pool.version,
},
...detail.logs.items.map((item) => ({
row_type: "lock_log",
draw_id: detail.draw_id,
draw_no: detail.draw_no,
number: detail.pool.normalized_number,
log_id: item.id,
action_type: item.action_type,
amount: item.amount,
source_reason: item.source_reason,
ticket_item_id: item.ticket_item_id,
ticket_no: item.ticket_no,
play_code: item.play_code,
player_id: item.player_id,
created_at: formatExportInstant(item.created_at),
})),
];
setResult({
key: "hot_number_risk",
raw: detail,
rows,
rows: [],
meta: metaFromList(detail.logs.meta),
summary: [
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" },
{
label: t("preview.stats.usage"),
value: formatUsagePercent(detail.pool.usage_ratio),
tone: detail.pool.is_sold_out ? "bad" : "warn",
},
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
],
});
@@ -970,42 +339,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
drawNoNotFound: (drawNo) =>
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
});
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
const payload = await getAdminRiskPools(draw.id, {
page,
per_page: perPage,
sold_out_only: true,
sort: "number_asc",
});
setDisplayCurrency(resolveDisplayCurrency(payload.currency_code));
const rows = payload.items.map((item) => ({
draw_id: payload.draw_id,
draw_no: payload.draw_no,
currency_code: payload.currency_code,
normalized_number: item.normalized_number,
total_cap_amount: item.total_cap_amount,
locked_amount: item.locked_amount,
remaining_amount: item.remaining_amount,
sold_out_status: item.sold_out_status,
is_sold_out: item.is_sold_out ? 1 : 0,
usage_ratio: item.usage_ratio,
version: item.version,
}));
setResult({
key: "sold_out_number",
raw: payload.items,
rows,
rows: payload.items.map((item) => ({ normalized_number: item.normalized_number })),
meta: metaFromList(payload.meta),
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total), tone: payload.meta.total > 0 ? "bad" : "good" },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{
label: t("preview.stats.records"),
value: String(payload.meta.total),
tone: payload.meta.total > 0 ? "bad" : "good",
},
{ label: t("preview.stats.drawNo"), value: payload.draw_no },
{ label: t("preview.stats.currency"), value: payload.currency_code || "-" },
],
});
break;
}
case "play_dimension": {
const payload = await getAdminReportPlayDimension(
reportListParams(filters, page, perPage),
);
const payload = await getAdminReportPlayDimension(reportListParams(filters, page, perPage));
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, currencyCode);
setResult({
key: "play_dimension",
raw: payload.items,
@@ -1025,28 +386,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
start_date: filters.dateFrom || undefined,
end_date: filters.dateTo || undefined,
});
const rows = payload.items.map((item) => ({
id: item.id,
operator_type: item.operator_type,
operator_id: item.operator_id,
module_code: item.module_code,
action_code: item.action_code,
target_type: item.target_type,
target_id: item.target_id,
ip: item.ip,
user_agent: item.user_agent,
created_at: formatExportInstant(item.created_at),
}));
setResult({
key: "admin_audit",
raw: payload.items,
rows,
rows: payload.items.map((item) => ({ id: item.id })),
meta: metaFromList(payload.meta),
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: t("preview.stats.modules"), value: String(new Set(payload.items.map((item) => item.module_code)).size) },
{ label: t("preview.stats.operators"), value: String(new Set(payload.items.map((item) => item.operator_id)).size) },
{
label: t("preview.stats.operators"),
value: String(new Set(payload.items.map((item) => item.operator_id)).size),
},
],
});
break;
@@ -1061,14 +411,12 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
} finally {
setLoading(false);
}
}, [canViewReports, filters, page, perPage, selectedReport]);
}, [canQuerySelected, filters, page, perPage, pageScopedLabel, selectedReport.key, t, tRef]);
useEffect(() => {
queueMicrotask(() => {
setResult(null);
setError(null);
setPage(1);
});
setResult(null);
setError(null);
setPage(1);
}, [selectedKey]);
useEffect(() => {
@@ -1076,25 +424,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
...prev,
drawNo: drawNoFromUrl || prev.drawNo,
}));
if (drawNoFromUrl && filteredReports.some((report) => report.key === "draw_profit")) {
if (drawNoFromUrl && visibleReports.some((report) => report.key === "draw_profit")) {
setSelectedKey("draw_profit");
}
}, [drawNoFromUrl, filteredReports]);
}, [drawNoFromUrl, visibleReports]);
useEffect(() => {
queueMicrotask(() => {
setResult(null);
setError(null);
setPage(1);
});
}, []);
useEffect(() => {
if (result && result.key === selectedReport.key && selectedReport.connected) {
queueMicrotask(() => {
void queryReport();
});
if (!result || result.key !== selectedReport.key || !selectedReport.connected) {
return;
}
void queryReport();
}, [page, perPage]);
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
@@ -1152,42 +491,48 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const open = search.open === kind;
return (
<div className="grid gap-1.5">
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label>
<div className="min-w-[12rem] flex-1">
<Label className="sr-only" htmlFor={`report-${kind}`}>
{t(`fields.${labelKey}`)}
</Label>
<Popover
open={open}
onOpenChange={(nextOpen) => {
setSearch((prev) =>
nextOpen
? {
...prev,
open: kind,
query: value,
}
: emptySearch,
nextOpen ? { ...prev, open: kind, query: value } : emptySearch,
);
}}
modal={false}
>
<div className="flex gap-2">
<Input
id={`report-${kind}`}
value={value}
onChange={(e) => {
const next = e.target.value;
if (kind === "draw") {
setFilters((prev) => ({ ...prev, drawNo: next, drawId: null }));
} else if (kind === "player") {
setFilters((prev) => ({ ...prev, player: next, playerId: null }));
} else {
setFilters((prev) => ({ ...prev, operator: next, operatorId: null }));
<div className="flex gap-1.5">
<Input
id={`report-${kind}`}
className="h-8"
value={value}
onChange={(e) => {
const next = e.target.value;
if (kind === "draw") {
setFilters((prev) => ({ ...prev, drawNo: next, drawId: null }));
} else if (kind === "player") {
setFilters((prev) => ({ ...prev, player: next, playerId: null }));
} else {
setFilters((prev) => ({ ...prev, operator: next, operatorId: null }));
}
}}
placeholder={t(`placeholders.${labelKey}`)}
/>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2"
aria-label={t("searchPicker.open")}
/>
}
}}
placeholder={t(`placeholders.${labelKey}`)}
/>
<PopoverTrigger render={<Button type="button" variant="outline" className="shrink-0" aria-label={t("searchPicker.open")} />}>
<Search data-icon="inline-start" />
{t("searchPicker.select")}
>
<Search className="size-3.5" />
</PopoverTrigger>
</div>
<PopoverContent
@@ -1202,65 +547,70 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))}
/>
<div className="mt-2 max-h-64 overflow-auto">
{search.loading ? (
<AdminLoadingInline className="py-2" />
) : null}
{!search.loading && kind === "draw" ? (
search.draws.map((item) => (
<button
key={item.id}
type="button"
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
onClick={() => {
setFilters((prev) => ({ ...prev, drawNo: item.draw_no, drawId: item.id }));
setSearch(emptySearch);
}}
>
<span className="font-medium">{item.draw_no}</span>
<DrawStatusBadge status={item.status} label={drawStatusLabel(item.status, t)} />
</button>
))
) : null}
{!search.loading && kind === "player" ? (
search.players.map((item) => (
<button
key={item.id}
type="button"
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
onClick={() => {
setFilters((prev) => ({
...prev,
player: optionText(item.username, item.site_player_id, `ID ${item.id}`),
playerId: item.id,
}));
setSearch(emptySearch);
}}
>
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname, item.site_player_id)}</span>
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
</button>
))
) : null}
{!search.loading && kind === "operator" ? (
search.operators.map((item) => (
<button
key={item.id}
type="button"
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
onClick={() => {
setFilters((prev) => ({
...prev,
operator: optionText(item.username, item.nickname, `ID ${item.id}`),
operatorId: item.id,
}));
setSearch(emptySearch);
}}
>
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname)}</span>
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
</button>
))
) : null}
{search.loading ? <AdminLoadingInline className="py-2" /> : null}
{!search.loading && kind === "draw"
? search.draws.map((item) => (
<button
key={item.id}
type="button"
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
onClick={() => {
setFilters((prev) => ({ ...prev, drawNo: item.draw_no, drawId: item.id }));
setSearch(emptySearch);
}}
>
<span className="font-medium">{item.draw_no}</span>
<DrawStatusBadge
status={item.status}
label={drawStatusLabel(item.status, t)}
/>
</button>
))
: null}
{!search.loading && kind === "player"
? search.players.map((item) => (
<button
key={item.id}
type="button"
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
onClick={() => {
setFilters((prev) => ({
...prev,
player: optionText(item.username, item.site_player_id, `ID ${item.id}`),
playerId: item.id,
}));
setSearch(emptySearch);
}}
>
<span className="min-w-0 truncate font-medium">
{optionText(item.username, item.nickname, item.site_player_id)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
</button>
))
: null}
{!search.loading && kind === "operator"
? search.operators.map((item) => (
<button
key={item.id}
type="button"
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
onClick={() => {
setFilters((prev) => ({
...prev,
operator: optionText(item.username, item.nickname, `ID ${item.id}`),
operatorId: item.id,
}));
setSearch(emptySearch);
}}
>
<span className="min-w-0 truncate font-medium">
{optionText(item.username, item.nickname)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
</button>
))
: null}
</div>
</PopoverContent>
</Popover>
@@ -1294,15 +644,21 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}
if (field === "play") {
return (
<div key={field} className="grid gap-1.5">
<Label htmlFor="report-play">{t("fields.play")}</Label>
<div key={field} className="min-w-[10rem] flex-1">
<Label className="sr-only" htmlFor="report-play">
{t("fields.play")}
</Label>
<Select
modal={false}
value={filters.play || "__none__"}
onValueChange={(value) => updateFilter("play", value === "__none__" ? "" : String(value))}
>
<SelectTrigger id="report-play" className="h-8 w-full">
<SelectValue>{filters.play ? playOptions.find((item) => item.code === filters.play)?.label ?? filters.play : t("placeholders.play")}</SelectValue>
<SelectValue>
{filters.play
? (playOptions.find((item) => item.code === filters.play)?.label ?? filters.play)
: t("filterAll")}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value="__none__">{t("filterAll")}</SelectItem>
@@ -1316,12 +672,14 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
</div>
);
}
return (
<div key={field} className="grid gap-1.5">
<Label htmlFor={`report-${field}`}>{t(`fields.${field}`)}</Label>
<div key={field} className="min-w-[8rem] flex-1">
<Label className="sr-only" htmlFor={`report-${field}`}>
{t(`fields.${field}`)}
</Label>
<Input
id={`report-${field}`}
className="h-8"
value={filters.number}
onChange={(e) => updateFilter("number", e.target.value)}
placeholder={t(`placeholders.${field}`)}
@@ -1330,321 +688,141 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
);
};
const renderTable = () => {
if (!selectedReport.connected) {
return (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("backendPending")}
</TableCell>
</TableRow>
);
}
if (loading) {
return (
<AdminTableLoadingRow colSpan={8} />
);
}
if (error) {
return (
<TableRow>
<TableCell colSpan={8} className="text-destructive">
{error}
</TableCell>
</TableRow>
);
}
if (!result || result.rows.length === 0) {
return (
<AdminTableNoResourceRow colSpan={8} />
);
}
const stats = headlineStats(result, selectedReport.key);
const shortcutHref = CATEGORY_SHORTCUT_HREF[activeCategory];
if (result.key === "draw_profit") {
const summary = result.raw;
return (
<>
<TableRow>
<TableCell className="font-medium">{summary.draw_no}</TableCell>
<TableCell>
<DrawStatusBadge status={summary.draw_status} label={drawStatusLabel(summary.draw_status, t)} />
</TableCell>
<TableCell className="text-center">{summary.order_count}</TableCell>
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
<TableCell className={signedProfitCell(summary.approx_house_gross_minor, summary.currency_code)}>
{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}
</TableCell>
<TableCell>{summary.settlement_batches.length}</TableCell>
</TableRow>
{summary.settlement_batches.map((batch) => (
<TableRow key={batch.id} className="bg-muted/15">
<TableCell>#{batch.id}</TableCell>
<TableCell>
<AdminStatusBadge status={batch.status}>{batch.status}</AdminStatusBadge>
</TableCell>
<TableCell className="text-center">{batch.total_ticket_count}</TableCell>
<TableCell className="text-center">{batch.total_win_count}</TableCell>
<TableCell className="text-center">-</TableCell>
<TableCell className="text-center">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell>
<TableCell>{formatTs(batch.finished_at)}</TableCell>
</TableRow>
))}
</>
);
}
if (result.key === "player_transfer") {
return result.raw.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">{item.transfer_no}</TableCell>
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
<TableCell>{item.direction}</TableCell>
<TableCell>
<AdminStatusBadge status={item.status}>{item.status}</AdminStatusBadge>
</TableCell>
<TableCell className="text-center">{item.currency_code} {item.amount}</TableCell>
<TableCell>{item.external_ref_no || "-"}</TableCell>
<TableCell>{item.fail_reason || "-"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell>
</TableRow>
));
}
if (result.key === "hot_number_risk") {
return (
<>
<TableRow>
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
<TableCell>{result.raw.draw_no}</TableCell>
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
<TableCell>v{result.raw.pool.version}</TableCell>
</TableRow>
{result.raw.logs.items.map((item) => (
<TableRow key={item.id} className="bg-muted/15">
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>{item.action_type}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
<TableCell>{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.ticket_no || "-"}</TableCell>
<TableCell>{item.player_id || "-"}</TableCell>
<TableCell>{item.source_reason || "-"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell>
</TableRow>
))}
</>
);
}
if (result.key === "sold_out_number") {
return result.raw.map((item) => (
<TableRow key={item.normalized_number}>
<TableCell className="font-medium">{item.normalized_number}</TableCell>
<TableCell>{filters.drawNo}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_cap_amount, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, displayCurrency)}</TableCell>
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
<TableCell>v{item.version}</TableCell>
</TableRow>
));
}
if (result.key === "daily_profit") {
return result.raw.map((item) => (
<TableRow key={item.business_date}>
<TableCell className="font-medium">{item.business_date}</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow>
));
}
if (result.key === "player_win_loss") {
return result.raw.map((item) => (
<TableRow key={item.player_id}>
<TableCell className="font-medium">{item.username}</TableCell>
<TableCell className="text-xs">
{adminAgentDisplayLabel(item)}
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
<TableCell className={signedProfitCell(item.net_win_loss_minor, displayCurrency)}>
{formatPlainMoney(item.net_win_loss_minor, displayCurrency)}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow>
));
}
if (result.key === "play_dimension") {
return result.raw.map((item) => (
<TableRow key={`${item.play_code}-${item.dimension}`}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.dimension}D</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow>
));
}
if (result.key === "admin_audit") {
return result.raw.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>{item.operator_type}</TableCell>
<TableCell>{item.operator_id}</TableCell>
<TableCell>{item.module_code}</TableCell>
<TableCell>{item.action_code}</TableCell>
<TableCell>{item.target_type || "-"}</TableCell>
<TableCell>{item.ip || "-"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell>
</TableRow>
));
}
return null;
};
if (visibleReports.length === 0) {
return <AdminNoResourceState message={t("empty")} />;
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
{filteredReports.length === 0 ? (
<AdminNoResourceState message={t("empty")} />
) : (
<>
<Card className="admin-list-card">
<CardHeader className="admin-list-header pb-3">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{filteredReports.map((report) => {
const Icon = report.icon;
const active = report.key === selectedReport.key;
return (
<button
key={report.key}
type="button"
onClick={() => setSelectedKey(report.key)}
className={cn(
"inline-flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition",
active
? "border-primary bg-primary/[0.06] text-primary shadow-sm"
: "border-border/80 bg-card text-muted-foreground hover:border-primary/35 hover:text-foreground",
)}
>
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
<Icon className="size-3.5" aria-hidden />
</span>
<span className="truncate">{t(`items.${report.key}.title`)}</span>
</button>
);
})}
</div>
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{selectedReport.fields.map(renderField)}
</div>
<div className="flex justify-end gap-2 border-t border-border/60 pt-3">
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
{t("reset")}
</Button>
<Button
type="button"
size="sm"
disabled={!canViewReports || !selectedReport.connected || loading}
onClick={() => {
setPage(1);
void queryReport();
}}
>
<Database data-icon="inline-start" />
{loading ? t("querying") : t("query")}
</Button>
</div>
</CardContent>
</Card>
<div className="grid min-w-0 gap-2 md:grid-cols-4">
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
<div key={item.label} className={cn("min-w-0 rounded-md border px-3 py-2.5", statTone(item.tone))}>
<div className="text-xs text-muted-foreground">{item.label}</div>
<AdminMoneyDisplay as="div" value={item.value} size="md" className="mt-0.5">
{item.value}
</AdminMoneyDisplay>
</div>
<div className="flex w-full flex-col gap-4">
<AdminSubnav aria-label={t("categories.all")}>
{categoriesWithReports.map((category) => (
<AdminSubnavLink
key={category}
href={`/admin/reports/${category}`}
active={activeCategory === category}
>
{t(`categories.${category}`)}
</AdminSubnavLink>
))}
</AdminSubnav>
<div className="flex flex-wrap items-center gap-2">
<Select
value={selectedReport.key}
onValueChange={(value) => {
if (value) {
setSelectedKey(value as ReportKey);
}
}}
>
<SelectTrigger className="h-9 w-full min-w-[12rem] max-w-md">
<SelectValue>{t(`items.${selectedReport.key}.title`)}</SelectValue>
</SelectTrigger>
<SelectContent>
{categoryReports.map((report) => (
<SelectItem key={report.key} value={report.key}>
{t(`items.${report.key}.title`)}
</SelectItem>
))}
</SelectContent>
</Select>
{shortcutHref ? (
<Link
href={shortcutHref}
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{t(`shortcuts.${activeCategory}`)}
<ArrowUpRight className="size-3.5" />
</Link>
) : null}
{activeCategory === "profit" ? (
<span className="text-xs text-muted-foreground">{t("profitScopeHint")}</span>
) : null}
</div>
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
</div>
<div className="flex flex-wrap justify-end gap-2">
<div className="rounded-lg border border-border/60 p-3">
<div className="flex flex-wrap items-end gap-2">
{selectedReport.fields.map(renderField)}
<div className="flex flex-wrap items-center gap-1.5 sm:ml-auto">
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={resetFilters}>
{t("reset")}
</Button>
<Button
type="button"
size="sm"
className="h-8"
disabled={!canQuerySelected || !selectedReport.connected || loading}
onClick={() => {
setPage(1);
void queryReport();
}}
>
<Database className="size-3.5" data-icon="inline-start" />
{loading ? t("querying") : t("query")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={!canExportReports || exporting !== null}
onClick={() => void exportReport("csv")}
>
<FileDown data-icon="inline-start" />
<FileDown className="size-3.5" data-icon="inline-start" />
{t("formats.csv")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={!canExportReports || exporting !== null}
onClick={() => void exportReport("excel")}
>
<FileSpreadsheet data-icon="inline-start" />
<FileSpreadsheet className="size-3.5" data-icon="inline-start" />
{t("formats.excel")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-3">
<Table id="reports-preview-table">
<TableHeader>
<TableRow>
<TableHead>{previewColumns.primary}</TableHead>
<TableHead>{previewColumns.secondary}</TableHead>
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
<TableHead>{previewColumns.status}</TableHead>
<TableHead>{previewColumns.extra}</TableHead>
<TableHead>{previewColumns.time}</TableHead>
</TableRow>
</TableHeader>
<TableBody>{renderTable()}</TableBody>
</Table>
</div>
</div>
{result?.meta ? (
{stats.length > 0 ? (
<div className="flex flex-wrap gap-2">
{stats.map((item) => (
<div
key={item.label}
className={cn(
"inline-flex items-baseline gap-2 rounded-md border px-3 py-1.5 text-sm",
item.tone === "good"
? "border-emerald-200/80 bg-emerald-50/50"
: item.tone === "bad"
? "border-red-200/80 bg-red-50/50"
: item.tone === "warn"
? "border-amber-200/80 bg-amber-50/50"
: "border-border/60 bg-muted/30",
)}
>
<span className="text-muted-foreground">{item.label}</span>
<span className="font-semibold tabular-nums">{item.value}</span>
</div>
))}
</div>
) : null}
<div className="rounded-lg border border-border/60">
<ReportPreviewTable
report={selectedReport}
result={result}
loading={loading}
error={error}
drawNoLabel={filters.drawNo}
displayCurrency={displayCurrency}
/>
{result?.meta ? (
<div className="border-t border-border/60 px-3 py-2">
<AdminListPaginationFooter
selectId="reports-preview-per-page"
total={result.meta.total}
@@ -1658,11 +836,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}}
onPageChange={setPage}
/>
) : null}
</CardContent>
</Card>
</>
)}
</div>
) : null}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,141 @@
import type { LucideIcon } from "lucide-react";
import {
CalendarDays,
FileSpreadsheet,
ListFilter,
ShieldAlert,
ShieldCheck,
Ticket,
Users,
WalletCards,
} from "lucide-react";
import {
PRD_AUDIT_VIEW,
PRD_REPORT_VIEW,
PRD_RISK_ACCESS_ANY,
PRD_WALLET_TRANSFER_ACCESS_ANY,
} from "@/lib/admin-prd";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
export const REPORT_CATEGORY_ORDER: readonly ReportCategory[] = [
"profit",
"wallet",
"risk",
"audit",
] as const;
export type FilterKind =
| "draw"
| "date"
| "player_period"
| "draw_number"
| "play"
| "play_period"
| "operator_period";
export type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
export type ReportKey =
| "draw_profit"
| "daily_profit"
| "player_win_loss"
| "player_transfer"
| "hot_number_risk"
| "play_dimension"
| "sold_out_number"
| "admin_audit";
export type ReportDefinition = {
key: ReportKey;
category: ReportCategory;
icon: LucideIcon;
filterKind: FilterKind;
fields: FieldKey[];
connected: boolean;
requiredAny: readonly string[];
};
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
export const REPORT_DEFINITIONS: ReportDefinition[] = [
{
key: "draw_profit",
category: "profit",
icon: Ticket,
filterKind: "draw",
fields: ["drawNo"],
connected: true,
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
},
{
key: "daily_profit",
category: "profit",
icon: CalendarDays,
filterKind: "date",
fields: ["period"],
connected: true,
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
},
{
key: "player_win_loss",
category: "profit",
icon: Users,
filterKind: "player_period",
fields: ["player", "period"],
connected: true,
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
},
{
key: "play_dimension",
category: "profit",
icon: ListFilter,
filterKind: "play_period",
fields: ["play", "period"],
connected: true,
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
},
{
key: "player_transfer",
category: "wallet",
icon: WalletCards,
filterKind: "player_period",
fields: ["player", "period"],
connected: true,
requiredAny: PRD_WALLET_TRANSFER_ACCESS_ANY,
},
{
key: "hot_number_risk",
category: "risk",
icon: ShieldAlert,
filterKind: "draw_number",
fields: ["drawNo", "number"],
connected: true,
requiredAny: PRD_RISK_ACCESS_ANY,
},
{
key: "sold_out_number",
category: "risk",
icon: ShieldCheck,
filterKind: "draw",
fields: ["drawNo"],
connected: true,
requiredAny: PRD_RISK_ACCESS_ANY,
},
{
key: "admin_audit",
category: "audit",
icon: FileSpreadsheet,
filterKind: "operator_period",
fields: ["operator", "period"],
connected: true,
requiredAny: [PRD_AUDIT_VIEW],
},
];
export const CATEGORY_SHORTCUT_HREF: Partial<Record<ReportCategory, string>> = {
wallet: "/admin/wallet/transfer-orders",
risk: "/admin/risk",
audit: "/admin/audit-logs",
};

View File

@@ -0,0 +1,358 @@
import { getAdminDraws } from "@/api/admin-draws";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
import { getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminAuditLogRow } from "@/types/api/admin-audit";
import type { AdminDrawListItem } from "@/types/api/admin-draws";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminPlayerRow } from "@/types/api/admin-player";
import type { AdminRiskPoolRow, AdminRiskPoolShowData } from "@/types/api/admin-risk";
import type { AdminUserPermissionRow } from "@/types/api/admin-user";
import type { AdminTransferOrderItem } from "@/types/api/admin-wallet";
import type {
AdminReportDailyProfitRow,
AdminReportPlayDimensionRow,
AdminReportPlayerWinLossRow,
} from "@/types/api/admin-reports";
import type { FieldKey, ReportDefinition, ReportKey } from "@/modules/reports/reports-definitions";
export type ExportCell = string | number | null;
export type ExportRow = Record<string, ExportCell>;
export type ExportFormat = "csv" | "excel";
export type SearchKind = "draw" | "player" | "operator";
export type ReportFilters = {
drawNo: string;
drawId: number | null;
number: string;
player: string;
playerId: number | null;
play: string;
operator: string;
operatorId: number | null;
dateFrom: string;
dateTo: string;
};
export type ReportMeta = {
total: number;
page: number;
perPage: number;
lastPage: number;
};
export type StatCard = {
label: string;
value: string;
tone?: "default" | "good" | "warn" | "bad";
};
export type ReportResult =
| { key: "draw_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminDrawFinanceSummaryData }
| { key: "daily_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportDailyProfitRow[] }
| { key: "player_win_loss"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayerWinLossRow[] }
| { key: "player_transfer"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminTransferOrderItem[] }
| { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData }
| { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] }
| { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] }
| { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
export type SearchState = {
open: SearchKind | null;
query: string;
loading: boolean;
draws: AdminDrawListItem[];
players: AdminPlayerRow[];
operators: AdminUserPermissionRow[];
};
export const emptyFilters: ReportFilters = {
drawNo: "",
drawId: null,
number: "",
player: "",
playerId: null,
play: "",
operator: "",
operatorId: null,
dateFrom: "",
dateTo: "",
};
export const emptySearch: SearchState = {
open: null,
query: "",
loading: false,
draws: [],
players: [],
operators: [],
};
function isoDateLocal(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function defaultReportPeriod(): Pick<ReportFilters, "dateFrom" | "dateTo"> {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 29);
return { dateFrom: isoDateLocal(from), dateTo: isoDateLocal(to) };
}
export function createDefaultFilters(): ReportFilters {
return { ...emptyFilters, ...defaultReportPeriod() };
}
export function reportHasPeriodField(report: ReportDefinition): boolean {
return report.fields.includes("period");
}
export function resolveDisplayCurrency(apiCode?: string | null): string {
const trimmed = apiCode?.trim();
if (trimmed) {
return trimmed;
}
const fallback = getCachedAdminCurrencies().find((row) => row.is_enabled && row.is_bettable)?.code;
return fallback?.trim() || "NPR";
}
export function formatPlainMoney(value: number, currencyCode: string | null | undefined): string {
return formatAdminMinorUnits(value, currencyCode || "NPR");
}
export function signedProfitCell(amount: number, currencyCode: string | null | undefined): string {
return cn("tabular-nums", signedMoneyClass(amount, true));
}
export function formatUsagePercent(ratio: number | null | undefined): string {
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
}
export function optionText(...parts: Array<string | number | null | undefined>): string {
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
}
export function reportListParams(filters: ReportFilters, page: number, perPage: number) {
return {
page,
per_page: perPage,
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
player_id: filters.playerId ?? undefined,
play_code: filters.play.trim() || undefined,
};
}
export function parsePositiveInteger(value: string): number | null {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
export async function resolveDraw(
filters: ReportFilters,
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
): Promise<{ id: number; draw_no: string }> {
if (filters.drawId != null && filters.drawId > 0) {
const drawNo = filters.drawNo.trim();
return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) };
}
const drawNo = filters.drawNo.trim();
if (!drawNo) {
throw new LotteryApiBizError(messages.drawNoRequired, -1, null);
}
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
if (!matched) {
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
}
return { id: matched.id, draw_no: matched.draw_no };
}
export function formatExportInstant(iso: string | null | undefined): ExportCell {
return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
}
export function metaFromList(meta: {
current_page: number;
per_page: number;
total: number;
last_page: number;
}): ReportMeta {
return {
total: meta.total,
page: meta.current_page,
perPage: meta.per_page,
lastPage: meta.last_page,
};
}
export function drawRowsFromSummary(summary: AdminDrawFinanceSummaryData): ExportRow[] {
return [
{
row_type: "summary",
draw_id: summary.draw_id,
draw_no: summary.draw_no,
draw_status: summary.draw_status,
currency_code: summary.currency_code,
order_count: summary.order_count,
ticket_item_count: summary.ticket_item_count,
total_bet_minor: summary.total_bet_minor,
total_win_payout_minor: summary.total_win_payout_minor,
total_jackpot_win_minor: summary.total_jackpot_win_minor,
total_payout_minor: summary.total_payout_minor,
approx_house_gross_minor: summary.approx_house_gross_minor,
},
...summary.settlement_batches.map((batch) => ({
row_type: "settlement_batch",
draw_id: summary.draw_id,
draw_no: summary.draw_no,
settlement_batch_id: batch.id,
settlement_status: batch.status,
total_ticket_count: batch.total_ticket_count,
total_win_count: batch.total_win_count,
total_payout_amount: batch.total_payout_amount,
total_jackpot_payout_amount: batch.total_jackpot_payout_amount,
finished_at: formatExportInstant(batch.finished_at),
})),
];
}
export function buildDailyProfitRowsAndSummary(
items: AdminReportDailyProfitRow[],
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "rows" | "summary"> {
let totalBet = 0;
let totalPayout = 0;
let totalGross = 0;
const rows = items.map((item) => {
totalBet += item.total_bet_minor;
totalPayout += item.total_payout_minor;
totalGross += item.approx_house_gross_minor;
return {
business_date: item.business_date,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
approx_house_gross_minor: item.approx_house_gross_minor,
};
});
return {
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(totalGross, currencyCode),
tone: totalGross >= 0 ? "good" : "bad",
},
],
};
}
export function buildPlayerTransferRowsAndSummary(
items: AdminTransferOrderItem[],
total: number,
page: number,
perPage: number,
t: (key: string) => string,
): Pick<Extract<ReportResult, { key: "player_transfer" }>, "rows" | "summary" | "meta"> {
const rows = items.map((item) => ({
id: item.id,
transfer_no: item.transfer_no,
player_id: item.player_id,
username: item.username,
nickname: item.nickname,
direction: item.direction,
currency_code: item.currency_code,
amount: item.amount,
status: item.status,
external_ref_no: item.external_ref_no,
fail_reason: item.fail_reason,
created_at: formatExportInstant(item.created_at),
finished_at: formatExportInstant(item.finished_at),
}));
return {
rows,
meta: { total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)) },
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
],
};
}
export function buildPlayDimensionRowsAndSummary(
items: AdminReportPlayDimensionRow[],
total: number,
t: (key: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
let totalBet = 0;
let totalGross = 0;
const rows = items.map((item) => {
totalBet += item.total_bet_minor;
totalGross += item.approx_house_gross_minor;
return {
play_code: item.play_code,
dimension: item.dimension,
total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor,
approx_house_gross_minor: item.approx_house_gross_minor,
};
});
return {
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{
label: t("preview.stats.houseGross"),
value: formatPlainMoney(totalGross, currencyCode),
tone: totalGross >= 0 ? "good" : "bad",
},
],
};
}
export function normalizeFilenamePart(value: string): string {
return value.trim().replace(/[\\/:*?"<>|\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
/** Primary headline stats shown above the preview table (max 2). */
export function headlineStats(result: ReportResult | null, reportKey: ReportKey): StatCard[] {
if (!result || result.key !== reportKey) {
return [];
}
return result.summary.slice(0, 2);
}

View File

@@ -19,11 +19,7 @@ export function RulesOddsConfigScreen() {
return (
<RulesPageShell>
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
<ConfigDocPage
title={t("nav.rulesOddsTitle")}
description={t("nav.rulesOddsDescriptionShort")}
contentClassName="pt-2"
>
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="pt-2">
<OddsConfigDocScreen embedded mergedLayout workspace={workspace} />
</ConfigDocPage>
</AdminPermissionGate>

View File

@@ -0,0 +1,61 @@
"use client";
import type { ReactNode } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
export function SettingsToggleRow({
label,
hint,
checked,
disabled,
onCheckedChange,
className,
trailing,
}: {
label: string;
hint?: string;
checked: boolean;
disabled?: boolean;
onCheckedChange: (value: boolean) => void;
className?: string;
trailing?: ReactNode;
}) {
return (
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30",
className,
)}
>
<div
className="min-w-0 flex-1 cursor-pointer space-y-1 pr-4"
onClick={() => !disabled && onCheckedChange(!checked)}
onKeyDown={(event) => {
if (disabled) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onCheckedChange(!checked);
}
}}
role="button"
tabIndex={disabled ? -1 : 0}
>
<Label className="cursor-pointer font-medium">{label}</Label>
{hint ? <p className="text-sm text-muted-foreground">{hint}</p> : null}
</div>
{trailing ?? (
<Switch
checked={checked}
disabled={disabled}
aria-label={label}
onCheckedChange={onCheckedChange}
/>
)}
</div>
);
}

View File

@@ -46,7 +46,6 @@ import type { AdminCurrencyRow } from "@/types/api/admin-currency";
type CurrencyFormState = {
code: string;
name: string;
decimal_places: string;
is_enabled: boolean;
is_bettable: boolean;
};
@@ -54,7 +53,6 @@ type CurrencyFormState = {
const EMPTY_FORM: CurrencyFormState = {
code: "",
name: "",
decimal_places: "2",
is_enabled: true,
is_bettable: false,
};
@@ -63,7 +61,6 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
return {
code: row.code,
name: row.name,
decimal_places: String(row.decimal_places),
is_enabled: row.is_enabled,
is_bettable: row.is_enabled && row.is_bettable,
};
@@ -138,7 +135,6 @@ export function CurrencySettingsPanel() {
async function handleSubmit(): Promise<void> {
const payload = {
name: form.name.trim(),
decimal_places: Number.parseInt(form.decimal_places || "0", 10),
is_enabled: form.is_enabled,
is_bettable: form.is_enabled && form.is_bettable,
};
@@ -150,11 +146,6 @@ export function CurrencySettingsPanel() {
}
}
if (!Number.isFinite(payload.decimal_places) || payload.decimal_places < 0) {
toast.error(t("currencies.form.decimalInvalid", { ns: "config" }));
return;
}
setSaving(true);
try {
if (mode === "create") {
@@ -229,7 +220,6 @@ export function CurrencySettingsPanel() {
<TableRow>
<TableHead className="whitespace-nowrap">{t("currencies.table.code", { ns: "config" })}</TableHead>
<TableHead>{t("currencies.table.name", { ns: "config" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("currencies.table.decimals", { ns: "config" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("currencies.table.actions", { ns: "config" })}</TableHead>
@@ -238,18 +228,17 @@ export function CurrencySettingsPanel() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
{t("currencies.loading", { ns: "config" })}
</TableCell>
</TableRow>
) : items.length === 0 ? (
<AdminTableNoResourceRow colSpan={6} cellClassName="text-center" />
<AdminTableNoResourceRow colSpan={5} cellClassName="text-center" />
) : (
items.map((row) => (
<TableRow key={row.code}>
<TableCell className="font-mono">{row.code}</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell>{row.decimal_places}</TableCell>
<TableCell>
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled
@@ -329,21 +318,6 @@ export function CurrencySettingsPanel() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="currency-decimals">{t("currencies.form.decimals", { ns: "config" })}</Label>
<Input
id="currency-decimals"
type="number"
min="0"
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}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
<div className="space-y-1">
<p className="text-sm font-medium">{t("currencies.form.enabled", { ns: "config" })}</p>

View File

@@ -1,5 +1,5 @@
export const settingsModuleMeta = {
segment: "settings",
title: "系统设置",
description: "管理影响钱包划转与跨模块行为的全局运行参数。",
description: "日常运营开关与限额在此调整。",
} as const;

View File

@@ -6,43 +6,28 @@ import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { SettingsToggleRow } from "@/modules/settings/components/settings-toggle-row";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
interface DrawDraft {
defaultCurrency: string;
drawIntervalMinutes: string;
drawBettingWindowSeconds: string;
drawCloseBeforeDrawSeconds: string;
drawBufferDrawsAhead: string;
requireManualReview: boolean;
cooldownMinutes: string;
}
const INITIAL: DrawDraft = {
defaultCurrency: "NPR",
drawIntervalMinutes: "5",
drawBettingWindowSeconds: "270",
drawCloseBeforeDrawSeconds: "30",
drawBufferDrawsAhead: "8",
requireManualReview: false,
cooldownMinutes: "15",
};
function fromKv(kv: Record<string, unknown>): DrawDraft {
return {
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
};
@@ -50,43 +35,16 @@ function fromKv(kv: Record<string, unknown>): DrawDraft {
function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = [];
const push = (key: string, value: unknown, changed: boolean) => {
if (changed) {
items.push({ key, value });
}
};
push(
DRAW_KEYS.DEFAULT_CURRENCY,
draft.defaultCurrency.trim().toUpperCase() || "NPR",
draft.defaultCurrency !== saved.defaultCurrency,
);
push(
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
draft.drawIntervalMinutes !== saved.drawIntervalMinutes,
);
push(
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
draft.drawBettingWindowSeconds !== saved.drawBettingWindowSeconds,
);
push(
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
draft.drawCloseBeforeDrawSeconds !== saved.drawCloseBeforeDrawSeconds,
);
push(
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
draft.drawBufferDrawsAhead !== saved.drawBufferDrawsAhead,
);
push(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview, draft.requireManualReview !== saved.requireManualReview);
push(
DRAW_KEYS.COOLDOWN_MINUTES,
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
draft.cooldownMinutes !== saved.cooldownMinutes,
);
if (draft.requireManualReview !== saved.requireManualReview) {
items.push({ key: DRAW_KEYS.REQUIRE_MANUAL_REVIEW, value: draft.requireManualReview });
}
if (draft.cooldownMinutes !== saved.cooldownMinutes) {
items.push({
key: DRAW_KEYS.COOLDOWN_MINUTES,
value: Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
});
}
return items;
}
@@ -114,114 +72,34 @@ export function DrawSettingsPanel() {
description={t("system.sections.drawDescription", { ns: "config" })}
>
<div className="space-y-6">
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30">
<Label className="font-medium cursor-pointer" onClick={() => updateField("requireManualReview", !draft.requireManualReview)}>{t("system.fields.manualReview", { ns: "config" })}</Label>
<Switch
checked={draft.requireManualReview}
disabled={!canManage || loading || saving}
aria-label={t("system.fields.manualReview", { ns: "config" })}
onCheckedChange={(value) => updateField("requireManualReview", value)}
/>
</div>
<div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm">
<SettingsToggleRow
label={t("system.fields.manualReview", { ns: "config" })}
hint={t("system.hints.manualReview", { ns: "config" })}
checked={draft.requireManualReview}
disabled={!canManage || loading || saving}
onCheckedChange={(value) => updateField("requireManualReview", value)}
/>
</div>
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="default-currency" className="text-muted-foreground">
{t("system.fields.defaultCurrency", { ns: "config" })}
</Label>
<Input
id="default-currency"
value={draft.defaultCurrency}
placeholder={t("system.placeholders.defaultCurrency", { ns: "config" })}
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
disabled={!canManage || loading || saving}
maxLength={16}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="draw-interval-minutes" className="text-muted-foreground">
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
</Label>
<Input
id="draw-interval-minutes"
type="number"
min="1"
max="1440"
step="1"
value={draft.drawIntervalMinutes}
placeholder={t("system.placeholders.drawIntervalMinutes", { ns: "config" })}
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="draw-betting-window-seconds" className="text-muted-foreground">
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
</Label>
<Input
id="draw-betting-window-seconds"
type="number"
min="10"
step="1"
value={draft.drawBettingWindowSeconds}
placeholder={t("system.placeholders.drawBettingWindowSeconds", { ns: "config" })}
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="draw-close-before-seconds" className="text-muted-foreground">
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
</Label>
<Input
id="draw-close-before-seconds"
type="number"
min="5"
step="1"
value={draft.drawCloseBeforeDrawSeconds}
placeholder={t("system.placeholders.drawCloseBeforeDrawSeconds", { ns: "config" })}
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="draw-buffer-ahead" className="text-muted-foreground">
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
</Label>
<Input
id="draw-buffer-ahead"
type="number"
min="1"
step="1"
value={draft.drawBufferDrawsAhead}
placeholder={t("system.placeholders.drawBufferDrawsAhead", { ns: "config" })}
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cooldown-minutes" className="text-muted-foreground">
{t("system.fields.cooldownMinutes", { ns: "config" })}
</Label>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="max-w-sm space-y-2">
<Label htmlFor="cooldown-minutes" className="text-muted-foreground">
{t("system.fields.cooldownMinutes", { ns: "config" })}
</Label>
<p className="text-sm text-muted-foreground">
{t("system.hints.cooldownMinutes", { ns: "config" })}
</p>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<SettingsSectionActions
@@ -248,4 +126,4 @@ export function DrawSettingsPanel() {
<ConfirmDialog />
</>
);
}
}

View File

@@ -6,11 +6,10 @@ import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
import { SettingsToggleRow } from "@/modules/settings/components/settings-toggle-row";
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_PAYOUT_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
@@ -82,49 +81,35 @@ export function SettlementSettingsPanel() {
description={t("system.sections.settlementDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoSettlement", !draft.autoSettlement)}>{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<Switch
checked={draft.autoSettlement}
disabled={loading || saving}
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
onCheckedChange={(value) => updateField("autoSettlement", value)}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoApprove", !draft.autoApprove)}>{t("system.fields.autoApprove", { ns: "config" })}</Label>
<Switch
checked={draft.autoApprove}
disabled={loading || saving}
aria-label={t("system.fields.autoApprove", { ns: "config" })}
onCheckedChange={(value) => updateField("autoApprove", value)}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoPayout", !draft.autoPayout)}>{t("system.fields.autoPayout", { ns: "config" })}</Label>
<Switch
checked={draft.autoPayout}
disabled={loading || saving}
aria-label={t("system.fields.autoPayout", { ns: "config" })}
onCheckedChange={(value) => updateField("autoPayout", value)}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30">
<div className="min-w-0 space-y-1 pr-4 cursor-pointer" onClick={() => updateField("applyRebateToPayout", !draft.applyRebateToPayout)}>
<Label className="font-medium cursor-pointer">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
<p className="text-[11px] text-muted-foreground/80">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
</div>
<Switch
checked={draft.applyRebateToPayout}
disabled={loading || saving}
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
/>
</div>
<div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm divide-y divide-border/50">
<SettingsToggleRow
label={t("system.fields.autoSettlement", { ns: "config" })}
hint={t("system.hints.autoSettlement", { ns: "config" })}
checked={draft.autoSettlement}
disabled={loading || saving}
onCheckedChange={(value) => updateField("autoSettlement", value)}
/>
<SettingsToggleRow
label={t("system.fields.autoApprove", { ns: "config" })}
hint={t("system.hints.autoApprove", { ns: "config" })}
checked={draft.autoApprove}
disabled={loading || saving}
onCheckedChange={(value) => updateField("autoApprove", value)}
/>
<SettingsToggleRow
label={t("system.fields.autoPayout", { ns: "config" })}
hint={t("system.hints.autoPayout", { ns: "config" })}
checked={draft.autoPayout}
disabled={loading || saving}
onCheckedChange={(value) => updateField("autoPayout", value)}
/>
<SettingsToggleRow
label={t("system.fields.applyRebateToPayout", { ns: "config" })}
hint={t("system.hints.applyRebateToPayout", { ns: "config" })}
checked={draft.applyRebateToPayout}
disabled={loading || saving}
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
/>
</div>
<SettingsSectionActions
@@ -137,8 +122,8 @@ export function SettlementSettingsPanel() {
description: t("system.confirmSaveSettlementDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => {
void save();
},
void save();
},
})
}
onDiscard={discard}
@@ -151,4 +136,4 @@ export function SettlementSettingsPanel() {
<ConfirmDialog />
</>
);
}
}

View File

@@ -4,11 +4,6 @@ export const FRONTEND_GROUP = "frontend";
export const WALLET_GROUP = "wallet";
export const DRAW_KEYS = {
DEFAULT_CURRENCY: "currency.default_code",
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes",
} as const;

View File

@@ -13,6 +13,8 @@ function SystemSettingsContent() {
return (
<div className="flex w-full max-w-none flex-col gap-6">
<h1 className="text-lg font-semibold tracking-tight">{t("system.pageTitle")}</h1>
<DrawSettingsPanel />
<SettlementSettingsPanel />

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -37,6 +37,7 @@ type AgentBillDetailProps = {
canManage?: boolean;
canFinanceAdjustments?: boolean;
boundAgent?: { id: number } | null;
focusSection?: "confirm" | "payment" | null;
onUpdated?: () => void;
};
@@ -46,6 +47,7 @@ export function AgentBillDetail({
canManage = true,
canFinanceAdjustments = false,
boundAgent = null,
focusSection = null,
onUpdated,
}: AgentBillDetailProps): React.ReactElement {
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
@@ -62,6 +64,9 @@ export function AgentBillDetail({
const [adjustReason, setAdjustReason] = useState("");
const [badDebtReason, setBadDebtReason] = useState("");
const [rebateDetailsOpen, setRebateDetailsOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const confirmRef = useRef<HTMLDivElement>(null);
const paymentRef = useRef<HTMLDivElement>(null);
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const load = useCallback(async () => {
@@ -89,6 +94,21 @@ export function AgentBillDetail({
void load();
}, [load]);
useEffect(() => {
if (loading || !bill) {
return;
}
const target =
focusSection === "payment"
? paymentRef.current
: focusSection === "confirm"
? confirmRef.current
: null;
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [bill, focusSection, loading]);
if (loading && !bill) {
return <AdminLoadingState />;
}
@@ -378,17 +398,13 @@ export function AgentBillDetail({
) : null}
{canOperateBill && bill.status === "pending_confirm" ? (
<div className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight text-primary">
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</p>
<p className="text-xs text-muted-foreground/80">
{t("settlementCenter:billDisplay.confirmHint", {
defaultValue: "确认后才可以登记收款或付款。",
})}
</p>
</div>
<div
ref={confirmRef}
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm"
>
<p className="font-semibold tracking-tight text-primary">
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</p>
<Button
type="button"
className="w-full sm:w-auto sm:min-w-[10rem]"
@@ -401,7 +417,10 @@ export function AgentBillDetail({
) : null}
{canOperateBill && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div
ref={paymentRef}
className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm"
>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold tracking-tight">{paymentTitle}</p>
@@ -461,87 +480,94 @@ export function AgentBillDetail({
</div>
) : null}
{canWriteOff ? (
<div className="space-y-4 rounded-xl border border-destructive/20 bg-destructive/5 p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight text-destructive">
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
</p>
<p className="text-xs text-destructive/80">
{t("settlementBills.badDebtHint", {
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
})}
</p>
{canWriteOff || (canFinanceAdjustments && locked) ? (
<div className="rounded-xl border border-dashed border-border/70">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium"
onClick={() => setAdvancedOpen((open) => !open)}
>
{t("settlementCenter:billDisplay.advancedActions", {
defaultValue: "调账 / 坏账",
})}
<span className="text-xs text-muted-foreground">{advancedOpen ? "" : "+"}</span>
</button>
{advancedOpen ? (
<div className="space-y-4 border-t border-border/70 px-4 py-4">
{canWriteOff ? (
<div className="space-y-3 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="font-medium text-destructive">
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
</p>
<div className="space-y-1.5">
<Label className="text-destructive/90">
{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}
</Label>
<Input
value={badDebtReason}
onChange={(e) => setBadDebtReason(e.target.value)}
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
defaultValue: "例如:客户失联、确认坏账",
})}
className="border-destructive/30"
/>
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={confirmBusy}
onClick={requestBadDebtWriteOff}
>
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button>
</div>
) : null}
{canFinanceAdjustments && locked ? (
<div className="space-y-3">
<p className="font-medium">
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
</p>
<div className="space-y-1.5">
<Label className="text-muted-foreground">
{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}
</Label>
<Input
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
inputMode="decimal"
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
defaultValue: "例如35.20 或 -10.00",
})}
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground">
{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}
</Label>
<Input
value={adjustReason}
onChange={(e) => setAdjustReason(e.target.value)}
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
defaultValue: "例如:人工复核补差、冲正错账",
})}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={confirmBusy}
onClick={requestAdjustment}
>
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
</Button>
</div>
) : null}
</div>
<div className="space-y-1.5 mt-2">
<Label className="text-destructive/90">{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<Input
value={badDebtReason}
onChange={(e) => setBadDebtReason(e.target.value)}
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
defaultValue: "例如:客户失联、确认坏账",
})}
className="bg-background/50 transition-colors focus:bg-background border-destructive/30"
/>
</div>
<Button
type="button"
variant="destructive"
className="w-full mt-2"
disabled={confirmBusy}
onClick={requestBadDebtWriteOff}
>
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button>
</div>
) : null}
{canFinanceAdjustments && locked ? (
<div className="space-y-4 rounded-xl border border-dashed border-border/70 bg-card p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight">
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
</p>
<p className="text-xs text-muted-foreground/80">
{t("settlementBills.adjustmentHint", {
defaultValue: "正数表示补收,负数表示冲减;提交后会生成一张独立调账单。",
})}
</p>
</div>
<div className="space-y-1.5 mt-2">
<Label className="text-muted-foreground">{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
<Input
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
inputMode="decimal"
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
defaultValue: "例如35.20 或 -10.00",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground">{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
<Input
value={adjustReason}
onChange={(e) => setAdjustReason(e.target.value)}
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
defaultValue: "例如:人工复核补差、冲正错账",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<Button
type="button"
variant="outline"
className="w-full mt-2"
disabled={confirmBusy}
onClick={requestAdjustment}
>
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
</Button>
</div>
) : null}
) : null}
</div>
) : null}
</div>
</>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { ArrowRight, Eye } from "lucide-react";
import { ArrowRight, Banknote, Check, Eye } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
@@ -16,9 +16,8 @@ import {
formatSignedSettlementMoney,
} from "@/modules/settlement/settlement-signed-money";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
describeBillPaymentDirection,
} from "@/modules/settlement/settlement-bill-display";
import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display";
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
import {
formatPlatformPartyLabel,
SettlementDashCell,
@@ -44,7 +43,11 @@ type SettlementBillsTableProps = {
currencyCode: string;
billTypeFilter?: BillTypeFilter;
emptyMessage?: string;
canOperate?: boolean;
boundAgentId?: number | null;
onOpenDetail: (billId: number) => void;
onConfirmBill?: (row: SettlementBillRow) => void;
onPayBill?: (row: SettlementBillRow) => void;
};
function billRowTone(row: SettlementBillRow): string {
@@ -120,7 +123,11 @@ export function SettlementBillsTable({
currencyCode,
billTypeFilter = "all",
emptyMessage,
canOperate = false,
boundAgentId = null,
onOpenDetail,
onConfirmBill,
onPayBill,
}: SettlementBillsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
@@ -173,8 +180,44 @@ export function SettlementBillsTable({
</TableHeader>
<TableBody>
{rows.map((row) => {
const isPlayerBill = row.bill_type === "player";
const direction = describeBillPaymentDirection(row, t);
const operable =
canOperate &&
settlementBillOperableByBoundAgent(row, boundAgentId ? { id: boundAgentId } : null);
const canConfirm = operable && row.status === "pending_confirm";
const canPay =
operable &&
["confirmed", "partial_paid", "overdue"].includes(row.status) &&
row.unpaid_amount > 0;
const rowActions = [
...(canConfirm && onConfirmBill
? [
{
key: "confirm",
label: t("billsPanel.confirmOneBtn", { defaultValue: "确认" }),
icon: Check,
onClick: () => onConfirmBill(row),
},
]
: []),
...(canPay && onPayBill
? [
{
key: "pay",
label: t("billsPanel.payBtn", { defaultValue: "收付" }),
icon: Banknote,
onClick: () => onPayBill(row),
},
]
: []),
{
key: "detail",
label: t("actions.detail", { defaultValue: "详情" }),
icon: Eye,
onClick: () => onOpenDetail(row.id),
},
];
return (
<TableRow key={row.id} className={billRowTone(row)}>
@@ -268,16 +311,7 @@ export function SettlementBillsTable({
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
actions={[
{
key: "detail",
label: t("actions.detail", { defaultValue: "详情" }),
icon: Eye,
onClick: () => onOpenDetail(row.id),
},
]}
/>
<AdminRowActionsMenu actions={rowActions} />
</TableCell>
</TableRow>
);

View File

@@ -1,6 +1,18 @@
export type SettlementPeriodView = "bills" | "operations" | "ledger";
import type { SettlementBillListScope } from "@/api/admin-agent-settlement";
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "operations", "ledger"];
export type SettlementPeriodView = "bills" | "ledger";
export type SettlementBillScopeFilter = "all" | SettlementBillListScope;
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "ledger"];
const VALID_BILL_SCOPES: SettlementBillScopeFilter[] = [
"all",
"pending_confirm",
"awaiting_payment",
"settled",
"adjustment",
];
function parsePositiveInt(raw: string | null): number | null {
if (raw === null || raw === "") {
@@ -11,16 +23,30 @@ function parsePositiveInt(raw: string | null): number | null {
}
export function settlementCenterListHref(adminSiteId?: number | null): string {
return settlementCenterScopeHref(adminSiteId, "all");
}
/** 结算中心列表深链,可带账单筛选 scope待确认 / 待收付等)。 */
export function settlementCenterScopeHref(
adminSiteId?: number | null,
billScope: SettlementBillScopeFilter = "all",
): string {
const params = new URLSearchParams();
if (adminSiteId != null && adminSiteId > 0) {
return `/admin/settlement-center?site=${adminSiteId}`;
params.set("site", String(adminSiteId));
}
return "/admin/settlement-center";
if (billScope !== "all") {
params.set("scope", billScope);
}
const qs = params.toString();
return qs ? `/admin/settlement-center?${qs}` : "/admin/settlement-center";
}
export function settlementPeriodViewHref(
periodId: number,
view: SettlementPeriodView = "bills",
adminSiteId?: number | null,
billScope?: SettlementBillScopeFilter | null,
): string {
const params = new URLSearchParams({
period: String(periodId),
@@ -29,24 +55,57 @@ export function settlementPeriodViewHref(
if (adminSiteId != null && adminSiteId > 0) {
params.set("site", String(adminSiteId));
}
if (billScope != null && billScope !== "all") {
params.set("scope", billScope);
}
return `/admin/settlement-center?${params.toString()}`;
}
export function preferredBillScopeForPeriod(summary?: {
pending_confirm?: number;
awaiting_payment?: number;
}): SettlementBillScopeFilter {
const pending = summary?.pending_confirm ?? 0;
const awaiting = summary?.awaiting_payment ?? 0;
if (pending > 0) {
return "pending_confirm";
}
if (awaiting > 0) {
return "awaiting_payment";
}
return "all";
}
export function parseSettlementCenterView(
siteRaw: string | null,
periodRaw: string | null,
viewRaw: string | null,
): { siteId: number | null; periodId: number | null; view: SettlementPeriodView } {
const normalizedView = viewRaw === "reports" ? "bills" : viewRaw;
scopeRaw?: string | null,
): {
siteId: number | null;
periodId: number | null;
view: SettlementPeriodView;
billScope: SettlementBillScopeFilter;
} {
const normalizedView =
viewRaw === "reports" || viewRaw === "operations" ? "bills" : viewRaw;
const view =
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
? (normalizedView as SettlementPeriodView)
: "bills";
const billScope =
scopeRaw !== null &&
scopeRaw !== "" &&
VALID_BILL_SCOPES.includes(scopeRaw as SettlementBillScopeFilter)
? (scopeRaw as SettlementBillScopeFilter)
: "all";
return {
siteId: parsePositiveInt(siteRaw),
periodId: parsePositiveInt(periodRaw),
view,
billScope,
};
}

View File

@@ -1,51 +1,51 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import type { SettlementPeriodCloseResult, SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel";
import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel";
import { SettlementOperationsPanel } from "@/modules/settlement/settlement-operations-panel";
import { SettlementPeriodHeader } from "@/modules/settlement/settlement-period-header";
import {
settlementCenterListHref,
settlementPeriodViewHref,
type SettlementBillScopeFilter,
type SettlementPeriodView,
} from "@/modules/settlement/settlement-center-nav";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type SettlementCenterPeriodDetailProps = {
period: SettlementPeriodRow;
view: SettlementPeriodView;
billScope: SettlementBillScopeFilter;
adminSiteId: number;
currencyCode: string;
canOperateBills: boolean;
canManagePeriods: boolean;
boundAgentId?: number | null;
refreshKey: number;
onOpenBillDetail: (billId: number) => void;
onOpenBillDetail: (billId: number, focus?: "confirm" | "payment") => void;
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
onReloadPeriods?: () => Promise<unknown>;
};
export function SettlementCenterPeriodDetail({
period,
view,
billScope,
adminSiteId,
currencyCode,
canOperateBills,
canManagePeriods,
boundAgentId = null,
refreshKey,
onOpenBillDetail,
onPeriodClosed,
onReloadPeriods,
}: SettlementCenterPeriodDetailProps): React.ReactElement {
const { t } = useTranslation("settlementCenter");
const subViews: { key: SettlementPeriodView; label: string }[] = [
{ key: "bills", label: t("nav.bills", { defaultValue: "账单" }) },
{ key: "operations", label: t("nav.operations", { defaultValue: "收付与调账" }) },
{ key: "ledger", label: t("nav.ledger", { defaultValue: "账务流水" }) },
];
@@ -54,31 +54,21 @@ export function SettlementCenterPeriodDetail({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col gap-2">
<Link
href={settlementCenterListHref(adminSiteId)}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")}
>
<ArrowLeft className="size-4" aria-hidden />
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
</Link>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold tracking-tight">
{formatSettlementPeriodSpan(period.period_start, period.period_end)}
</h2>
<AdminStatusBadge status={period.status}>
{settlementPeriodStatusLabel(period.status, t)}
</AdminStatusBadge>
</div>
</div>
</div>
<SettlementPeriodHeader
period={period}
adminSiteId={adminSiteId}
currencyCode={currencyCode}
billScope={billScope}
canManage={canManagePeriods}
onPeriodClosed={onPeriodClosed}
onReloadPeriods={onReloadPeriods}
/>
<AdminSubnav aria-label={t("nav.aria", { defaultValue: "账期视图" })}>
{subViews.map((item) => (
<AdminSubnavLink
key={item.key}
href={settlementPeriodViewHref(period.id, item.key, adminSiteId)}
href={settlementPeriodViewHref(period.id, item.key, adminSiteId, billScope)}
active={view === item.key}
>
{item.label}
@@ -88,12 +78,14 @@ export function SettlementCenterPeriodDetail({
{view === "bills" ? (
<SettlementMainPanel
key={`${adminSiteId}-${period.id}-${refreshKey}`}
key={`${adminSiteId}-${period.id}-${refreshKey}-${billScope}`}
adminSiteId={adminSiteId}
currencyCode={currencyCode}
periodFilter={period.id}
billScope={billScope}
onOpenBillDetail={onOpenBillDetail}
refreshKey={refreshKey}
canOperateBills={canOperateBills}
pendingConfirm={pendingConfirm}
awaitingPayment={awaitingPayment}
selectedPeriodStatus={period.status}
@@ -101,17 +93,6 @@ export function SettlementCenterPeriodDetail({
/>
) : null}
{view === "operations" ? (
<SettlementOperationsPanel
key={`${adminSiteId}-${period.id}-${refreshKey}-ops`}
adminSiteId={adminSiteId}
settlementPeriodId={period.id}
currencyCode={currencyCode}
refreshKey={refreshKey}
onOpenBill={onOpenBillDetail}
/>
) : null}
{view === "ledger" ? (
<SettlementCreditLedgerPanel
key={`${adminSiteId}-${period.id}-${refreshKey}`}
@@ -121,7 +102,6 @@ export function SettlementCenterPeriodDetail({
refreshKey={refreshKey}
/>
) : null}
</div>
);
}
}

View File

@@ -2,7 +2,7 @@
import { Check, ChevronDown, Search } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -15,6 +15,7 @@ import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import {
parseSettlementCenterView,
preferredBillScopeForPeriod,
settlementCenterListHref,
settlementPeriodViewHref,
type SettlementPeriodView,
@@ -45,11 +46,24 @@ export function SettlementCenterShell(): React.ReactElement {
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const { siteId: siteFromUrl, periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
const {
siteId: siteFromUrl,
periodId: activePeriodId,
view: activeView,
billScope: activeBillScope,
} = parseSettlementCenterView(
searchParams.get("site"),
searchParams.get("period"),
searchParams.get("view"),
searchParams.get("scope"),
);
const billIdFromUrl = Number.parseInt(searchParams.get("bill") ?? "", 10);
const billFocusFromUrl =
searchParams.get("focus") === "payment"
? "payment"
: searchParams.get("focus") === "confirm"
? "confirm"
: null;
const canOperateBills =
profile?.is_super_admin === true ||
@@ -66,6 +80,8 @@ export function SettlementCenterShell(): React.ReactElement {
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null);
const [detailBillFocus, setDetailBillFocus] = useState<"confirm" | "payment" | null>(null);
const billDeepLinkHandledRef = useRef<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [periodLookupDone, setPeriodLookupDone] = useState(false);
@@ -261,8 +277,15 @@ export function SettlementCenterShell(): React.ReactElement {
const activePeriod =
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
router.push(settlementPeriodViewHref(periodId, view, siteId));
const openPeriodView = (periodId: number, view: SettlementPeriodView = "bills"): void => {
const period = periods.find((row) => row.id === periodId);
const scope = preferredBillScopeForPeriod(period?.summary);
router.push(settlementPeriodViewHref(periodId, view, siteId, scope));
};
const openBillDetail = (billId: number, focus?: "confirm" | "payment"): void => {
setDetailBillId(billId);
setDetailBillFocus(focus ?? null);
};
const isListMode = activePeriodId === null;
@@ -279,14 +302,30 @@ export function SettlementCenterShell(): React.ReactElement {
return;
}
if (activePeriodId !== null && siteFromUrl !== siteId) {
router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId));
router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId, activeBillScope));
}
}, [activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]);
}, [activeBillScope, activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]);
useEffect(() => {
setPeriodLookupDone(false);
}, [activePeriodId, siteId]);
useEffect(() => {
if (
!periodsReady ||
!Number.isFinite(billIdFromUrl) ||
billIdFromUrl <= 0 ||
activePeriodId === null
) {
return;
}
if (billDeepLinkHandledRef.current === billIdFromUrl) {
return;
}
billDeepLinkHandledRef.current = billIdFromUrl;
openBillDetail(billIdFromUrl, billFocusFromUrl ?? undefined);
}, [activePeriodId, billFocusFromUrl, billIdFromUrl, periodsReady]);
useEffect(() => {
if (!periodsReady || activePeriodId === null || siteId === null) {
return;
@@ -306,7 +345,9 @@ export function SettlementCenterShell(): React.ReactElement {
const match = (data.items ?? []).find((row) => row.id === activePeriodId);
if (match?.admin_site_id && match.admin_site_id !== siteId) {
setAdminSiteId(match.admin_site_id);
router.replace(settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id));
router.replace(
settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id, activeBillScope),
);
return;
}
@@ -381,21 +422,51 @@ export function SettlementCenterShell(): React.ReactElement {
<SettlementCenterPeriodDetail
period={activePeriod}
view={activeView}
billScope={activeBillScope}
adminSiteId={siteId}
currencyCode={currency}
canOperateBills={canOperateBills}
canManagePeriods={canManagePeriods}
boundAgentId={boundAgent?.id ?? null}
refreshKey={refreshKey}
onOpenBillDetail={setDetailBillId}
onOpenBillDetail={openBillDetail}
onPeriodClosed={(result) => {
setRefreshKey((n) => n + 1);
const n = result?.unsettled_ticket_count ?? 0;
if (n > 0) {
toast.warning(
t("toast.periodClosedUnsettled", {
defaultValue: "已关账,仍有 {{count}} 笔注单未结算。",
count: n,
}),
);
}
}}
onReloadPeriods={loadPeriods}
/>
)}
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<Dialog
open={detailBillId !== null}
onOpenChange={(open) => {
if (!open) {
setDetailBillId(null);
setDetailBillFocus(null);
}
}}
>
<DialogContent
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(640px,calc(100vw-2rem))] sm:!max-w-[640px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(720px,calc(100vw-2rem))] sm:!max-w-[720px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
<DialogTitle>
{detailBillId !== null
? t("actions.billDetailWithId", {
defaultValue: "账单 #{{id}}",
id: detailBillId,
})
: t("actions.billDetail", { defaultValue: "账单详情" })}
</DialogTitle>
</DialogHeader>
{detailBillId !== null ? (
<div className="min-h-0 overflow-y-auto px-6 py-5">
@@ -405,6 +476,7 @@ export function SettlementCenterShell(): React.ReactElement {
canManage={canOperateBills}
boundAgent={boundAgent}
canFinanceAdjustments={canFinanceAdjustments}
focusSection={detailBillFocus}
onUpdated={() => {
void loadPeriods();
setRefreshKey((n) => n + 1);

View File

@@ -1,11 +1,13 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementBills,
postSettlementBillConfirm,
type SettlementBillListScope,
type SettlementBillRow,
} from "@/api/admin-agent-settlement";
@@ -22,40 +24,45 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
import {
settlementPeriodViewHref,
type SettlementBillScopeFilter,
} from "@/modules/settlement/settlement-center-nav";
import { settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
import { LotteryApiBizError } from "@/types/api/errors";
import { cn } from "@/lib/utils";
type BillTypeFilter = "all" | "player" | "agent";
type BillStatusFilter = "all" | SettlementBillListScope;
type BillFilters = {
billId: string;
ownerKeyword: string;
billType: BillTypeFilter;
statusScope: BillStatusFilter;
statusScope: SettlementBillScopeFilter;
};
function filtersForPeriod(boundAgentId: number | null): BillFilters {
function filtersForPeriod(
boundAgentId: number | null,
billScope: SettlementBillScopeFilter,
): BillFilters {
return {
billId: "",
ownerKeyword: "",
billType: boundAgentId !== null ? "agent" : "all",
statusScope: "all",
statusScope: billScope,
};
}
function apiQueryFromFilters(filters: BillFilters): {
bill_type?: string;
scope?: SettlementBillListScope;
bill_id?: number;
keyword?: string;
} {
const out: {
bill_type?: string;
scope?: SettlementBillListScope;
bill_id?: number;
keyword?: string;
} = {};
@@ -65,10 +72,6 @@ function apiQueryFromFilters(filters: BillFilters): {
if (filters.statusScope !== "all") {
out.scope = filters.statusScope;
}
const id = Number(filters.billId.trim());
if (filters.billId.trim() !== "" && !Number.isNaN(id) && id > 0) {
out.bill_id = id;
}
const keyword = filters.ownerKeyword.trim();
if (keyword !== "") {
out.keyword = keyword;
@@ -81,8 +84,10 @@ export type SettlementMainPanelProps = {
adminSiteId: number;
currencyCode: string;
periodFilter: AgentSettlementPeriodFilter;
onOpenBillDetail: (billId: number) => void;
billScope?: SettlementBillScopeFilter;
onOpenBillDetail: (billId: number, focus?: "confirm" | "payment") => void;
refreshKey?: number;
canOperateBills?: boolean;
pendingConfirm: number;
awaitingPayment: number;
selectedPeriodStatus?: string | null;
@@ -93,8 +98,10 @@ export function SettlementMainPanel({
adminSiteId,
currencyCode,
periodFilter,
billScope = "all",
onOpenBillDetail,
refreshKey = 0,
canOperateBills = false,
pendingConfirm,
awaitingPayment,
selectedPeriodStatus,
@@ -103,8 +110,12 @@ export function SettlementMainPanel({
const { t } = useTranslation("settlementCenter");
const periodId = periodFilter === "all" ? undefined : periodFilter;
const periodOpen = selectedPeriodStatus === "open";
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const initialFilters = useMemo(() => filtersForPeriod(boundAgentId), [boundAgentId]);
const initialFilters = useMemo(
() => filtersForPeriod(boundAgentId, billScope),
[boundAgentId, billScope],
);
const [draft, setDraft] = useState<BillFilters>(initialFilters);
const [applied, setApplied] = useState<BillFilters>(initialFilters);
@@ -129,7 +140,6 @@ export function SettlementMainPanel({
settlement_period_id: periodId,
bill_type: q.bill_type,
scope: q.scope,
bill_id: q.bill_id,
keyword: q.keyword,
page,
per_page: perPage,
@@ -164,7 +174,7 @@ export function SettlementMainPanel({
setPage(1);
};
const statusOptionLabel = (value: BillStatusFilter): string => {
const statusOptionLabel = (value: SettlementBillScopeFilter): string => {
if (value === "all") {
return t("billsPanel.filterAll", { defaultValue: "全部状态" });
}
@@ -185,7 +195,7 @@ export function SettlementMainPanel({
const emptyBillMessage = useMemo((): string | undefined => {
if (periodOpen) {
return t("empty.billsNeedClose", {
defaultValue: "账单在关账后生成。请返回账期列表,对本期执行「关账」后再查看。",
defaultValue: "账单在关账后生成。请对本期执行「关账」后再查看。",
});
}
if (applied.statusScope !== "all") {
@@ -213,75 +223,95 @@ export function SettlementMainPanel({
}
};
const scopeChipClass = (active: boolean): string =>
cn(
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
active
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground",
);
const scopeChips: SettlementBillScopeFilter[] = [
"all",
"pending_confirm",
"awaiting_payment",
"settled",
];
const requestConfirmBill = (row: SettlementBillRow): void => {
requestConfirm({
title: t("billsPanel.confirmOneTitle", { defaultValue: "确认账单 #{{id}}", id: row.id }),
description: t("billsPanel.confirmOneDesc", {
defaultValue: "确认后进入待收付,可登记线下收付。",
}),
confirmLabel: t("billsPanel.confirmOneBtn", { defaultValue: "确认" }),
onConfirm: async () => {
try {
await postSettlementBillConfirm(row.id);
await load();
toast.success(t("billsPanel.confirmedOne", { defaultValue: "已确认" }));
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("billsPanel.confirmFailed", { defaultValue: "确认失败" }),
);
}
},
});
};
const canOperateRow = (row: SettlementBillRow): boolean =>
canOperateBills && settlementBillOperableByBoundAgent(row, boundAgentId ? { id: boundAgentId } : null);
return (
<div className="space-y-5">
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="grid gap-x-5 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-2">
<Label htmlFor="sb-bill-id" className="text-muted-foreground">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
<Input
id="sb-bill-id"
inputMode="numeric"
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
value={draft.billId}
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
runSearch();
}
}}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="sb-owner" className="text-muted-foreground">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
<Input
id="sb-owner"
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
value={draft.ownerKeyword}
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
runSearch();
}
}}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="sb-status" className="text-muted-foreground">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
<Select
modal={false}
value={draft.statusScope}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusScope: (v ?? "all") as BillStatusFilter,
}))
<div className="space-y-4">
<ConfirmDialog />
{!periodOpen && periodId != null ? (
<div className="flex flex-wrap items-center gap-2">
{scopeChips.map((scope) => {
const active = applied.statusScope === scope;
const href = settlementPeriodViewHref(periodId, "bills", adminSiteId, scope);
const count =
scope === "pending_confirm"
? pendingConfirm
: scope === "awaiting_payment"
? awaitingPayment
: undefined;
return (
<Link key={scope} href={href} className={scopeChipClass(active)}>
{statusOptionLabel(scope)}
{count != null && count > 0 ? ` ${count}` : null}
</Link>
);
})}
</div>
) : null}
<div className="admin-list-toolbar">
<div className="admin-list-field min-w-[12rem] flex-1">
<Label htmlFor="sb-owner" className="sm:shrink-0">
{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}
</Label>
<Input
id="sb-owner"
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
value={draft.ownerKeyword}
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
runSearch();
}
>
<SelectTrigger id="sb-status" className="w-full bg-background/50 transition-colors focus:bg-background">
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
[
"all",
"pending_confirm",
"awaiting_payment",
"settled",
"adjustment",
] as BillStatusFilter[]
).map((value) => (
<SelectItem key={value} value={value}>
{statusOptionLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="sb-type" className="text-muted-foreground">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
}}
/>
</div>
{billTypeOptions.length > 1 ? (
<div className="admin-list-field">
<Label htmlFor="sb-type" className="sm:shrink-0">
{t("billsPanel.billType", { defaultValue: "账单类型" })}
</Label>
<Select
modal={false}
value={draft.billType}
@@ -292,7 +322,7 @@ export function SettlementMainPanel({
}))
}
>
<SelectTrigger id="sb-type" className="w-full bg-background/50 transition-colors focus:bg-background">
<SelectTrigger id="sb-type" className="h-9 w-full sm:w-36">
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -304,16 +334,15 @@ export function SettlementMainPanel({
</SelectContent>
</Select>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button type="button" onClick={() => runSearch()}>
) : null}
<div className="admin-list-actions">
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" variant="outline" onClick={() => resetFilters()}>
<Button type="button" size="sm" variant="secondary" onClick={() => resetFilters()}>
{t("billsPanel.reset", { defaultValue: "重置" })}
</Button>
<Button type="button" variant="secondary" onClick={() => void load()}>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("billsPanel.refresh", { defaultValue: "刷新" })}
</Button>
</div>
@@ -329,7 +358,23 @@ export function SettlementMainPanel({
currencyCode={currencyCode}
billTypeFilter={applied.billType}
emptyMessage={emptyBillMessage}
onOpenDetail={onOpenBillDetail}
canOperate={canOperateBills}
boundAgentId={boundAgentId}
onOpenDetail={(billId) => onOpenBillDetail(billId)}
onConfirmBill={(row) => {
if (canOperateRow(row) && row.status === "pending_confirm") {
requestConfirmBill(row);
}
}}
onPayBill={(row) => {
if (
canOperateRow(row) &&
["confirmed", "partial_paid", "overdue"].includes(row.status) &&
row.unpaid_amount > 0
) {
onOpenBillDetail(row.id, "payment");
}
}}
/>
<AdminListPaginationFooter
selectId="settlement-bills-per-page"
@@ -348,4 +393,4 @@ export function SettlementMainPanel({
)}
</div>
);
}
}

View File

@@ -0,0 +1,223 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
postSettlementPeriodClose,
type SettlementPeriodCloseResult,
type SettlementPeriodRow,
} from "@/api/admin-agent-settlement";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
settlementCenterListHref,
settlementPeriodViewHref,
type SettlementBillScopeFilter,
} from "@/modules/settlement/settlement-center-nav";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import { LotteryApiBizError } from "@/types/api/errors";
import { cn } from "@/lib/utils";
type SettlementPeriodHeaderProps = {
period: SettlementPeriodRow;
adminSiteId: number;
currencyCode: string;
billScope: SettlementBillScopeFilter;
canManage: boolean;
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
onReloadPeriods?: () => Promise<unknown>;
};
function scopeChipClass(active: boolean): string {
return cn(
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
active
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground",
);
}
export function SettlementPeriodHeader({
period,
adminSiteId,
currencyCode,
billScope,
canManage,
onPeriodClosed,
onReloadPeriods,
}: SettlementPeriodHeaderProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [busy, setBusy] = useState(false);
const pendingConfirm = period.summary?.pending_confirm ?? 0;
const awaitingPayment = period.summary?.awaiting_payment ?? 0;
const totalUnpaid = period.summary?.total_unpaid ?? 0;
const shareCount = period.pipeline?.share_ledger_count ?? 0;
const unsettledCount = period.pipeline?.unsettled_ticket_count ?? 0;
const isOpen = period.status === "open";
const scopeChips: { key: SettlementBillScopeFilter; label: string; count?: number }[] = [
{
key: "all",
label: t("billsPanel.filterAll", { defaultValue: "全部" }),
},
{
key: "pending_confirm",
label: t("billsPanel.category.pendingConfirm", { defaultValue: "待确认" }),
count: pendingConfirm,
},
{
key: "awaiting_payment",
label: t("billsPanel.category.awaitingPayment", { defaultValue: "待收付" }),
count: awaitingPayment,
},
];
async function confirmClose(): Promise<void> {
setBusy(true);
try {
const result = await postSettlementPeriodClose(period.id);
await onReloadPeriods?.();
setCloseDialogOpen(false);
onPeriodClosed?.(result);
const n = result.unsettled_ticket_count ?? 0;
if (n > 0) {
toast.warning(
t("toast.periodClosedUnsettled", {
defaultValue: "已关账,仍有 {{count}} 笔注单未结算。",
count: n,
}),
);
} else {
toast.success(t("agents:settlementPeriods.closed", { defaultValue: "账期已关账,账单已生成" }));
}
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("agents:settlementPeriods.closeFailed", { defaultValue: "关账失败" }),
);
} finally {
setBusy(false);
}
}
return (
<>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-2">
<Link
href={settlementCenterListHref(adminSiteId)}
className="w-fit text-sm text-muted-foreground hover:text-foreground hover:underline"
>
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
</Link>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold tracking-tight">
{formatSettlementPeriodSpan(period.period_start, period.period_end)}
</h2>
<AdminStatusBadge status={period.status}>
{settlementPeriodStatusLabel(period.status, t)}
</AdminStatusBadge>
</div>
</div>
{canManage && isOpen ? (
<Button type="button" size="sm" disabled={busy} onClick={() => setCloseDialogOpen(true)}>
{t("periodTable.close", { defaultValue: "关账" })}
</Button>
) : null}
</div>
{!isOpen ? (
<div className="flex flex-wrap items-center gap-2">
{scopeChips.map((chip) => {
const active = billScope === chip.key;
const href = settlementPeriodViewHref(period.id, "bills", adminSiteId, chip.key);
return (
<Link key={chip.key} href={href} className={scopeChipClass(active)}>
{chip.label}
{chip.count != null && chip.count > 0 ? ` ${chip.count}` : null}
</Link>
);
})}
{totalUnpaid > 0 ? (
<span className="text-xs text-muted-foreground">
{t("overview.totalUnpaid", { defaultValue: "未结合计" })}{" "}
<span className="font-medium tabular-nums text-foreground">
{formatDashboardMoneyMinor(totalUnpaid, currencyCode)}
</span>
</span>
) : null}
</div>
) : (
<p className="text-xs text-muted-foreground">
{t("overview.pipelineHint", {
defaultValue: "账单须关账后生成;下方为账期内实时流水笔数。",
})}
{shareCount > 0
? ` · ${t("period.pipelineShare", { defaultValue: "流水 {{count}} 笔", count: shareCount })}`
: null}
</p>
)}
</div>
<Dialog open={closeDialogOpen} onOpenChange={setCloseDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("period.closeDialogTitle", { defaultValue: "确认关账" })}</DialogTitle>
<DialogDescription>
{t("period.closeDialogDesc", {
defaultValue: "将汇总 {{range}} 内的流水并生成账单。",
range: formatSettlementPeriodSpan(period.period_start, period.period_end),
})}
</DialogDescription>
</DialogHeader>
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
<li>
{shareCount > 0
? t("period.closeDialogShare", {
defaultValue: "流水 {{count}} 笔",
count: shareCount,
})
: t("period.closeDialogEmpty", {
defaultValue: "本期暂无占成流水,关账后不会生成账单。",
})}
</li>
{unsettledCount > 0 ? (
<li className="text-amber-800">
{t("period.closeDialogUnsettled", {
defaultValue: "仍有 {{count}} 笔注单未结算",
count: unsettledCount,
})}
</li>
) : null}
</ul>
<DialogFooter>
<Button type="button" variant="outline" disabled={busy} onClick={() => setCloseDialogOpen(false)}>
{t("common:cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={busy} onClick={() => void confirmClose()}>
{t("period.closeDialogConfirm", { defaultValue: "确认关账" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -95,6 +95,12 @@ export function SettlementPeriodsTable({
) : (
periods.map((row) => {
const canCloseRow = canManage && row.status === "open";
const todoCount =
(row.summary?.pending_confirm ?? 0) + (row.summary?.awaiting_payment ?? 0);
const detailLabel =
todoCount > 0
? t("periodTable.processBills", { defaultValue: "处理账单" })
: t("periodTable.viewDetail", { defaultValue: "查看详情" });
return (
<TableRow key={row.id}>
@@ -144,7 +150,7 @@ export function SettlementPeriodsTable({
actions={[
{
key: "detail",
label: t("periodTable.viewDetail", { defaultValue: "查看详情" }),
label: detailLabel,
icon: Eye,
onClick: () => onViewDetail(row.id),
},

View File

@@ -0,0 +1,79 @@
import { getSettlementBills, type SettlementBillRow } from "@/api/admin-agent-settlement";
import type { AdminPlayerRow } from "@/types/api/admin-player";
import {
settlementPeriodViewHref,
type SettlementBillScopeFilter,
} from "@/modules/settlement/settlement-center-nav";
export type SettlementBillFocus = "confirm" | "payment";
function pickActionableBill(bills: SettlementBillRow[]): SettlementBillRow | null {
const pending = bills.find((bill) => bill.status === "pending_confirm");
if (pending) {
return pending;
}
const awaiting = bills.find(
(bill) =>
["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
Number(bill.unpaid_amount ?? 0) > 0,
);
if (awaiting) {
return awaiting;
}
return bills[0] ?? null;
}
export function settlementBillDeepLinkHref(
bill: SettlementBillRow,
adminSiteId?: number | null,
): string {
const scope: SettlementBillScopeFilter =
bill.status === "pending_confirm" ? "pending_confirm" : "awaiting_payment";
const focus: SettlementBillFocus =
bill.status === "pending_confirm" ? "confirm" : "payment";
const params = new URLSearchParams(
settlementPeriodViewHref(
bill.settlement_period_id,
"bills",
adminSiteId ?? bill.admin_site_id,
scope,
).split("?")[1] ?? "",
);
params.set("bill", String(bill.id));
params.set("focus", focus);
return `/admin/settlement-center?${params.toString()}`;
}
export async function resolvePlayerSettlementHref(
player: Pick<AdminPlayerRow, "id" | "site_player_id" | "username">,
adminSiteId?: number | null,
): Promise<string | null> {
const keyword =
player.site_player_id?.trim() || player.username?.trim() || String(player.id);
const data = await getSettlementBills({
bill_type: "player",
keyword,
per_page: 20,
page: 1,
});
const items = (data.items ?? []).filter(
(bill) =>
bill.bill_type === "player" &&
bill.owner_id === player.id &&
(bill.status === "pending_confirm" || Number(bill.unpaid_amount ?? 0) > 0),
);
const bill = pickActionableBill(items);
if (bill === null) {
return null;
}
return settlementBillDeepLinkHref(bill, adminSiteId);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
/** 长单号/流水号:单行截断;点击复制全文,悬停可看全文 */
export function WalletCellMonoId({
value,
empty = "—",
copyHint,
}: {
value: string | null | undefined;
empty?: string;
/** 用于 toast / 无障碍:如「流水号」「主站流水号」 */
copyHint?: string;
}): React.ReactElement {
const { t } = useTranslation("wallet");
if (value == null || value === "") {
return <span className="text-muted-foreground">{empty}</span>;
}
const copy = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(value);
toast.success(
copyHint
? t("copySuccess", { label: copyHint })
: t("copySuccess", { label: "" }).trim(),
);
} catch {
toast.error(t("copyFailed"));
}
};
return (
<button
type="button"
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
title={value}
aria-label={copyHint ?? t("copyTxnNo")}
onClick={(e) => void copy(e)}
>
<span className="min-w-0 flex-1 truncate">{value}</span>
<Copy
className="size-3.5 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100"
aria-hidden
/>
</button>
);
}

View File

@@ -1,10 +1,10 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Copy, RotateCcw, Wrench } from "lucide-react";
import { RotateCcw, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
@@ -12,7 +12,6 @@ import { toast } from "sonner";
import {
getAdminPlayerWallets,
getAdminTransferOrders,
getAdminWalletTransactions,
reverseTransferOrder,
manuallyProcessTransferOrder,
completeTransferInCredit,
@@ -58,204 +57,27 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { formatAdminMinorUnits } from "@/lib/money";
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
import { WalletCellMonoId } from "@/modules/wallet/wallet-cell-mono-id";
import {
TRANSFER_ORDER_STATUS_OPTIONS,
WALLET_FILTER_ALL,
emptyTransferFilters,
transferFiltersFromSearchParams,
walletAdminSelectDisplayedLabel,
type TransferFilters,
} from "@/modules/wallet/wallet-filter-utils";
import { walletStatusLabel } from "@/modules/wallet/wallet-labels";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminPlayerWalletsData,
AdminTransferOrderItem,
AdminTransferOrderListData,
AdminWalletTxnListData,
} from "@/types/api/admin-wallet";
/** 长单号/流水号:单行截断;点击复制全文,悬停可看全文 */
function CellMonoId({
value,
empty = "—",
copyHint,
}: {
value: string | null | undefined;
empty?: string;
/** 用于 toast / 无障碍:如「流水号」「主站流水号」 */
copyHint?: string;
}): React.ReactElement {
const { t } = useTranslation("wallet");
if (value == null || value === "") {
return <span className="text-muted-foreground">{empty}</span>;
}
const copy = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(value);
toast.success(
copyHint
? t("copySuccess", { label: copyHint })
: t("copySuccess", { label: "" }).trim(),
);
} catch {
toast.error(t("copyFailed"));
}
};
return (
<button
type="button"
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
title={value}
aria-label={copyHint ?? t("copyTxnNo")}
onClick={(e) => void copy(e)}
>
<span className="min-w-0 flex-1 truncate">{value}</span>
<Copy
className="size-3.5 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100"
aria-hidden
/>
</button>
);
}
function walletTxnBizTypeLabel(
bizType: string,
ledgerSource: string | null | undefined,
t: (key: string) => string,
tSettlement: TFunction<"settlementCenter">,
): string {
if (ledgerSource === "credit_ledger") {
return creditLedgerReasonLabel(bizType, tSettlement);
}
switch (bizType) {
case "transfer_in":
return t("transferIn");
case "transfer_out":
return t("transferOut");
case "transfer_out_refund":
return t("transferOutRefund");
case "bet_deduct":
return t("bizBetDeduct");
case "bet_reverse":
return t("bizBetReverse");
case "settle_payout":
return t("bizSettlePayout");
case "jackpot_manual_payout":
return t("bizJackpotPayout");
case "settlement_adjustment":
return t("bizSettlementAdjustment");
default:
return bizType;
}
}
function statusLabelT(status: string, t: (key: string) => string): string {
switch (status) {
case "processing":
return t("statusProcessing");
case "success":
return t("statusSuccess");
case "failed":
return t("statusFailed");
case "pending_reconcile":
return t("statusPendingReconcile");
case "reversed":
return t("statusReversed");
case "manually_processed":
return t("statusCaseClosed");
case "posted":
return t("statusPosted");
default:
return status;
}
}
type TransferFilters = {
playerId: string;
playerAccount: string;
transferNo: string;
externalRefNo: string;
createdFrom: string;
createdTo: string;
statusCsv: string;
abnormalOnly: boolean;
};
const emptyTransferFilters: TransferFilters = {
playerId: "",
playerAccount: "",
transferNo: "",
externalRefNo: "",
createdFrom: "",
createdTo: "",
statusCsv: "",
abnormalOnly: false,
};
type TxnFilters = {
playerId: string;
playerAccount: string;
txnNo: string;
externalRefNo: string;
bizType: string;
statusCsv: string;
createdFrom: string;
createdTo: string;
abnormalOnly: boolean;
};
const emptyTxnFilters: TxnFilters = {
playerId: "",
playerAccount: "",
txnNo: "",
externalRefNo: "",
bizType: "",
statusCsv: "",
createdFrom: "",
createdTo: "",
abnormalOnly: false,
};
/** 下拉「不限」值;请求时转为空串不传参 */
const WALLET_FILTER_ALL = "__all__";
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
{ value: "transfer_in", label: "transferIn" },
{ value: "transfer_out", label: "transferOut" },
{ value: "transfer_out_refund", label: "transferOutRefund" },
];
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "posted", label: "statusPosted" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
];
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "processing", label: "statusProcessing" },
{ value: "success", label: "statusSuccess" },
{ value: "failed", label: "statusFailed" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
{ value: "manually_processed", label: "statusCaseClosed" },
];
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
function walletAdminSelectDisplayedLabel(
raw: unknown,
options: readonly { value: string; label: string }[],
t?: (key: string) => string,
): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === WALLET_FILTER_ALL) {
return t ? t("filterAll") : "All";
}
const key = options.find((o) => o.value === v)?.label;
return key ? (t ? t(key) : key) : v;
}
export { WalletTxnsPanel } from "@/modules/wallet/wallet-txns-panel";
type TransferOrderRowActionsProps = {
row: AdminTransferOrderItem;
@@ -317,24 +139,27 @@ export function TransferOrdersPanel(): React.ReactElement {
useAdminCurrencyCatalog();
const formatTs = useAdminDateTimeFormatter();
const searchParams = useSearchParams();
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
const transferNoFromUrl = (searchParams.get("transfer_no") ?? "").trim();
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
const urlFilterKey = searchParams.toString();
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const initialTransferFilters: TransferFilters = {
...emptyTransferFilters,
playerId: playerIdFromUrl,
transferNo: transferNoFromUrl,
externalRefNo: externalRefNoFromUrl,
};
const [draft, setDraft] = useState<TransferFilters>(initialTransferFilters);
const [applied, setApplied] = useState<TransferFilters>(initialTransferFilters);
const [draft, setDraft] = useState<TransferFilters>(() =>
transferFiltersFromSearchParams(searchParams),
);
const [applied, setApplied] = useState<TransferFilters>(() =>
transferFiltersFromSearchParams(searchParams),
);
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
useEffect(() => {
const next = transferFiltersFromSearchParams(searchParams);
setDraft(next);
setApplied(next);
setPage(1);
}, [urlFilterKey, searchParams]);
const doAction = async (
transferNo: string,
fn: () => Promise<unknown>,
@@ -399,7 +224,7 @@ export function TransferOrdersPanel(): React.ReactElement {
external_ref_no: applied.externalRefNo.trim() || undefined,
created_from: applied.createdFrom.trim() || undefined,
created_to: applied.createdTo.trim() || undefined,
status: applied.statusCsv.trim() || undefined,
status: applied.abnormalOnly ? undefined : applied.statusCsv.trim() || undefined,
});
setData(d);
} catch (e) {
@@ -432,6 +257,11 @@ export function TransferOrdersPanel(): React.ReactElement {
<CardTitle>{t("transferOrders")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{applied.abnormalOnly ? (
<p className="rounded-md border border-amber-200/80 bg-amber-50 px-3 py-2 text-sm text-amber-950">
{t("abnormalFilterActive")}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
@@ -571,10 +401,10 @@ export function TransferOrdersPanel(): React.ReactElement {
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
<CellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
<WalletCellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
</TableCell>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
<WalletCellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
@@ -585,7 +415,7 @@ export function TransferOrdersPanel(): React.ReactElement {
</AdminTableMoney>
</TableCell>
<TableCell>
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
<AdminStatusBadge status={row.status}>{walletStatusLabel(row.status, t)}</AdminStatusBadge>
</TableCell>
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
{row.fail_reason?.trim() ? row.fail_reason : "—"}
@@ -639,322 +469,6 @@ export function TransferOrdersPanel(): React.ReactElement {
);
}
export function WalletTxnsPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const { t: tSettlement } = useTranslation("settlementCenter");
const tRef = useTranslationRef(["wallet", "common"]);
const exportLabels = useExportLabels("walletTransactions");
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const searchParams = useSearchParams();
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
const txnNoFromUrl = (searchParams.get("txn_no") ?? "").trim();
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
const initialTxnFilters: TxnFilters = {
...emptyTxnFilters,
playerId: playerIdFromUrl,
txnNo: txnNoFromUrl,
externalRefNo: externalRefNoFromUrl,
};
const [draft, setDraft] = useState<TxnFilters>(initialTxnFilters);
const [applied, setApplied] = useState<TxnFilters>(initialTxnFilters);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const player_id =
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
const d = await getAdminWalletTransactions({
page,
per_page: perPage,
abnormal: applied.abnormalOnly || undefined,
player_id:
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
? player_id
: undefined,
player_account: applied.playerAccount.trim() || undefined,
txn_no: applied.txnNo.trim() || undefined,
external_ref_no: applied.externalRefNo.trim() || undefined,
created_from: applied.createdFrom.trim() || undefined,
created_to: applied.createdTo.trim() || undefined,
biz_type: applied.bizType.trim() || undefined,
status: applied.statusCsv.trim() || undefined,
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, applied, tRef]);
useAsyncEffect(() => {
void load();
}, [page, perPage, applied]);
const runSearch = () => {
setApplied({ ...draft });
setPage(1);
};
const resetFilters = () => {
setDraft(emptyTxnFilters);
setApplied(emptyTxnFilters);
setPage(1);
};
const showLedgerColumn =
data?.items.some((row) => row.ledger_source === "credit_ledger") ?? false;
const txnTableColSpan = showLedgerColumn ? 12 : 11;
return (
<Card>
<CardHeader>
<CardTitle>{t("walletTransactions")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="tx-no">{t("txnNo")}</Label>
<Input
id="tx-no"
placeholder={t("search")}
value={draft.txnNo}
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-ext">{t("externalRefNo")}</Label>
<Input
id="tx-ext"
placeholder={t("search")}
value={draft.externalRefNo}
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-account">{t("playerAccount")}</Label>
<Input
id="tx-account"
placeholder={t("playerAccountPlaceholder")}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-player">{t("playerId")}</Label>
<Input
id="tx-player"
inputMode="numeric"
placeholder={t("playerIdOptional")}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-biz">{t("bizType")}</Label>
<Select
modal={false}
value={
draft.bizType === "" || !WALLET_TXN_BIZ_OPTIONS.some((o) => o.value === draft.bizType)
? WALLET_FILTER_ALL
: draft.bizType
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-biz" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-status">{t("status")}</Label>
<Select
modal={false}
value={
draft.statusCsv === "" || !WALLET_TXN_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
? WALLET_FILTER_ALL
: draft.statusCsv
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="tx-created-range"
label={t("requestDateRange")}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none">{t("options")}</span>
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={draft.abnormalOnly}
onCheckedChange={(v) =>
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
}
/>
{t("abnormalOnlyPending")}
</label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<AdminTableExportButton
tableId="wallet-transactions-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("search")}
</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>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
{(loading && !data) || data ? (
<>
<div className="admin-table-shell overflow-x-auto rounded-md border">
<Table id="wallet-transactions-table" className="min-w-[1180px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-[10rem] whitespace-nowrap">{t("txnNo")}</TableHead>
<TableHead className="min-w-[8rem] whitespace-nowrap">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
{showLedgerColumn ? (
<TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead>
) : null}
<TableHead className="min-w-[6.5rem] whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="min-w-[6.5rem] whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("requestTime")}</TableHead>
<TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("finishedTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={txnTableColSpan} />
) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={txnTableColSpan} />
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-[10rem] max-w-[12rem] align-top">
<CellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
</TableCell>
<TableCell className="min-w-[8rem] max-w-[10rem] align-top">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
{showLedgerColumn ? (
<TableCell className="align-top whitespace-nowrap">
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
</TableCell>
) : null}
<TableCell className="min-w-[6.5rem] max-w-[9rem] align-top text-xs">
<span
className="block truncate"
title={walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
>
{walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
</span>
</TableCell>
<TableCell className={adminMoneyCellClassName("min-w-[6.5rem] text-right text-xs")}>
<AdminTableMoney>
{row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
</AdminTableMoney>
<span className="ml-1 text-muted-foreground">
({row.direction === 1 ? t("in") : t("out")})
</span>
</TableCell>
<TableCell className="align-top whitespace-nowrap">
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
</TableCell>
<TableCell className="min-w-[8.5rem] align-top whitespace-nowrap font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
<TableCell className="min-w-[8.5rem] align-top whitespace-nowrap font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.updated_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data ? (
<AdminListPaginationFooter
selectId="wallet-transactions-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
);
}
export function PlayerWalletPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const tRef = useTranslationRef(["wallet", "common"]);

View File

@@ -0,0 +1,116 @@
export type TransferFilters = {
playerId: string;
playerAccount: string;
transferNo: string;
externalRefNo: string;
createdFrom: string;
createdTo: string;
statusCsv: string;
abnormalOnly: boolean;
};
export const emptyTransferFilters: TransferFilters = {
playerId: "",
playerAccount: "",
transferNo: "",
externalRefNo: "",
createdFrom: "",
createdTo: "",
statusCsv: "",
abnormalOnly: false,
};
export function transferFiltersFromSearchParams(searchParams: URLSearchParams): TransferFilters {
return {
...emptyTransferFilters,
playerId: (searchParams.get("player_id") ?? "").trim(),
transferNo: (searchParams.get("transfer_no") ?? "").trim(),
externalRefNo: (searchParams.get("external_ref_no") ?? "").trim(),
abnormalOnly: searchParams.get("abnormal") === "1",
};
}
export type TxnFilters = {
playerId: string;
playerAccount: string;
txnNo: string;
externalRefNo: string;
bizType: string;
statusCsv: string;
createdFrom: string;
createdTo: string;
abnormalOnly: boolean;
};
export const emptyTxnFilters: TxnFilters = {
playerId: "",
playerAccount: "",
txnNo: "",
externalRefNo: "",
bizType: "",
statusCsv: "",
createdFrom: "",
createdTo: "",
abnormalOnly: false,
};
export function txnFiltersFromSearchParams(searchParams: URLSearchParams): TxnFilters {
const abnormalOnly = searchParams.get("abnormal") === "1";
return {
...emptyTxnFilters,
playerId: (searchParams.get("player_id") ?? "").trim(),
txnNo: (searchParams.get("txn_no") ?? "").trim(),
externalRefNo: (searchParams.get("external_ref_no") ?? "").trim(),
abnormalOnly,
statusCsv: abnormalOnly ? "" : emptyTxnFilters.statusCsv,
};
}
export function txnFiltersHaveUrlPreset(filters: TxnFilters): boolean {
return (
filters.txnNo !== "" ||
filters.playerId !== "" ||
filters.externalRefNo !== "" ||
filters.abnormalOnly
);
}
/** 下拉「不限」值;请求时转为空串不传参 */
export const WALLET_FILTER_ALL = "__all__";
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
export const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
{ value: "transfer_in", label: "transferIn" },
{ value: "transfer_out", label: "transferOut" },
{ value: "transfer_out_refund", label: "transferOutRefund" },
];
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
export const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "posted", label: "statusPosted" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
];
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
export const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "processing", label: "statusProcessing" },
{ value: "success", label: "statusSuccess" },
{ value: "failed", label: "statusFailed" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
{ value: "manually_processed", label: "statusCaseClosed" },
];
export function walletAdminSelectDisplayedLabel(
raw: unknown,
options: readonly { value: string; label: string }[],
t?: (key: string) => string,
): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === WALLET_FILTER_ALL) {
return t ? t("filterAll") : "All";
}
const key = options.find((o) => o.value === v)?.label;
return key ? (t ? t(key) : key) : v;
}

View File

@@ -0,0 +1,56 @@
import type { TFunction } from "i18next";
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
export function walletStatusLabel(status: string, t: (key: string) => string): string {
switch (status) {
case "processing":
return t("statusProcessing");
case "success":
return t("statusSuccess");
case "failed":
return t("statusFailed");
case "pending_reconcile":
return t("statusPendingReconcile");
case "reversed":
return t("statusReversed");
case "manually_processed":
return t("statusCaseClosed");
case "posted":
return t("statusPosted");
default:
return status;
}
}
export function walletTxnBizTypeLabel(
bizType: string,
ledgerSource: string | null | undefined,
t: (key: string) => string,
tSettlement: TFunction<"settlementCenter">,
): string {
if (ledgerSource === "credit_ledger") {
return creditLedgerReasonLabel(bizType, tSettlement);
}
switch (bizType) {
case "transfer_in":
return t("transferIn");
case "transfer_out":
return t("transferOut");
case "transfer_out_refund":
return t("transferOutRefund");
case "bet_deduct":
return t("bizBetDeduct");
case "bet_reverse":
return t("bizBetReverse");
case "settle_payout":
return t("bizSettlePayout");
case "jackpot_manual_payout":
return t("bizJackpotPayout");
case "settlement_adjustment":
return t("bizSettlementAdjustment");
default:
return bizType;
}
}

View File

@@ -1,36 +0,0 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
/** 钱包模块仅服务主站钱包玩家;信用盘结账在结算中心。 */
export function WalletScopeHint(): React.ReactElement {
const { t } = useTranslation("wallet");
const profile = useAdminProfile();
const canSettlement = adminHasAnyPermission(profile?.permissions, [
...PRD_SETTLEMENT_AGENT_ACCESS_ANY,
]);
return (
<p className="text-sm text-muted-foreground">
{t("scopeHint", {
defaultValue:
"本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的下注占用、结算记账请查看",
})}
{canSettlement ? (
<Link href="/admin/settlement-center" className="mx-1 text-primary underline">
{t("scopeHintSettlementLink", { defaultValue: "结算中心" })}
</Link>
) : (
<span className="mx-1 font-medium text-foreground">
{t("scopeHintSettlement", { defaultValue: "结算中心" })}
</span>
)}
</p>
);
}

View File

@@ -0,0 +1,421 @@
"use client";
import { RefreshCw } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminWalletTransactions } from "@/api/admin-wallet";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { WalletCellMonoId } from "@/modules/wallet/wallet-cell-mono-id";
import {
WALLET_FILTER_ALL,
WALLET_TXN_BIZ_OPTIONS,
WALLET_TXN_STATUS_OPTIONS,
emptyTxnFilters,
txnFiltersFromSearchParams,
walletAdminSelectDisplayedLabel,
type TxnFilters,
} from "@/modules/wallet/wallet-filter-utils";
import { walletStatusLabel, walletTxnBizTypeLabel } from "@/modules/wallet/wallet-labels";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminWalletTxnListData } from "@/types/api/admin-wallet";
function parsePlayerId(raw: string): number | undefined {
if (raw.trim() === "") {
return undefined;
}
const id = Number(raw);
return !Number.isNaN(id) && id > 0 ? id : undefined;
}
export function WalletTxnsPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const { t: tSettlement } = useTranslation("settlementCenter");
const tRef = useTranslationRef(["wallet", "common"]);
const exportLabels = useExportLabels("walletTransactions");
const formatTs = useAdminDateTimeFormatter();
const searchParams = useSearchParams();
const urlFilterKey = searchParams.toString();
const router = useRouter();
const pathname = usePathname();
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [draft, setDraft] = useState<TxnFilters>(() => txnFiltersFromSearchParams(searchParams));
const [applied, setApplied] = useState<TxnFilters>(() => txnFiltersFromSearchParams(searchParams));
useEffect(() => {
const next = txnFiltersFromSearchParams(searchParams);
setDraft(next);
setApplied(next);
setPage(1);
}, [urlFilterKey, searchParams]);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const d = await getAdminWalletTransactions({
page,
per_page: perPage,
abnormal: applied.abnormalOnly || undefined,
player_id: parsePlayerId(applied.playerId),
player_account: applied.playerAccount.trim() || undefined,
txn_no: applied.txnNo.trim() || undefined,
external_ref_no: applied.externalRefNo.trim() || undefined,
created_from: applied.createdFrom.trim() || undefined,
created_to: applied.createdTo.trim() || undefined,
biz_type: applied.bizType.trim() || undefined,
status: applied.abnormalOnly ? undefined : applied.statusCsv.trim() || undefined,
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, applied, tRef]);
useAsyncEffect(() => {
void load();
}, [load]);
const runSearch = () => {
const next = draft.abnormalOnly ? { ...draft, statusCsv: "" } : { ...draft };
setDraft(next);
setApplied(next);
setPage(1);
};
const resetFilters = () => {
setDraft(emptyTxnFilters);
setApplied(emptyTxnFilters);
setPage(1);
router.replace(pathname);
};
const showLedgerColumn = data?.items.some((row) => row.ledger_source === "credit_ledger") ?? false;
const txnTableColSpan = showLedgerColumn ? 13 : 12;
const hasDeepLink = applied.txnNo !== "" || applied.playerId !== "" || applied.externalRefNo !== "";
return (
<div className="flex w-full flex-col gap-4">
<div className="rounded-lg border border-border/60">
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-3 py-2.5">
<h2 className="text-sm font-semibold">{t("walletTransactions")}</h2>
<div className="flex shrink-0 items-center gap-2">
<AdminTableExportButton
tableId="wallet-transactions-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button type="button" variant="ghost" size="sm" className="h-8" disabled={loading} onClick={() => void load()}>
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
{t("refreshCurrentPage")}
</Button>
</div>
</div>
<div className="space-y-3 border-b border-border/60 px-3 py-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="tx-no" className="text-xs">
{t("txnNo")}
</Label>
<Input
id="tx-no"
className="h-8"
placeholder={t("search")}
value={draft.txnNo}
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-ext" className="text-xs">
{t("externalRefNo")}
</Label>
<Input
id="tx-ext"
className="h-8"
placeholder={t("search")}
value={draft.externalRefNo}
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-account" className="text-xs">
{t("playerAccount")}
</Label>
<Input
id="tx-account"
className="h-8"
placeholder={t("playerAccountPlaceholder")}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-player" className="text-xs">
{t("playerId")}
</Label>
<Input
id="tx-player"
className="h-8"
inputMode="numeric"
placeholder={t("playerIdOptional")}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-biz" className="text-xs">
{t("bizType")}
</Label>
<Select
modal={false}
value={
draft.bizType === "" || !WALLET_TXN_BIZ_OPTIONS.some((o) => o.value === draft.bizType)
? WALLET_FILTER_ALL
: draft.bizType
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-biz" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-status" className="text-xs">
{t("status")}
</Label>
<Select
modal={false}
disabled={draft.abnormalOnly}
value={
draft.statusCsv === "" || !WALLET_TXN_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
? WALLET_FILTER_ALL
: draft.statusCsv
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2 lg:col-span-2">
<AdminDateRangeField
id="tx-created-range"
label={t("requestDateRange")}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
<div className="flex flex-col justify-end gap-2">
<label className="flex min-h-8 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={draft.abnormalOnly}
onCheckedChange={(v) => {
const abnormalOnly = v === true;
setDraft((d) => ({
...d,
abnormalOnly,
statusCsv: abnormalOnly ? "" : d.statusCsv,
}));
}}
/>
{t("abnormalOnlyPending")}
</label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" className="h-8" onClick={() => runSearch()}>
{t("search")}
</Button>
<Button type="button" size="sm" variant="outline" className="h-8" onClick={() => resetFilters()}>
{t("resetFilters")}
</Button>
</div>
</div>
<div className="space-y-3 p-3">
{applied.abnormalOnly ? (
<p className="rounded-md border border-amber-200/80 bg-amber-50 px-3 py-2 text-sm text-amber-950">
{t("txnAbnormalFilterActive")}
</p>
) : null}
{hasDeepLink ? (
<p className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
{t("txnDeepLinkActive")}
</p>
) : null}
{err ? <p className="text-sm text-destructive">{err}</p> : null}
{(loading && !data) || data ? (
<>
<div className="admin-table-shell overflow-x-auto rounded-md border">
<Table id="wallet-transactions-table" className="min-w-[1080px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-[9.5rem]">{t("txnNo")}</TableHead>
<TableHead className="min-w-[8rem]">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
{showLedgerColumn ? (
<TableHead className="whitespace-nowrap">{t("ledgerChannel")}</TableHead>
) : null}
<TableHead className="min-w-[6rem]">{t("type")}</TableHead>
<TableHead className="min-w-[5.5rem] text-right">{t("amount")}</TableHead>
<TableHead className="min-w-[4rem] text-center">{t("direction")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-[8rem] whitespace-nowrap">{t("requestTime")}</TableHead>
<TableHead className="min-w-[8rem] whitespace-nowrap">{t("finishedTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={txnTableColSpan} />
) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={txnTableColSpan} />
) : (
data.items.map((row) => (
<TableRow key={`${row.ledger_source ?? "wallet"}-${row.id}`}>
<TableCell className="align-middle">
<WalletCellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
</TableCell>
<TableCell className="align-middle">
<WalletCellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
{showLedgerColumn ? (
<TableCell className="align-middle whitespace-nowrap">
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
</TableCell>
) : null}
<TableCell className="align-middle text-xs">
<span
className="line-clamp-2"
title={walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
>
{walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
</span>
</TableCell>
<TableCell className={adminMoneyCellClassName("align-middle text-right text-xs")}>
<AdminTableMoney>
{row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
</AdminTableMoney>
</TableCell>
<TableCell className="align-middle text-center text-xs text-muted-foreground">
{row.direction === 1 ? t("in") : t("out")}
</TableCell>
<TableCell className="align-middle whitespace-nowrap">
<AdminStatusBadge status={row.status}>{walletStatusLabel(row.status, t)}</AdminStatusBadge>
</TableCell>
<TableCell className="align-middle whitespace-nowrap font-mono text-xs text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
<TableCell className="align-middle whitespace-nowrap font-mono text-xs text-muted-foreground">
{formatTs(row.updated_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data ? (
<AdminListPaginationFooter
selectId="wallet-transactions-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -53,6 +53,7 @@ export type AgentNodeListData = {
};
export type AgentProfilePayload = {
relative_share_rate?: number;
total_share_rate?: number;
credit_limit?: number;
rebate_limit?: number;

View File

@@ -11,6 +11,7 @@ export type AdminAuditLogRow = {
module_label: string;
action_label: string;
target_label: string;
summary_label: string;
before_json: Record<string, unknown> | null;
after_json: Record<string, unknown> | null;
ip: string | null;