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.siteRequired": "Select an integration site",
"lineProvision.noUnboundSite": "No sites without a level-1 agent", "lineProvision.noUnboundSite": "No sites without a level-1 agent",
"lineProvision.openIntegrationSites": "Go to integration sites", "lineProvision.openIntegrationSites": "Go to integration sites",
"lineProvision.passwordHint": "At least 8 characters", "lineProvision.passwordHint": "At least 6 characters",
"playersPanel.siteCode": "Line site", "playersPanel.siteCode": "Line site",
"playersPanel.passwordHint": "At least 8 characters", "playersPanel.passwordHint": "At least 6 characters",
"playersPanel.passwordMinLength": "Initial password must be at least 8 characters", "playersPanel.passwordMinLength": "Initial password must be at least 6 characters",
"playersPanel.creditLimitInvalid": "Credit limit must be an integer ≥ 0", "playersPanel.creditLimitInvalid": "Credit limit must be an integer ≥ 0",
"playersPanel.creditLimitExceeded": "Credit limit cannot exceed this agents available grant", "playersPanel.creditLimitExceeded": "Credit limit cannot exceed this agents available grant",
"playersPanel.rebateRateInvalid": "Rebate rate must be between 0 and 100%", "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"; export default function AgentsListRedirectPage() {
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; redirect("/admin/agents?view=list");
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>
);
} }

View File

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

View File

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

View File

@@ -1,17 +1,33 @@
import { notFound, redirect } from "next/navigation"; import { notFound } from "next/navigation";
import { buildPageMetadata } from "@/lib/page-metadata"; import { Suspense } from "react";
import type { Metadata } from "next"; 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"); export const metadata: Metadata = buildPageMetadata("reports", "title");
const VALID_CATEGORIES = new Set<ReportCategory>(["profit", "wallet", "risk", "audit"]);
export default async function AdminReportsCategoryPage({ export default async function AdminReportsCategoryPage({
params, params,
}: { }: {
params: Promise<{ category: string }>; params: Promise<{ category: string }>;
}) { }) {
const { category } = await params; const { category } = await params;
if (!["profit", "wallet", "risk", "audit"].includes(category)) { if (!VALID_CATEGORIES.has(category as ReportCategory)) {
notFound(); 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 { redirect } from "next/navigation";
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";
export default function AdminReportsPage() { export default function AdminReportsPage() {
return ( redirect("/admin/reports/profit");
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
<Suspense fallback={<AdminLoadingState minHeight="12rem" />}>
<ReportsConsole />
</Suspense>
</AdminPermissionGate>
);
} }

View File

@@ -1,14 +1,12 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { WalletScopeHint } from "@/modules/wallet/wallet-scope-hint";
import { WalletSubnav } from "@/modules/wallet/wallet-subnav"; import { WalletSubnav } from "@/modules/wallet/wallet-subnav";
export default function AdminWalletLayout({ children }: { children: ReactNode }) { export default function AdminWalletLayout({ children }: { children: ReactNode }) {
return ( 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="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 /> <WalletSubnav />
<WalletScopeHint />
</div> </div>
{children} {children}
</div> </div>

View File

@@ -168,6 +168,24 @@
@apply overflow-x-auto rounded-2xl border border-border/80 bg-card shadow-sm; @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 */ /* Sticky columns need an opaque background so scrolled cells/headers do not show through */
[data-slot="table-head"][class*="sticky"] { [data-slot="table-head"][class*="sticky"] {
@apply bg-muted; @apply bg-muted;

View File

@@ -1,20 +1,17 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Checkbox } from "@/components/ui/checkbox"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
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 { cn } from "@/lib/utils"; 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"; import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
type PackageSelectorProps = { type PackageSelectorProps = {
@@ -22,10 +19,7 @@ type PackageSelectorProps = {
selectedSlugs: string[]; selectedSlugs: string[];
onChange: (next: string[]) => void; onChange: (next: string[]) => void;
resolveGroupLabel: (key: string, fallback: string) => string; resolveGroupLabel: (key: string, fallback: string) => string;
resolvePackageLabel: (key: string, fallback: string) => string; isSuperAdmin?: boolean;
selectableSlugs?: string[] | null;
helperText?: string;
summaryText?: string;
emptyText: string; emptyText: string;
heightClassName?: string; heightClassName?: string;
}; };
@@ -33,24 +27,8 @@ type PackageSelectorProps = {
type RenderGroup = { type RenderGroup = {
key: string; key: string;
label: string; label: string;
packages: Array<{ key: string; label: string; slugs: string[] }>; profile: PermissionGroupProfile;
}; levels: Array<{ key: PermissionAccessLevel; 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,
}; };
export function AdminPermissionPackageSelector({ export function AdminPermissionPackageSelector({
@@ -58,197 +36,101 @@ export function AdminPermissionPackageSelector({
selectedSlugs, selectedSlugs,
onChange, onChange,
resolveGroupLabel, resolveGroupLabel,
resolvePackageLabel, isSuperAdmin = false,
selectableSlugs = null,
helperText,
summaryText,
emptyText, emptyText,
heightClassName = "h-[52vh]", heightClassName = "h-[52vh]",
}: PackageSelectorProps): React.ReactElement { }: PackageSelectorProps): React.ReactElement {
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]); const { t } = useTranslation("adminUsers");
const catalogSlugSet = useMemo( const catalogSlugSet = useMemo(
() => new Set((catalog?.permissions ?? []).map((permission) => permission.slug)), () => new Set((catalog?.permissions ?? []).map((permission) => permission.slug)),
[catalog], [catalog],
); );
const allowedSet = useMemo(
() => (selectableSlugs ? new Set(selectableSlugs) : null),
[selectableSlugs],
);
const groups = useMemo<RenderGroup[]>(() => { const groups = useMemo<RenderGroup[]>(() => {
const defs = catalog?.permission_menu_groups ?? []; const defs = catalog?.permission_menu_groups ?? [];
const out: RenderGroup[] = []; const out: RenderGroup[] = [];
for (const group of defs) { for (const group of defs) {
const bundles = ADMIN_PERMISSION_PACKAGES[group.key] ?? []; const profile = permissionProfileForGroup(group.key);
if (bundles.length === 0) { if (profile === undefined) {
continue; continue;
} }
const renderedPackages = bundles if (profile.platformOnly && !isSuperAdmin) {
.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) {
continue; 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({ out.push({
key: group.key, key: group.key,
label: resolveGroupLabel(group.key, group.label), label: resolveGroupLabel(group.key, group.label),
packages: renderedPackages, profile,
levels,
}); });
} }
return out; return out;
}, [allowedSet, catalog, catalogSlugSet, resolveGroupLabel, resolvePackageLabel]); }, [catalog, catalogSlugSet, isSuperAdmin, resolveGroupLabel, t]);
const bundleCount = useMemo( if (groups.length === 0) {
() => groups.reduce((sum, group) => sum + group.packages.length, 0),
[groups],
);
if (groups.length === 0 || bundleCount === 0) {
return ( return (
<div className="rounded-xl border border-dashed p-4"> <div className="rounded-lg border border-dashed p-4">
<AdminNoResourceState compact className="py-4" /> <AdminNoResourceState compact className="py-4" message={emptyText} />
</div> </div>
); );
} }
const toggleBundle = (group: RenderGroup, bundleKey: string, slugs: string[], checked: boolean) => { const setGroupLevel = (group: RenderGroup, levelKey: PermissionAccessLevel) => {
const next = new Set(selectedSet); onChange(applyProfileLevel(group.profile, levelKey, selectedSlugs));
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());
}; };
return ( return (
<div className="space-y-3"> <div className={cn("overflow-y-auto rounded-lg border bg-card", heightClassName)}>
{helperText || summaryText ? ( <ul className="divide-y divide-border/60">
<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) => { {groups.map((group) => {
const groupAllSlugs = group.packages.flatMap((p) => p.slugs); const activeLevel = resolveProfileLevel(group.profile, selectedSlugs);
const groupSelectedCount = groupAllSlugs.filter((slug) => selectedSet.has(slug)).length;
const groupChecked = groupSelectedCount === groupAllSlugs.length && groupAllSlugs.length > 0;
return ( return (
<tr key={group.key} className="hover:bg-muted/10 transition-colors"> <li
<td style={{ textAlign: "left" }} className="align-top py-4 pl-4"> key={group.key}
<label className="flex flex-col gap-2.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
style={{ display: "flex", justifyContent: "flex-start" }}
className="cursor-pointer items-center gap-2 font-medium w-full"
> >
<Checkbox <span className="min-w-[7rem] shrink-0 text-sm font-medium text-foreground">{group.label}</span>
checked={groupChecked} <div className="flex flex-wrap gap-1.5">
onCheckedChange={(value) => {group.levels.map((level) => {
toggleGroup(group, value === true) const active = activeLevel === level.key;
}
/>
<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 ( return (
<label <button
key={`${group.key}.${bundle.key}`} key={`${group.key}.${level.key}`}
type="button"
aria-pressed={active}
className={cn( 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", "rounded-md border px-2.5 py-1 text-xs transition-colors",
checked && "border-primary/40 bg-primary/5", 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 {level.label}
checked={checked} </button>
onCheckedChange={(value) =>
toggleBundle(group, bundle.key, bundle.slugs, value === true)
}
/>
<span>{bundle.label}</span>
</label>
); );
})} })}
</div> </div>
</td> </li>
</tr>
); );
})} })}
</tbody> </ul>
</table>
</div>
</div> </div>
); );
} }

View File

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

View File

@@ -11,7 +11,7 @@
"noRoles": "No roles available yet. Wait for the list to finish loading and try again.", "noRoles": "No roles available yet. Wait for the list to finish loading and try again.",
"password": "Password", "password": "Password",
"passwordOptional": "Password (optional)", "passwordOptional": "Password (optional)",
"passwordPlaceholderCreate": "At least 8 characters", "passwordPlaceholderCreate": "at least 6 characters",
"passwordPlaceholderEdit": "Leave empty to keep unchanged", "passwordPlaceholderEdit": "Leave empty to keep unchanged",
"rolesDescription": "After creation, adjust per-site role bindings in Assign Roles.", "rolesDescription": "After creation, adjust per-site role bindings in Assign Roles.",
"rolesRequired": "Roles (at least one)", "rolesRequired": "Roles (at least one)",
@@ -56,9 +56,9 @@
"listTitle": "Admin user list", "listTitle": "Admin user list",
"loadFailed": "Failed to load admin list", "loadFailed": "Failed to load admin list",
"modelGuide": "Accounts bind roles only. Maintain functional permissions in Role Management.", "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", "nicknameRequired": "Enter a nickname",
"passwordMin": "Password must be at least 8 characters", "passwordMin": "Password must be at least 6 characters",
"permissionDialog": { "permissionDialog": {
"rolePermissionCount": "Contains {{count}} functional permissions", "rolePermissionCount": "Contains {{count}} functional permissions",
"rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management. Save roles per site.", "rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management. Save roles per site.",
@@ -179,9 +179,18 @@
"roleListTitle": "Role Management", "roleListTitle": "Role Management",
"roleLoadFailed": "Failed to load role list", "roleLoadFailed": "Failed to load role list",
"rolePermissionDialog": { "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", "rolePermissionSaveFailed": "Failed to save role permissions",
"rolePermissionSaveSuccess": "Role permissions updated", "rolePermissionSaveSuccess": "Role permissions updated",
"roleRequired": "Select at least one role", "roleRequired": "Select at least one role",
@@ -190,6 +199,7 @@
"actions": "Actions", "actions": "Actions",
"name": "Role", "name": "Role",
"permissions": "Permission count", "permissions": "Permission count",
"enabledAreas": "Enabled modules",
"slug": "Role Code", "slug": "Role Code",
"status": "Status", "status": "Status",
"type": "Type", "type": "Type",

View File

@@ -56,7 +56,7 @@
"noUnboundSite": "No sites without a level-1 agent", "noUnboundSite": "No sites without a level-1 agent",
"openIntegrationSites": "Go to integration sites", "openIntegrationSites": "Go to integration sites",
"password": "Initial password", "password": "Initial password",
"passwordHint": "At least 8 characters", "passwordHint": "at least 6 characters",
"secretsOnce": "Integration secrets are shown once — save them now", "secretsOnce": "Integration secrets are shown once — save them now",
"siteCode": "Integration site", "siteCode": "Integration site",
"siteCodePlaceholder": "Select site", "siteCodePlaceholder": "Select site",
@@ -82,7 +82,8 @@
"downlineEmptyShort": "No direct downline yet.", "downlineEmptyShort": "No direct downline yet.",
"downlineEmptyTitle": "No direct downline yet", "downlineEmptyTitle": "No direct downline yet",
"editAccount": "Account & status", "editAccount": "Account & status",
"editAgent": "Edit agent", "editAgent": "Edit",
"parentMeta": "Parent {{name}}",
"editCurrent": "Edit this agent", "editCurrent": "Edit this agent",
"expand": "Expand", "expand": "Expand",
"kicker": "Credit share · Agent tree", "kicker": "Credit share · Agent tree",
@@ -112,6 +113,8 @@
"tabDownline": "Downline", "tabDownline": "Downline",
"tabOverview": "Overview", "tabOverview": "Overview",
"tabPlayers": "Players", "tabPlayers": "Players",
"tabProfileShort": "Share & credit",
"openInTree": "Open in tree",
"createDirectPlayer": "Create direct player", "createDirectPlayer": "Create direct player",
"createDownline": "Create child agent", "createDownline": "Create child agent",
"editDownline": "Edit 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.", "listFlatHint": "All operating agents in a flat list. Use row actions to add a child under a specific agent.",
"listSearch": "Search name / code / login", "listSearch": "Search name / code / login",
"viewModes": {
"label": "View",
"tree": "Tree",
"list": "List"
},
"listTitle": "Agents", "listTitle": "Agents",
"loadFailed": "Failed to load agent tree", "loadFailed": "Failed to load agent tree",
"modelGuide": "Agent layer controls data scope and delegation ceiling. Account permissions are assigned through roles.", "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.", "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.", "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", "parentAgent": "Parent",
"passwordMinLength": "Password must be at least 8 characters", "passwordMinLength": "Password must be at least 6 characters",
"passwordOptionalHint": "Leave empty to keep unchanged, or enter an 8-character password", "passwordOptionalHint": "Leave empty to keep unchanged, or enter a 6-character password",
"passwordPlaceholder": "Enter an 8-character password", "passwordPlaceholder": "Enter a 6-character password",
"passwordRequired": "Password is required", "passwordRequired": "Password is required",
"path": "Path", "path": "Path",
"playersPanel": { "playersPanel": {
@@ -157,8 +165,8 @@
"initialPassword": "Initial password", "initialPassword": "Initial password",
"loginRequired": "Enter login username and initial password", "loginRequired": "Enter login username and initial password",
"loginUsername": "Login username", "loginUsername": "Login username",
"passwordHint": "At least 8 characters", "passwordHint": "at least 6 characters",
"passwordMinLength": "Initial password must be at least 8 characters", "passwordMinLength": "Initial password must be at least 6 characters",
"playerRef": "Player ref", "playerRef": "Player ref",
"rebateInherited": "Inherit agent default rebate", "rebateInherited": "Inherit agent default rebate",
"rebateRate": "Rebate rate (%)", "rebateRate": "Rebate rate (%)",
@@ -168,7 +176,11 @@
"riskTagsPlaceholder": "Comma-separated", "riskTagsPlaceholder": "Comma-separated",
"scopedTo": "Direct players: {{agent}}", "scopedTo": "Direct players: {{agent}}",
"siteCode": "Line site", "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": { "profile": {
"availableCredit": "Available to grant {{amount}}", "availableCredit": "Available to grant {{amount}}",

View File

@@ -1,19 +1,36 @@
{ {
"title": "Audit Logs", "title": "Activity Log",
"moduleCode": "Module code", "filterModule": "Business area",
"actionCode": "Action code", "filterModuleAll": "All areas",
"operatorType": "Operator type", "filterOperatorType": "Actor type",
"operatorIdPlaceholder": "Enter operator ID", "filterOperatorTypeAll": "All",
"exactMatch": "Exact match", "operatorIdPlaceholder": "Optional operator ID",
"operatorTypePlaceholder": "For example admin / system", "operator": "Actor",
"operator": "Operator", "summary": "What happened",
"module": "Module", "target": "Related to",
"action": "Action",
"target": "Target",
"time": "Time", "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": { "operatorTypes": {
"admin": "Admin", "admin": "Admin user",
"player": "Player", "player": "Player",
"system": "System" "system": "System"
} }

View File

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

View File

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

View File

@@ -133,7 +133,7 @@
}, },
"form": { "form": {
"adminNicknameRequired": "Site admin nickname is required", "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", "adminUsernameRequired": "Site admin username is required",
"codeRequired": "site_code is required", "codeRequired": "site_code is required",
"required": "Site name is required" "required": "Site name is required"
@@ -147,7 +147,7 @@
"placeholders": { "placeholders": {
"adminEmail": "Enter email", "adminEmail": "Enter email",
"adminNickname": "Enter account nickname", "adminNickname": "Enter account nickname",
"adminPassword": "At least 8 characters", "adminPassword": "at least 6 characters",
"adminUsername": "Enter admin username", "adminUsername": "Enter admin username",
"code": "Enter site identifier, for example partner-a", "code": "Enter site identifier, for example partner-a",
"connectivityPlayerId": "Enter player ID, for example 10001", "connectivityPlayerId": "Enter player ID, for example 10001",
@@ -527,10 +527,12 @@
} }
}, },
"system": { "system": {
"pageTitle": "System settings",
"confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.", "confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.",
"confirmSaveCurrencyFormatTitle": "Save currency display format?", "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.", "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?", "confirmSaveDrawTitle": "Save draw parameters?",
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed.", "confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed.",
"confirmSaveFrontendTitle": "Save front-end display settings?", "confirmSaveFrontendTitle": "Save front-end display settings?",
@@ -591,12 +593,33 @@
"saveSettlementSuccess": "Settlement automation saved", "saveSettlementSuccess": "Settlement automation saved",
"saveSuccess": "System settings saved", "saveSuccess": "System settings saved",
"sections": { "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", "currencyFormat": "Currency display format",
"currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).", "currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).",
"draw": "Draw schedule and review", "draw": "Draw review",
"drawDescription": "Controls draw timing, close window, manual review, and cooldown. Only changed fields in this block are submitted.", "drawDescription": "Manual review after RNG and post-publish cooldown.",
"settlement": "Settlement automation", "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": { "states": {
"disabled": "Disabled", "disabled": "Disabled",
@@ -630,19 +653,17 @@
"count": "{{count}} items", "count": "{{count}} items",
"current": "Current", "current": "Current",
"delete": "Delete", "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?", "deleteConfirmTitle": "Delete this version?",
"effectiveAt": "Effective at: {{value}}", "effectiveAt": "Effective at: {{value}}",
"empty": "No version records yet.", "empty": "No version records yet.",
"loading": "Loading…", "loading": "Loading…",
"moreActions": "More actions for v{{version}}", "moreActions": "More actions for v{{version}}",
"noneSelected": "No version selected", "noneSelected": "Select version",
"note": "Note: {{value}}", "note": "Note: {{value}}",
"rollback": "Rollback", "rollback": "Rollback",
"selected": "Selected", "selected": "Selected",
"sheetDescription": "Choose a version to view on this page. Drafts are editable, while active and archived versions are read-only.", "sheetTitle": "Versions",
"sheetTitle": "Switch configuration version",
"switch": "Switch version",
"view": "View" "view": "View"
}, },
"versionToolbar": { "versionToolbar": {

View File

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

View File

@@ -1,7 +1,10 @@
{ {
"title": "Reconcile", "title": "Reconcile",
"createTitle": "Run reconcile scan", "workflowHint": "Run dated reconcile scans here. For live unresolved exceptions, use the link above (opens Wallet → Transfer orders with abnormal-only filter).",
"createHint": "Scans transfer orders in the selected period, compares lottery wallet ledgers, and checks main-site idempotent records when the wallet API is configured.", "shortcutAbnormalTransfers": "Live abnormal transfers",
"viewOnlyHint": "View-only: historical reconcile jobs.",
"createTitle": "Run scan",
"createHint": "",
"reconcileType": "Reconcile type", "reconcileType": "Reconcile type",
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)", "reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
"dateRange": "Reconcile date range", "dateRange": "Reconcile date range",
@@ -12,13 +15,18 @@
"periodRequired": "Enter both reconcile start and end dates", "periodRequired": "Enter both reconcile start and end dates",
"periodOrderInvalid": "End time must be later than or equal to start time", "periodOrderInvalid": "End time must be later than or equal to start time",
"confirmCreateTitle": "Start reconcile scan?", "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", "confirmCreatePlayer": " for the selected player",
"confirmCreateAllPlayers": " for all players", "confirmCreateAllPlayers": " for all players",
"createSuccess": "Scan finished: {{count}} issue(s) found", "createSuccess": "Scan finished: {{count}} issue(s) found",
"createSuccessEmpty": "Scan finished: no issues found", "createSuccessEmpty": "Scan finished: no issues found",
"createFailed": "Scan failed", "createFailed": "Scan failed",
"noCreatePermission": "Current account cannot start reconcile scans.", "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", "jobsTitle": "Reconcile jobs",
"refresh": "Refresh", "refresh": "Refresh",
"jobNo": "Job no.", "jobNo": "Job no.",
@@ -31,13 +39,22 @@
"finishedAt": "Finished at", "finishedAt": "Finished at",
"createdAt": "Created at", "createdAt": "Created at",
"operate": "Action", "operate": "Action",
"viewDetails": "View discrepancy details", "viewDetails": "View details",
"hideDetails": "Hide",
"closeDetails": "Close",
"detailsTitle": "Discrepancy details", "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.", "transferNo": "Transfer no.",
"walletTxnNo": "Lottery wallet txn", "walletTxnNo": "Lottery wallet txn",
"mainSiteRef": "Main-site ref", "mainSiteRef": "Main-site ref",
"mainSiteCheck": "Main-site check", "mainSiteCheck": "Main-site check",
"differenceAmount": "Difference (minor)", "differenceAmount": "Difference",
"itemResult": "Check result", "itemResult": "Check result",
"processingStatus": "Processing status", "processingStatus": "Processing status",
"actions": "Actions", "actions": "Actions",

View File

@@ -1,6 +1,12 @@
{ {
"title": "Reports", "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.", "pageGuide": "P&L and risk by draw, player, and play type; export permission enables async jobs.",
"exportPanel": "Export setup", "exportPanel": "Export setup",
"chooseReport": "Choose a report to export", "chooseReport": "Choose a report to export",
@@ -86,8 +92,12 @@
}, },
"preview": { "preview": {
"title": "Preview", "title": "Preview",
"subtitle": "Results appear below. Export as CSV or Excel.", "subtitle": "",
"empty": "No data. Adjust filters and try again.", "empty": "No data",
"sections": {
"settlementBatches": "Settlement batches",
"lockLogs": "Lock logs"
},
"exportableRows": "rows exportable", "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.", "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": { "scope": {

View File

@@ -38,6 +38,7 @@
"unsettledTickets": "Unsettled tickets", "unsettledTickets": "Unsettled tickets",
"openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.", "openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.",
"viewDetail": "View details", "viewDetail": "View details",
"processBills": "Process bills",
"close": "Close", "close": "Close",
"closeNow": "Close now", "closeNow": "Close now",
"hasOpen": "Period {{range}} is open. Close it before opening a new one.", "hasOpen": "Period {{range}} is open. Close it before opening a new one.",
@@ -66,6 +67,7 @@
"periods": "Periods", "periods": "Periods",
"bills": "Bills", "bills": "Bills",
"operations": "Payments & adjustments", "operations": "Payments & adjustments",
"aria": "Period views",
"ledger": "Account ledger", "ledger": "Account ledger",
"creditLedger": "Credit ledger", "creditLedger": "Credit ledger",
"playerBills": "Player bills", "playerBills": "Player bills",
@@ -213,7 +215,8 @@
"actions": { "actions": {
"detail": "Detail", "detail": "Detail",
"viewBill": "View bill", "viewBill": "View bill",
"billDetail": "Bill detail" "billDetail": "Bill detail",
"billDetailWithId": "Bill #{{id}}"
}, },
"billDisplay": { "billDisplay": {
"settlementFlow": "Who pays whom", "settlementFlow": "Who pays whom",
@@ -250,6 +253,7 @@
"unpaidAwaitingPayment": "Record offline payment", "unpaidAwaitingPayment": "Record offline payment",
"fullySettled": "Fully settled this period", "fullySettled": "Fully settled this period",
"confirmHint": "Confirm the bill before recording payment.", "confirmHint": "Confirm the bill before recording payment.",
"advancedActions": "Adjust / bad debt",
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})", "recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})", "recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
"rebateAllocationsHint": "How rebate is allocated across agent tiers.", "rebateAllocationsHint": "How rebate is allocated across agent tiers.",
@@ -282,6 +286,12 @@
}, },
"billsPanel": { "billsPanel": {
"intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.", "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.", "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": { "quickFilter": {
"title": "Which settlement layer do you want to review", "title": "Which settlement layer do you want to review",

View File

@@ -3,9 +3,7 @@
"subnavLabel": "Wallet sub pages", "subnavLabel": "Wallet sub pages",
"subnavTransactions": "Main-site wallet txns", "subnavTransactions": "Main-site wallet txns",
"subnavTransferOrders": "Main-site transfers", "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", "ledgerChannel": "Ledger",
"ledgerCredit": "Credit ledger", "ledgerCredit": "Credit ledger",
"ledgerWallet": "Wallet txn", "ledgerWallet": "Wallet txn",
@@ -43,6 +41,9 @@
"options": "Options", "options": "Options",
"abnormalOnly": "Abnormal only", "abnormalOnly": "Abnormal only",
"abnormalOnlyPending": "Abnormal only (pending reconcile)", "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", "search": "Search",
"resetFilters": "Reset filters", "resetFilters": "Reset filters",
"refreshCurrentPage": "Refresh current page", "refreshCurrentPage": "Refresh current page",

View File

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

View File

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

View File

@@ -1,17 +1,34 @@
{ {
"title": "अडिट लग", "title": "सञ्चालन रेकर्ड",
"moduleCode": "मोड्युल कोड", "filterModule": "व्यवसाय प्रकार",
"actionCode": "कार्य कोड", "filterModuleAll": "सबै",
"operatorType": "अपरेटर प्रकार", "filterOperatorType": "अपरेटर प्रकार",
"operatorIdPlaceholder": "अपरेटर ID प्रविष्ट गर्नुहोस्", "filterOperatorTypeAll": "सबै",
"exactMatch": "ठ्याक्कै मिलान", "operatorIdPlaceholder": "अपरेटर ID थाहा भए मात्र",
"operatorTypePlaceholder": "जस्तै admin / system",
"operator": "अपरेटर", "operator": "अपरेटर",
"module": "मोड्युल", "summary": "के भयो",
"action": "कार्य", "target": "सम्बन्धित",
"target": "लक्ष्य",
"time": "समय", "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": { "operatorTypes": {
"admin": "प्रशासक", "admin": "प्रशासक",
"player": "खेलाडी", "player": "खेलाडी",

View File

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

View File

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

View File

@@ -518,6 +518,7 @@
} }
}, },
"system": { "system": {
"pageTitle": "प्रणाली सेटिङ",
"confirmSaveCurrencyFormatDescription": "यसले दशमलव स्थान र विभाजक अद्यावधिक गर्छ।", "confirmSaveCurrencyFormatDescription": "यसले दशमलव स्थान र विभाजक अद्यावधिक गर्छ।",
"confirmSaveCurrencyFormatTitle": "मुद्रा प्रदर्शन ढाँचा बचत गर्ने?", "confirmSaveCurrencyFormatTitle": "मुद्रा प्रदर्शन ढाँचा बचत गर्ने?",
"confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।", "confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।",
@@ -582,12 +583,33 @@
"saveSettlementSuccess": "सेटलमेन्ट स्वचालन बचत भयो", "saveSettlementSuccess": "सेटलमेन्ट स्वचालन बचत भयो",
"saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो", "saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो",
"sections": { "sections": {
"deployment": "डिप्लोयमेन्ट (पढ्न मात्र)",
"deploymentDescription": "पूर्वनिर्धारित मुद्रा र ड्र तालिका सर्भर .env मा सेट हुन्छ; यहाँ प्रभावी मान देखिन्छ।",
"currencyFormat": "मुद्रा प्रदर्शन ढाँचा", "currencyFormat": "मुद्रा प्रदर्शन ढाँचा",
"currencyFormatDescription": "साइटभरि रकमका दशमलव र विभाजक (मुद्रा मास्टर डाटाबाट अलग)।", "currencyFormatDescription": "साइटभरि रकमका दशमलव र विभाजक (मुद्रा मास्टर डाटाबाट अलग)।",
"draw": "ड्र तालिका र समीक्षा", "draw": "ड्र समीक्षा",
"drawDescription": "ड्र समय, बन्द सञ्झ्याल, म्यानुअल समीक्षा र कूलडाउन नियन्त्रण। यो ब्लकमा परिवर्तित फिल्ड मात्र पेश हुन्छ।", "drawDescription": "RNG पछि म्यानुअल समीक्षा र प्रकाशनपछि कूलडाउन।",
"settlement": "सेटलमेन्ट स्वचालन", "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": { "states": {
"disabled": "बन्द", "disabled": "बन्द",

View File

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

View File

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

View File

@@ -1,20 +1,37 @@
{ {
"title": "审计日志", "title": "操作记录",
"moduleCode": "模块", "filterModule": "业务类型",
"actionCode": "动作", "filterModuleAll": "全部业务",
"operatorType": "操作类型", "filterOperatorType": "操作类型",
"operatorIdPlaceholder": "请输入操作人 ID", "filterOperatorTypeAll": "全部",
"exactMatch": "请输入完整名称", "operatorIdPlaceholder": "知道操作人编号时可填写",
"operatorTypePlaceholder": "如管理员、系统", "operator": "操作人",
"operator": "操作", "summary": "操作说明",
"module": "模块", "target": "涉及对象",
"action": "动作",
"target": "目标",
"time": "时间", "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": { "operatorTypes": {
"admin": "管理员", "admin": "后台管理员",
"player": "玩家", "player": "玩家",
"system": "系统" "system": "系统自动"
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
/** 后台/彩票端登录账号:字母、数字、点、下划线、连字符。 */ /** 后台/彩票端登录账号:字母、数字、点、下划线、连字符。 */
export const ADMIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/; 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 AccountValidationIssue = "empty" | "invalid_charset";
export type PasswordValidationIssue = "empty" | "too_short"; export type PasswordValidationIssue = "empty" | "too_short";

View File

@@ -23,16 +23,19 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
settings: "settings", settings: "settings",
integration: "integration", integration: "integration",
agents: "agents", agents: "agents",
agent_list: "agent_list",
config: "config", config: "config",
}; };
const LEGACY_NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
agent_list: "agents",
};
export function adminNavLabel( export function adminNavLabel(
segment: string, segment: string,
t: TFunction, t: TFunction,
apiLabel?: string | null, apiLabel?: string | null,
): string { ): string {
const key = NAV_SEGMENT_I18N_KEYS[segment]; const key = NAV_SEGMENT_I18N_KEYS[segment] ?? LEGACY_NAV_SEGMENT_I18N_KEYS[segment];
if (key) { if (key) {
return t(`nav.${key}`, { ns: "common" }); 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/settlement-batches": { ns: "settlement", key: "batchList" },
"/admin/reconcile": { ns: "reconcile", key: "title" }, "/admin/reconcile": { ns: "reconcile", key: "title" },
"/admin/reports": { ns: "reports", 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/audit-logs": { ns: "audit", key: "title" },
"/admin/admin-users": { ns: "adminUsers", key: "title" }, "/admin/admin-users": { ns: "adminUsers", key: "title" },
"/admin/admin-roles": { ns: "adminRoles", key: "title" }, "/admin/admin-roles": { ns: "adminRoles", key: "title" },
"/admin/agents": { ns: "agents", 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/provision": { ns: "agents", key: "subnav.provision" },
"/admin/agents/sites": { ns: "config", key: "integrationSites.title" }, "/admin/agents/sites": { ns: "config", key: "integrationSites.title" },
"/admin/settlement-center": { ns: "settlementCenter", key: "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, PRD_WALLET_ADJUST_MANAGE,
] as const; ] 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 = [ export const PRD_WALLET_PLAYER_ACCESS_ANY = [
PRD_USERS_VIEW_FINANCE, PRD_USERS_VIEW_FINANCE,

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { putAdminUser } from "@/api/admin-users"; import { putAdminUser } from "@/api/admin-users";
import { validateAdminPassword } from "@/lib/admin-input-validation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -62,6 +63,10 @@ export function AccountSettingsConsole() {
toast.error(t("validation.passwordMismatch")); toast.error(t("validation.passwordMismatch"));
return; return;
} }
if (validateAdminPassword(password) === "too_short") {
toast.error(t("validation.passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }));
return;
}
if (!adminProfile) { if (!adminProfile) {
toast.error(t("actions.updateFailed")); toast.error(t("actions.updateFailed"));
return; 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 { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector"; import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
import { permissionProfileForGroup, resolveProfileLevel } from "@/lib/admin-permission-profiles";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone"; import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; 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; return translated === `permissionGroups.${key}` ? fallback : translated;
} }
function permissionPackageLabel(key: string, fallback: string, t: (key: string, options?: { defaultValue?: string }) => string): string { function countEnabledAreas(
return t(`permissionLevels.${key}`, { defaultValue: fallback }); 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 { export function AdminRolesConsole(): React.ReactElement {
@@ -67,6 +86,7 @@ export function AdminRolesConsole(): React.ReactElement {
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]); const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
const isSuperAdmin = profile?.is_super_admin === true;
const exportLabels = useExportLabels("adminRoles"); const exportLabels = useExportLabels("adminRoles");
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null); const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
const [roles, setRoles] = useState<AdminRoleRow[]>([]); const [roles, setRoles] = useState<AdminRoleRow[]>([]);
@@ -270,11 +290,7 @@ export function AdminRolesConsole(): React.ReactElement {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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} {err ? <p className="text-sm text-destructive">{err}</p> : null}
<div className="rounded-md border"> <div className="rounded-md border">
<Table id="admin-roles-table"> <Table id="admin-roles-table">
@@ -282,19 +298,19 @@ export function AdminRolesConsole(): React.ReactElement {
<TableRow> <TableRow>
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead> <TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("roleTable.name")}</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.type")}</TableHead>
<TableHead>{t("roleTable.status")}</TableHead> <TableHead>{t("roleTable.status")}</TableHead>
<TableHead>{t("roleTable.users")}</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> <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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading && roles.length === 0 ? ( {loading && roles.length === 0 ? (
<AdminTableLoadingRow colSpan={8} /> <AdminTableLoadingRow colSpan={isSuperAdmin ? 8 : 7} />
) : roles.length === 0 ? ( ) : roles.length === 0 ? (
<AdminTableNoResourceRow colSpan={8} className="text-muted-foreground" /> <AdminTableNoResourceRow colSpan={isSuperAdmin ? 8 : 7} className="text-muted-foreground" />
) : ( ) : (
roles.map((role) => { roles.map((role) => {
const fixedRole = isPlatformFixedRole(role); const fixedRole = isPlatformFixedRole(role);
@@ -306,7 +322,7 @@ export function AdminRolesConsole(): React.ReactElement {
<TableCell> <TableCell>
<span className="font-medium">{role.name}</span> <span className="font-medium">{role.name}</span>
</TableCell> </TableCell>
<TableCell>{role.slug}</TableCell> {isSuperAdmin ? <TableCell className="font-mono text-xs text-muted-foreground">{role.slug}</TableCell> : null}
<TableCell> <TableCell>
{role.is_system ? ( {role.is_system ? (
<Badge variant="secondary">{t("roleType.system")}</Badge> <Badge variant="secondary">{t("roleType.system")}</Badge>
@@ -320,7 +336,9 @@ export function AdminRolesConsole(): React.ReactElement {
</AdminStatusBadge> </AdminStatusBadge>
</TableCell> </TableCell>
<TableCell className="tabular-nums">{role.user_count}</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)]"> <TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageRoles ? ( {canManageRoles ? (
<AdminRowActionsMenu <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"> <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"> <DialogTitle className="text-[15px] font-semibold tracking-tight text-foreground">
{t("rolePermissionDialog.title")} {selectedRole
? t("rolePermissionDialog.titleWithName", { name: selectedRole.name })
: t("rolePermissionDialog.title")}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{selectedRole ? selectedRole.name : null}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
<AdminPermissionPackageSelector <AdminPermissionPackageSelector
@@ -382,11 +399,7 @@ export function AdminRolesConsole(): React.ReactElement {
selectedSlugs={draftRolePermissions} selectedSlugs={draftRolePermissions}
onChange={setDraftRolePermissions} onChange={setDraftRolePermissions}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)} resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)} isSuperAdmin={isSuperAdmin}
helperText={t("rolePermissionDialog.packageHint", {
defaultValue:
"勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。",
})}
emptyText={t("states.noData", { ns: "common" })} emptyText={t("states.noData", { ns: "common" })}
heightClassName="h-[min(56vh,520px)]" heightClassName="h-[min(56vh,520px)]"
/> />
@@ -404,7 +417,6 @@ export function AdminRolesConsole(): React.ReactElement {
title: t("confirmSaveRolePermissionsTitle"), title: t("confirmSaveRolePermissionsTitle"),
description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }), description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }), confirmLabel: t("confirm.confirmSave", { ns: "common" }),
confirmVariant: "destructive",
onConfirm: () => saveRolePermissions(), onConfirm: () => saveRolePermissions(),
}) })
} }
@@ -421,7 +433,6 @@ export function AdminRolesConsole(): React.ReactElement {
<DialogTitle> <DialogTitle>
{editingRoleId === null ? t("roleDialog.createTitle") : t("roleDialog.editTitle")} {editingRoleId === null ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
</DialogTitle> </DialogTitle>
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1.5"> <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 { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { validateAdminPassword } from "@/lib/admin-input-validation";
import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone"; import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -266,7 +267,7 @@ export function AdminUsersConsole(): React.ReactElement {
toast.error(t("nicknameRequired")); toast.error(t("nicknameRequired"));
return; return;
} }
if (accountMode === "edit" && formPassword !== "" && formPassword.length < 8) { if (accountMode === "edit" && formPassword !== "" && validateAdminPassword(formPassword) === "too_short") {
toast.error(t("newPasswordMin")); toast.error(t("newPasswordMin"));
return; return;
} }
@@ -287,7 +288,7 @@ export function AdminUsersConsole(): React.ReactElement {
toast.error(t("usernameRequired")); toast.error(t("usernameRequired"));
return; return;
} }
if (formPassword.length < 8) { if (validateAdminPassword(formPassword) === "too_short") {
toast.error(t("passwordMin")); toast.error(t("passwordMin"));
return; return;
} }
@@ -404,12 +405,6 @@ export function AdminUsersConsole(): React.ReactElement {
</Button> </Button>
) : null} ) : null}
</div> </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-toolbar">
<div className="admin-list-field xl:min-w-0"> <div className="admin-list-field xl:min-w-0">
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0"> <Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">

View File

@@ -16,136 +16,101 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel"; import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields"; import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields";
import { formatCredit } from "@/modules/agents/agent-line-sidebar"; import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { percentValueToUi } from "@/lib/admin-rate-percent"; import { percentValueToUi } from "@/lib/admin-rate-percent";
import { isLineRootAgentNode } from "@/lib/agent-profile-caps";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone"; 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 { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { cn } from "@/lib/utils";
import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent"; import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null { export type AgentDetailTab = "profile" | "downline" | "players";
if (
totalShareRate == null ||
parentShareRate == null ||
parentShareRate <= 0
) {
return null;
}
return percentValueToUi((totalShareRate / parentShareRate) * 100);
}
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
export type AgentLineDetailPanelProps = { export type AgentLineDetailPanelProps = {
node: AgentNodeRow | null; node: AgentNodeRow | null;
profile: AgentProfileRow | null; profile: AgentProfileRow | null;
profileLoading: boolean; profileLoading: boolean;
profileError?: string | null;
childAgents: AgentNodeRow[]; childAgents: AgentNodeRow[];
childCountById: Map<number, number>; childCountById: Map<number, number>;
siteCode: string; siteCode: string;
siteLabel: string | null;
parentName: string | null; parentName: string | null;
detailTab: AgentDetailTab; detailTab: AgentDetailTab;
onDetailTabChange: (tab: AgentDetailTab) => void; onDetailTabChange: (tab: AgentDetailTab) => void;
canViewProfileTab: boolean; canViewProfileTab: boolean;
canEditProfileTab: boolean; canEditProfileTab: boolean;
profileReadOnly: boolean; canSaveProfileTab: boolean;
canViewDownlineTab: boolean; canViewDownlineTab: boolean;
canViewPlayersTab: boolean; canViewPlayersTab: boolean;
playersTabHint?: string | null;
canManageNode: boolean; canManageNode: boolean;
canCreateChild: boolean; canCreateChild: boolean;
canCreateChildAgent: boolean;
canCreatePlayerAction: boolean; canCreatePlayerAction: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean; canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void; onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void; onDeleteChild: (node: AgentNodeRow) => void;
onAddChild: () => void; onAddChild: () => void;
onAddPlayer: () => void;
onEditCurrent: () => void; onEditCurrent: () => void;
onSelectChild: (node: AgentNodeRow) => void; onSelectChild: (node: AgentNodeRow) => void;
profileFields: AgentProfileFieldsProps | null; profileFields: AgentProfileFieldsProps | null;
profileSaving: boolean; profileSaving: boolean;
onSaveProfile: () => void; onSaveProfile: () => void;
playerCreateRequestKey?: number;
}; };
export function AgentLineDetailPanel({ export function AgentLineDetailPanel({
node, node,
profile, profile,
profileLoading, profileLoading,
profileError = null,
childAgents, childAgents,
childCountById, childCountById,
siteCode, siteCode,
siteLabel,
parentName, parentName,
detailTab, detailTab,
onDetailTabChange, onDetailTabChange,
canViewProfileTab, canViewProfileTab,
canEditProfileTab, canEditProfileTab,
profileReadOnly, canSaveProfileTab,
canViewDownlineTab, canViewDownlineTab,
canViewPlayersTab, canViewPlayersTab,
playersTabHint,
canManageNode, canManageNode,
canCreateChild, canCreateChild,
canCreateChildAgent,
canCreatePlayerAction, canCreatePlayerAction,
canDeleteChild, canDeleteChild,
onEditChild, onEditChild,
onDeleteChild, onDeleteChild,
onAddChild, onAddChild,
onAddPlayer,
onEditCurrent, onEditCurrent,
onSelectChild, onSelectChild,
profileFields, profileFields,
profileSaving, profileSaving,
onSaveProfile, onSaveProfile,
playerCreateRequestKey = 0,
}: AgentLineDetailPanelProps): React.ReactElement { }: AgentLineDetailPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]); const { t } = useTranslation(["agents", "common"]);
if (node === null) { if (node === null) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/20 px-6 py-20 text-center"> <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 bg-background"> <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 /> <Network className="size-6 text-muted-foreground/70" aria-hidden />
</div> </div>
<p className="mt-4 text-sm font-medium text-foreground"> <p className="mt-4 text-sm font-medium text-foreground">
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理查看占成与授信" })} {t("lineUi.selectAgent", { defaultValue: "选择左侧代理" })}
</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{t("lineUi.selectAgentHint", {
defaultValue: "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
})}
</p> </p>
</div> </div>
); );
} }
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [ const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
{
key: "overview",
label: t("lineUi.tabOverview", { defaultValue: "概览" }),
visible: true,
},
{ {
key: "profile", key: "profile",
label: profileReadOnly label: t("lineUi.tabProfileShort", { defaultValue: "占成授信" }),
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
: t("lineUi.tabProfile", { defaultValue: "占成与授信" }),
visible: canViewProfileTab, visible: canViewProfileTab,
}, },
{ {
key: "downline", key: "downline",
label: t("lineUi.tabDownline", { defaultValue: "直属下级" }), label: t("lineUi.tabDownline", { defaultValue: "直属下级" }),
count: childAgents.length,
visible: canViewDownlineTab, visible: canViewDownlineTab,
}, },
{ {
@@ -155,87 +120,44 @@ export function AgentLineDetailPanel({
}, },
]; ];
const siteDisplay = const metaParts = [
siteLabel && siteCode.trim() !== "" parentName
? `${siteLabel} (${siteCode})` ? t("lineUi.parentMeta", { defaultValue: "上级 {{name}}", name: parentName })
: siteLabel ?? siteCode; : null,
const childActionHint = canCreateChild node.username?.trim() ? node.username : node.code,
? null ].filter(Boolean);
: 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;
return ( return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"> <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="shrink-0 bg-card shadow-[0_1px_0_rgb(216_230_251_/_35%)]"> <div className="shrink-0 border-b border-border/60">
<header className="border-b border-border/60 px-5 py-4 sm:px-6"> <header className="px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground"> <h2 className="truncate text-lg font-semibold tracking-tight">{node.name}</h2>
{node.name}
</h2>
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0"> <AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
{node.status === 1 {node.status === 1
? t("common:status.enabled", { defaultValue: "启用" }) ? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })} : t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge> </AdminStatusBadge>
</div> </div>
{siteDisplay ? ( {metaParts.length > 0 ? (
<p className="mt-1 truncate text-sm text-muted-foreground" title={siteDisplay}> <p className="truncate text-sm text-muted-foreground">{metaParts.join(" · ")}</p>
{siteDisplay}
</p>
) : null} ) : null}
<AgentHeaderMetrics profile={profile} loading={profileLoading} />
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-2">
{canManageNode ? ( {canManageNode ? (
<>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}> <Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
<Pencil className="mr-1.5 size-3.5" /> <Pencil className="mr-1.5 size-3.5" />
{t("lineUi.editAgent", { defaultValue: "编辑代理" })} {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> </Button>
) : null} ) : null}
</div> </div>
{primaryActionHint ? (
<p className="max-w-[26rem] text-right text-xs leading-5 text-muted-foreground">
{primaryActionHint}
</p>
) : null}
</>
) : null}
</div>
</div>
</header> </header>
<AdminSubnav <AdminSubnav
aria-label={t("detailTabs", { defaultValue: "代理详情" })} aria-label={t("detailTabs", { defaultValue: "代理详情" })}
className="min-h-11 overflow-x-auto border-b border-border/60 px-4 sm:px-5" className="min-h-11 overflow-x-auto px-4 sm:px-5"
> >
{tabs {tabs
.filter((tab) => tab.visible) .filter((tab) => tab.visible)
@@ -252,68 +174,40 @@ export function AgentLineDetailPanel({
</AdminSubnav> </AdminSubnav>
</div> </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"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6 sm:py-6">
{profileLoading && detailTab !== "overview" ? ( {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" /> <AdminLoadingInline className="py-16" />
) : null} ) : null}
{detailTab === "overview" ? (
<OverviewTab
profile={profile}
profileLoading={profileLoading}
profileReadOnly={profileReadOnly}
/>
) : null}
{detailTab === "profile" && canViewProfileTab && profileFields && !profileLoading ? ( {detailTab === "profile" && canViewProfileTab && profileFields && !profileLoading ? (
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm"> <div className="mx-auto max-w-3xl space-y-5">
<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" /> <AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
{canManageNode && canEditProfileTab ? ( {canSaveProfileTab ? (
<div className="mt-6 flex justify-end border-t border-border/60 pt-5"> <div className="flex justify-end">
<Button <Button
type="button" type="button"
className="min-w-[10rem]"
disabled={profileSaving || profileFields.loading} disabled={profileSaving || profileFields.loading}
onClick={onSaveProfile} onClick={onSaveProfile}
> >
{profileSaving {profileSaving
? t("common:actions.saving", { defaultValue: "保存中…" }) ? t("common:actions.saving", { defaultValue: "保存中…" })
: t("lineUi.saveProfile", { defaultValue: "保存占成与授信" })} : t("lineUi.saveProfile", { defaultValue: "保存" })}
</Button> </Button>
</div> </div>
) : null} ) : null}
</CardContent> </div>
</Card>
) : null} ) : null}
{detailTab === "downline" && canViewDownlineTab && !profileLoading ? ( {detailTab === "downline" && canViewDownlineTab ? (
<DownlineTable <DownlineSection
childAgents={childAgents} childAgents={childAgents}
childCountById={childCountById} childCountById={childCountById}
parentTotalShareRate={profile?.total_share_rate}
canManageNode={canManageNode} canManageNode={canManageNode}
canCreateChild={canCreateChild} canCreateChild={canCreateChild}
canDeleteChild={canDeleteChild} canDeleteChild={canDeleteChild}
@@ -324,13 +218,12 @@ export function AgentLineDetailPanel({
/> />
) : null} ) : null}
{detailTab === "players" && canViewPlayersTab && !profileLoading ? ( {detailTab === "players" && canViewPlayersTab ? (
<AgentsPlayersPanel <AgentsPlayersPanel
siteCode={siteCode} siteCode={siteCode}
agentNodeId={node.id} agentNodeId={node.id}
allowCreatePlayer={canCreatePlayerAction} allowCreatePlayer={canCreatePlayerAction}
embedded embedded
createRequestKey={playerCreateRequestKey}
/> />
) : null} ) : null}
</div> </div>
@@ -338,150 +231,36 @@ export function AgentLineDetailPanel({
); );
} }
function OverviewTab({ function AgentHeaderMetrics({
profile, profile,
profileLoading, loading,
profileReadOnly,
}: { }: {
profile: AgentProfileRow | null; profile: AgentProfileRow | null;
profileLoading: boolean; loading: boolean;
profileReadOnly: boolean;
}): React.ReactElement { }): React.ReactElement {
const { t } = useTranslation(["agents", "common"]); const { t } = useTranslation("agents");
const rebateCap = if (loading) {
profile && !profileLoading ? percentValueToUi(profile.rebate_limit ?? 0) : null; return <p className="text-sm text-muted-foreground"></p>;
const parentRelativeShare = relativeShareRate( }
profile?.total_share_rate,
profile?.parent_caps?.total_share_rate,
);
const yesLabel = t("common:states.yes", { defaultValue: "是" }); const items = [
const noLabel = t("common:states.no", { defaultValue: "" }); `${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 ( return (
<div className="mx-auto max-w-5xl space-y-6"> <p className="text-sm text-muted-foreground">
{profileReadOnly ? ( {items.join(" · ")}
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
{t("lineUi.selfAgentOverviewHint", {
defaultValue:
"以下为上级为您分配的占成与授信;如需调整请联系上级代理或平台。",
})}
</p> </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>
); );
} }
function CapabilityMetric({ function DownlineSection({
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({
childAgents, childAgents,
childCountById, childCountById,
parentTotalShareRate,
canManageNode, canManageNode,
canCreateChild, canCreateChild,
canDeleteChild, canDeleteChild,
@@ -492,7 +271,6 @@ function DownlineTable({
}: { }: {
childAgents: AgentNodeRow[]; childAgents: AgentNodeRow[];
childCountById: Map<number, number>; childCountById: Map<number, number>;
parentTotalShareRate?: number;
canManageNode: boolean; canManageNode: boolean;
canCreateChild: boolean; canCreateChild: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean; canDeleteChild: (node: AgentNodeRow) => boolean;
@@ -502,45 +280,92 @@ function DownlineTable({
onAddChild: () => void; onAddChild: () => void;
}): React.ReactElement { }): React.ReactElement {
const { t } = useTranslation(["agents", "common"]); const { t } = useTranslation(["agents", "common"]);
const createChildLabel = t("lineUi.createDownline", { defaultValue: "创建下级代理" });
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" }); if (childAgents.length === 0) {
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" }); 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 ( return (
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm"> <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 DownlineTable({
childAgents,
childCountById,
canManageNode,
canDeleteChild,
onEditChild,
onDeleteChild,
onSelectChild,
}: {
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="admin-table-inset overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/40 hover:bg-muted/40"> <TableRow className="hover:bg-transparent">
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead> <TableHead>{t("agentName", { defaultValue: "名称" })}</TableHead>
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead> <TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
<TableHead>{t("lineUi.downlineColumns.email", { defaultValue: "邮箱" })}</TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("profile.totalShareRate", { defaultValue: "占成 (%)" })} {t("profile.totalShareRate", { defaultValue: "占成" })}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("profile.creditLimit", { defaultValue: "授信额度" })} {t("profile.creditLimit", { defaultValue: "授信" })}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("lineUi.allocatedCredit", { defaultValue: "下发" })} {t("lineUi.availableCredit", { defaultValue: "下发" })}
</TableHead> </TableHead>
<TableHead className="text-center whitespace-nowrap"> <TableHead className="text-center whitespace-nowrap">
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级" })} {t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级" })}
</TableHead> </TableHead>
<TableHead className="w-24">{t("common:status.label", { defaultValue: "状态" })}</TableHead> <TableHead>{t("common:status.label", { defaultValue: "状态" })}</TableHead>
{canManageNode ? ( {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)]"> <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: "操作" })} {t("common:table.actions", { defaultValue: "操作" })}
</TableHead> </TableHead>
) : null} ) : null}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{childAgents.length === 0 ? ( {childAgents.map((child) => {
<AdminTableNoResourceRow colSpan={canManageNode ? 10 : 9} cellClassName="py-12 text-center" />
) : (
childAgents.map((child) => {
const summary = child.profile_summary; const summary = child.profile_summary;
return ( return (
<TableRow <TableRow
@@ -548,37 +373,25 @@ function DownlineTable({
className="cursor-pointer" className="cursor-pointer"
onClick={() => onSelectChild(child)} onClick={() => onSelectChild(child)}
> >
<TableCell className="font-mono text-xs">{child.code}</TableCell> <TableCell>
<TableCell className="font-medium">{child.name}</TableCell> <div className="font-medium">{child.name}</div>
<TableCell className="text-xs">{child.username ?? "—"}</TableCell> <div className="font-mono text-xs text-muted-foreground">{child.code}</div>
<TableCell className="max-w-[10rem] truncate text-xs text-muted-foreground">
{child.email ?? "—"}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-sm">{child.username ?? "—"}</TableCell>
{summary ? ( <TableCell className="text-right tabular-nums text-sm">
<div className="space-y-0.5"> {summary ? `${summary.total_share_rate ?? 0}%` : "—"}
<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>
<TableCell className={adminMoneyCellClassName("text-right text-xs")}> <TableCell className={adminMoneyCellClassName("text-right text-sm")}>
{summary ? <AdminTableMoney>{formatCredit(summary.credit_limit)}</AdminTableMoney> : "—"} {summary ? <AdminTableMoney>{formatCredit(summary.credit_limit)}</AdminTableMoney> : "—"}
</TableCell> </TableCell>
<TableCell className={adminMoneyCellClassName("text-right text-xs")}> <TableCell className={adminMoneyCellClassName("text-right text-sm")}>
{summary ? <AdminTableMoney>{formatCredit(summary.allocated_credit)}</AdminTableMoney> : "—"} {summary ? (
<AdminTableMoney>{formatCredit(summary.available_credit)}</AdminTableMoney>
) : (
"—"
)}
</TableCell> </TableCell>
<TableCell className="text-center tabular-nums text-xs"> <TableCell className="text-center tabular-nums text-sm">
{childCountById.get(child.id) ?? 0} {childCountById.get(child.id) ?? 0}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -590,20 +403,20 @@ function DownlineTable({
</TableCell> </TableCell>
{canManageNode ? ( {canManageNode ? (
<TableCell <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()} onClick={(e) => e.stopPropagation()}
> >
<AdminRowActionsMenu <AdminRowActionsMenu
actions={[ actions={[
{ {
key: "edit", key: "edit",
label: editChildLabel, label: t("lineUi.editDownline", { defaultValue: "编辑" }),
icon: Pencil, icon: Pencil,
onClick: () => onEditChild(child), onClick: () => onEditChild(child),
}, },
{ {
key: "delete", key: "delete",
label: deleteChildLabel, label: t("lineUi.deleteDownline", { defaultValue: "删除" }),
icon: Trash2, icon: Trash2,
destructive: true, destructive: true,
disabled: !canDeleteChild(child), disabled: !canDeleteChild(child),
@@ -615,61 +428,10 @@ function DownlineTable({
) : null} ) : null}
</TableRow> </TableRow>
); );
}) })}
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div> </div>
); );
} }
function MetricCard({
label,
value,
subtitle,
accent = false,
highlight = false,
money = true,
}: {
label: string;
value: string;
subtitle?: string;
accent?: boolean;
highlight?: boolean;
/** 金额类指标:自适应字号 + 换行 */
money?: boolean;
}): React.ReactElement {
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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -127,38 +127,8 @@ export function AgentsDirectoryConsole(): React.ReactElement {
}); });
}, [items, keyword, parentNameMap, status]); }, [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 ( return (
<div className="space-y-4"> <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 <AdminPageCard
title={t("listTitle", { defaultValue: "代理列表" })} title={t("listTitle", { defaultValue: "代理列表" })}
actions={ actions={
@@ -209,10 +179,10 @@ export function AgentsDirectoryConsole(): React.ReactElement {
</div> </div>
) : null} ) : null}
<div className="overflow-x-auto"> <div className="admin-table-inset overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="hover:bg-transparent">
<TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead> <TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead>
<TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead> <TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead>
<TableHead className="w-[90px]">{t("depth", { 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"> <TableHead className="w-[130px] text-right">
{t("lineUi.availableCredit", { defaultValue: "可下发" })} {t("lineUi.availableCredit", { defaultValue: "可下发" })}
</TableHead> </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: "操作" })} {t("common:table.actions", { defaultValue: "操作" })}
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -289,12 +259,12 @@ export function AgentsDirectoryConsole(): React.ReactElement {
<TableCell className="text-right"> <TableCell className="text-right">
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span> <span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
</TableCell> </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 <AdminRowActionsMenu
actions={[ actions={[
{ {
key: "view", key: "view",
label: t("common:actions.viewDetails", { defaultValue: "查看详情" }), label: t("lineUi.openInTree", { defaultValue: "在树中打开" }),
icon: Eye, icon: Eye,
href: `/admin/agents?agent_node_id=${item.id}`, 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"; "use client";
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react"; import { Eye, Pencil, Plus, ReceiptText, Search, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { getAgentNodeProfile } from "@/api/admin-agents"; import { getAgentNodeProfile } from "@/api/admin-agents";
import {
getSettlementBills,
postSettlementBillBadDebtWriteOff,
postSettlementBillConfirm,
postSettlementBillPayment,
type SettlementBillRow,
} from "@/api/admin-agent-settlement";
import { import {
deleteAdminPlayer, deleteAdminPlayer,
getAdminPlayer, getAdminPlayer,
@@ -57,18 +52,21 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { playerBalanceCells } from "@/lib/admin-player-display"; 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 { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { AGENT_PERCENT_HARD_MAX } from "@/lib/agent-profile-caps"; import { AGENT_PERCENT_HARD_MAX } from "@/lib/agent-profile-caps";
import { import {
NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
validateNativePlayerPassword, validateNativePlayerPassword,
validateNativePlayerUsername, validateNativePlayerUsername,
} from "@/lib/admin-input-validation"; } from "@/lib/admin-input-validation";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; 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 { 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 { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -148,8 +146,6 @@ type AgentsPlayersPanelProps = {
allowCreatePlayer?: boolean; allowCreatePlayer?: boolean;
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */ /** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
embedded?: boolean; embedded?: boolean;
/** 外部触发创建直属玩家的计数器 */
createRequestKey?: number;
}; };
export function AgentsPlayersPanel({ export function AgentsPlayersPanel({
@@ -157,10 +153,11 @@ export function AgentsPlayersPanel({
agentNodeId, agentNodeId,
allowCreatePlayer, allowCreatePlayer,
embedded = false, embedded = false,
createRequestKey = 0,
}: AgentsPlayersPanelProps): React.ReactElement { }: AgentsPlayersPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "players", "common"]); const { t } = useTranslation(["agents", "players", "common"]);
const router = useRouter();
const formatDt = useAdminDateTimeFormatter(); const formatDt = useAdminDateTimeFormatter();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const createPlayerLabel = embedded const createPlayerLabel = embedded
? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" }) ? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" })
: t("playersPanel.create", { defaultValue: "创建玩家" }); : t("playersPanel.create", { defaultValue: "创建玩家" });
@@ -173,21 +170,6 @@ export function AgentsPlayersPanel({
const isSiteAdmin = isSiteAdminOperator(profile); const isSiteAdmin = isSiteAdminOperator(profile);
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction(); 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(() => { const effectiveAgentId = useMemo(() => {
if (agentNodeId !== null) { if (agentNodeId !== null) {
return agentNodeId; return agentNodeId;
@@ -195,6 +177,26 @@ export function AgentsPlayersPanel({
return boundAgent?.id ?? null; return boundAgent?.id ?? null;
}, [agentNodeId, boundAgent?.id]); }, [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 [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20); const [perPage, setPerPage] = useState(20);
const [items, setItems] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]); const [items, setItems] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]);
@@ -231,18 +233,6 @@ export function AgentsPlayersPanel({
const [editRebateRate, setEditRebateRate] = useState(""); const [editRebateRate, setEditRebateRate] = useState("");
const [editRiskTags, setEditRiskTags] = useState(""); const [editRiskTags, setEditRiskTags] = useState("");
const [editDetailLoading, setEditDetailLoading] = useState(false); 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 () => { const load = useCallback(async () => {
if (siteCode.trim() === "") { if (siteCode.trim() === "") {
setItems([]); setItems([]);
@@ -259,6 +249,7 @@ export function AgentsPlayersPanel({
page, page,
per_page: perPage, per_page: perPage,
site_code: siteCode.trim(), site_code: siteCode.trim(),
...(appliedKeyword.trim() !== "" ? { keyword: appliedKeyword.trim() } : {}),
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}), ...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
}); });
setItems(data.items); setItems(data.items);
@@ -272,7 +263,7 @@ export function AgentsPlayersPanel({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [effectiveAgentId, page, perPage, siteCode]); }, [appliedKeyword, effectiveAgentId, page, perPage, siteCode, t]);
useAsyncEffect(() => { useAsyncEffect(() => {
void load(); void load();
@@ -355,9 +346,7 @@ export function AgentsPlayersPanel({
username: username.trim(), username: username.trim(),
password: password, password: password,
nickname: nickname.trim() || null, nickname: nickname.trim() || null,
...(effectiveAgentId != null && (isSuperAdmin || isSiteAdmin || boundAgent !== null) ...(canAttachAgentNodeOnCreate ? { agent_node_id: effectiveAgentId! } : {}),
? { agent_node_id: effectiveAgentId }
: {}),
credit_limit: parsedCreditLimit, credit_limit: parsedCreditLimit,
...(parsedRebateRate !== null ...(parsedRebateRate !== null
? { rebate_rate: parsedRebateRate } ? { 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 applyEditForm = (row: AdminPlayerRow): void => {
const form = fillEditFormFromPlayer(row); const form = fillEditFormFromPlayer(row);
setEditUsername(form.username); setEditUsername(form.username);
@@ -601,47 +580,19 @@ export function AgentsPlayersPanel({
} }
} }
const selectedBill = useMemo( async function goToPlayerSettlement(row: AdminPlayerRow): Promise<void> {
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null, setSettlementNavBusy(true);
[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);
try { try {
const data = await getSettlementBills({ const href = await resolvePlayerSettlementHref(row, adminSiteId);
bill_type: "player", if (href) {
keyword: row.site_player_id, router.push(href);
per_page: 20, return;
}); }
const items = (data.items ?? []).filter( router.push(settlementCenterListHref(adminSiteId));
(bill) => toast.info(
bill.bill_type === "player" && t("playersPanel.noPendingBillsGoCenter", {
bill.owner_id === row.id && defaultValue: "该玩家暂无待处理账单,已打开结算中心。",
(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") : "",
); );
} catch (e) { } catch (e) {
toast.error( toast.error(
@@ -650,173 +601,51 @@ export function AgentsPlayersPanel({
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }), : t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
); );
} finally { } 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<ConfirmDialog /> <ConfirmDialog />
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="admin-list-toolbar">
{!embedded ? ( <div className="admin-list-field min-w-[12rem] flex-1">
<p className="text-xs text-muted-foreground"> <div className="relative">
{t("playersPanel.creditListHint", { <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。", <Input
})} value={keyword}
</p> onChange={(e) => setKeyword(e.target.value)}
) : ( onKeyDown={(e) => {
<div /> if (e.key === "Enter") {
)} setAppliedKeyword(keyword);
{canCreatePlayer && !embedded ? ( setPage(1);
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}> }
}}
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>
{canCreatePlayer ? (
<Button type="button" size="sm" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" /> <Plus className="mr-1.5 size-3.5" />
{createPlayerLabel} {createPlayerLabel}
</Button> </Button>
) : null} ) : null}
</div> </div>
</div>
{loading ? ( {loading ? (
<AdminLoadingState minHeight="6rem" /> <AdminLoadingState minHeight="6rem" />
@@ -825,16 +654,24 @@ export function AgentsPlayersPanel({
{listErr ? ( {listErr ? (
<p className="mb-3 text-sm text-destructive">{listErr}</p> <p className="mb-3 text-sm text-destructive">{listErr}</p>
) : null} ) : 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"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <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 className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead> <TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
<TableHead className="whitespace-nowrap"> <TableHead className="whitespace-nowrap">
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })} {t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
</TableHead> </TableHead>
{!embedded ? (
<>
<TableHead className="whitespace-nowrap"> <TableHead className="whitespace-nowrap">
{t("players:riskTags", { defaultValue: "风控标签" })} {t("players:riskTags", { defaultValue: "风控标签" })}
</TableHead> </TableHead>
@@ -842,24 +679,26 @@ export function AgentsPlayersPanel({
{t("players:fundingMode", { defaultValue: "资金模式" })} {t("players:fundingMode", { defaultValue: "资金模式" })}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead> <TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
</>
) : null}
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })} {t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("players:rebateRate", { defaultValue: "回水" })} {t("players:rebateRate", { defaultValue: "回水" })}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
{!embedded ? ( {!embedded ? (
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead> <TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
) : null} ) : 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: "操作" })} {t("common:table.actions", { defaultValue: "操作" })}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.length === 0 ? ( {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) => { items.map((row) => {
const balances = playerBalanceCells(row, formatAdminMinorUnits); const balances = playerBalanceCells(row, formatAdminMinorUnits);
@@ -876,13 +715,15 @@ export function AgentsPlayersPanel({
<span className="text-muted-foreground"> / </span> <span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{row.nickname ?? "—"}</span> <span className="text-muted-foreground">{row.nickname ?? "—"}</span>
</TableCell> </TableCell>
{!embedded ? (
<>
<TableCell className="max-w-[14rem]"> <TableCell className="max-w-[14rem]">
{riskTags.length > 0 ? ( {riskTags.length > 0 ? (
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}> <div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
{riskTags.map((tag) => ( {riskTags.map((tag) => (
<span <span
key={`${row.id}-${tag}`} 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" 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} {tag}
</span> </span>
@@ -896,6 +737,8 @@ export function AgentsPlayersPanel({
<PlayerFundingModeBadge row={row} /> <PlayerFundingModeBadge row={row} />
</TableCell> </TableCell>
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell> <TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
</>
) : null}
<TableCell className="text-right text-xs tabular-nums"> <TableCell className="text-right text-xs tabular-nums">
<span>{balances.balance}</span> <span>{balances.balance}</span>
<span className="text-muted-foreground"> / </span> <span className="text-muted-foreground"> / </span>
@@ -911,22 +754,22 @@ export function AgentsPlayersPanel({
> >
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"} {rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
</TableCell> </TableCell>
{!embedded ? (
<TableCell className="whitespace-nowrap text-xs text-muted-foreground"> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"} {row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell> </TableCell>
{!embedded ? ( ) : null}
<TableCell> <TableCell>
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}> <AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
{playerStatusLabel(row.status, t)} {playerStatusLabel(row.status, t)}
</AdminStatusBadge> </AdminStatusBadge>
</TableCell> </TableCell>
) : null}
<TableCell <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()} onClick={(e) => e.stopPropagation()}
> >
<AdminRowActionsMenu <AdminRowActionsMenu
busy={confirmBusy} busy={confirmBusy || settlementNavBusy}
actions={[ actions={[
{ {
key: "detail", key: "detail",
@@ -942,7 +785,7 @@ export function AgentsPlayersPanel({
defaultValue: "处理账单", defaultValue: "处理账单",
}), }),
icon: ReceiptText, 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" className="bg-background/50 transition-colors focus:bg-background"
/> />
<p className="text-[11px] text-muted-foreground/80"> <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> </p>
</div> </div>
</div> </div>
@@ -1101,157 +947,6 @@ export function AgentsPlayersPanel({
</DialogContent> </DialogContent>
</Dialog> </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}> <Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="sm:max-w-[460px]">
<DialogHeader> <DialogHeader>

View File

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

View File

@@ -15,7 +15,14 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { import {
Table, Table,
TableBody, TableBody,
@@ -28,6 +35,28 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminAuditLogListData } from "@/types/api/admin-audit"; 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 { export function AuditLogsConsole(): React.ReactElement {
const { t } = useTranslation(["audit", "common"]); const { t } = useTranslation(["audit", "common"]);
const tRef = useTranslationRef(["audit", "common"]); const tRef = useTranslationRef(["audit", "common"]);
@@ -39,15 +68,13 @@ export function AuditLogsConsole(): React.ReactElement {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10); const [perPage, setPerPage] = useState(10);
const [operatorId, setOperatorId] = useState(""); const [operatorId, setOperatorId] = useState("");
const [moduleCode, setModuleCode] = useState(""); const [moduleCode, setModuleCode] = useState("all");
const [actionCode, setActionCode] = useState(""); const [operatorType, setOperatorType] = useState("all");
const [operatorType, setOperatorType] = useState("");
const [startDate, setStartDate] = useState(""); const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState(""); const [endDate, setEndDate] = useState("");
const [appliedOperatorId, setAppliedOperatorId] = useState(""); const [appliedOperatorId, setAppliedOperatorId] = useState("");
const [appliedModule, setAppliedModule] = useState(""); const [appliedModule, setAppliedModule] = useState("all");
const [appliedAction, setAppliedAction] = useState(""); const [appliedOpType, setAppliedOpType] = useState("all");
const [appliedOpType, setAppliedOpType] = useState("");
const [appliedStartDate, setAppliedStartDate] = useState(""); const [appliedStartDate, setAppliedStartDate] = useState("");
const [appliedEndDate, setAppliedEndDate] = useState(""); const [appliedEndDate, setAppliedEndDate] = useState("");
@@ -66,9 +93,8 @@ export function AuditLogsConsole(): React.ReactElement {
page, page,
per_page: perPage, per_page: perPage,
operator_id: operatorIdParam, operator_id: operatorIdParam,
module_code: appliedModule.trim() || undefined, module_code: appliedModule !== "all" ? appliedModule : undefined,
action_code: appliedAction.trim() || undefined, operator_type: appliedOpType !== "all" ? appliedOpType : undefined,
operator_type: appliedOpType.trim() || undefined,
start_date: appliedStartDate || undefined, start_date: appliedStartDate || undefined,
end_date: appliedEndDate || undefined, end_date: appliedEndDate || undefined,
}); });
@@ -79,14 +105,29 @@ export function AuditLogsConsole(): React.ReactElement {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]); }, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
useAsyncEffect(() => { useAsyncEffect(() => {
void load(); void load();
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]); }, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
const meta = data?.meta; 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 ( return (
<Card className="admin-list-card w-full max-w-none"> <Card className="admin-list-card w-full max-w-none">
<CardHeader className="admin-list-header"> <CardHeader className="admin-list-header">
@@ -94,6 +135,42 @@ export function AuditLogsConsole(): React.ReactElement {
</CardHeader> </CardHeader>
<CardContent className="admin-list-content"> <CardContent className="admin-list-content">
<div className="admin-list-toolbar"> <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"> <div className="admin-list-field">
<Label htmlFor="aud-operator-id" className="sm:shrink-0"> <Label htmlFor="aud-operator-id" className="sm:shrink-0">
{t("operator")} {t("operator")}
@@ -107,42 +184,6 @@ export function AuditLogsConsole(): React.ReactElement {
inputMode="numeric" inputMode="numeric"
/> />
</div> </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"> <div className="admin-list-field">
<Label htmlFor="aud-date-range" className="sm:shrink-0"> <Label htmlFor="aud-date-range" className="sm:shrink-0">
{t("time")} {t("time")}
@@ -170,7 +211,6 @@ export function AuditLogsConsole(): React.ReactElement {
onClick={() => { onClick={() => {
setAppliedOperatorId(operatorId); setAppliedOperatorId(operatorId);
setAppliedModule(moduleCode); setAppliedModule(moduleCode);
setAppliedAction(actionCode);
setAppliedOpType(operatorType); setAppliedOpType(operatorType);
setAppliedStartDate(startDate); setAppliedStartDate(startDate);
setAppliedEndDate(endDate); setAppliedEndDate(endDate);
@@ -184,15 +224,13 @@ export function AuditLogsConsole(): React.ReactElement {
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setOperatorId(""); setOperatorId("");
setModuleCode(""); setModuleCode("all");
setActionCode(""); setOperatorType("all");
setOperatorType("");
setStartDate(""); setStartDate("");
setEndDate(""); setEndDate("");
setAppliedOperatorId(""); setAppliedOperatorId("");
setAppliedModule(""); setAppliedModule("all");
setAppliedAction(""); setAppliedOpType("all");
setAppliedOpType("");
setAppliedStartDate(""); setAppliedStartDate("");
setAppliedEndDate(""); setAppliedEndDate("");
setPage(1); setPage(1);
@@ -209,35 +247,35 @@ export function AuditLogsConsole(): React.ReactElement {
<Table id="audit-logs-table"> <Table id="audit-logs-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead> <TableHead className="w-40 whitespace-nowrap">{t("time")}</TableHead>
<TableHead>{t("operator")}</TableHead> <TableHead className="w-36">{t("operator")}</TableHead>
<TableHead>{t("module")}</TableHead> <TableHead>{t("summary")}</TableHead>
<TableHead>{t("action")}</TableHead> <TableHead className="w-36">{t("target")}</TableHead>
<TableHead>{t("target")}</TableHead>
<TableHead>{t("time")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading && !data ? ( {loading && !data ? (
<AdminTableLoadingRow colSpan={6} /> <AdminTableLoadingRow colSpan={4} />
) : !data || data.items.length === 0 ? ( ) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={6} /> <AdminTableNoResourceRow colSpan={4} message={t("empty")} />
) : ( ) : (
data.items.map((row) => ( data.items.map((row) => (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell>{row.id}</TableCell> <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
<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)} {formatTs(row.created_at)}
</TableCell> </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> </TableRow>
)) ))
)} )}
@@ -260,7 +298,9 @@ export function AuditLogsConsole(): React.ReactElement {
/> />
) : null} ) : null}
</> </>
) : null} ) : (
<AdminNoResourceState message={t("empty")} />
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -32,18 +32,13 @@ export function ConfigDocToolbar({
className?: string; className?: string;
}) { }) {
return ( return (
<div <div className={cn("rounded-lg border border-border/70 bg-background", className)}>
className={cn( <div className="flex flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
"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="min-w-0 flex-1">{switcher}</div> <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 className="flex shrink-0 flex-wrap items-center gap-1.5 sm:justify-end">{actions}</div>
</div> </div>
{footer ? ( {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} ) : null}
</div> </div>
); );

View File

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

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { useMemo, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -20,20 +20,13 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
Sheet, import { ScrollArea } from "@/components/ui/scroll-area";
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ConfigStatusBadge } from "@/modules/config/config-status-badge"; import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import type { ConfigVersionSummary } from "@/types/api/admin-config"; import type { ConfigVersionSummary } from "@/types/api/admin-config";
const STATUS_ORDER = ["draft", "active", "archived"] as const;
export type ConfigVersionSwitcherProps = { export type ConfigVersionSwitcherProps = {
versions: ConfigVersionSummary[]; versions: ConfigVersionSummary[];
selectedId: string; selectedId: string;
@@ -48,34 +41,12 @@ export type ConfigVersionSwitcherProps = {
rollbackBusy?: boolean; 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({ export function ConfigVersionSwitcher({
versions, versions,
selectedId, selectedId,
onSelectedIdChange, onSelectedIdChange,
loading = false, loading = false,
sheetTitle, sheetTitle,
sheetDescription,
className, className,
onDeleteVersion, onDeleteVersion,
onRollbackVersion, onRollbackVersion,
@@ -83,48 +54,24 @@ export function ConfigVersionSwitcher({
}: ConfigVersionSwitcherProps) { }: ConfigVersionSwitcherProps) {
const { t } = useTranslation(["config", "adminUsers"]); const { t } = useTranslation(["config", "adminUsers"]);
const formatDt = useAdminDateTimeFormatter(); const formatDt = useAdminDateTimeFormatter();
const [sheetOpen, setSheetOpen] = useState(false); const [open, setOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ConfigVersionSummary | null>(null); const [deleteTarget, setDeleteTarget] = useState<ConfigVersionSummary | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null); const [deletingId, setDeletingId] = useState<number | null>(null);
const resolvedSheetTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" }); const resolvedTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" });
const resolvedSheetDescription =
sheetDescription ?? t("versionSwitcher.sheetDescription", { ns: "config" });
const sortedVersions = useMemo( const sortedVersions = useMemo(
() => [...versions].sort((a, b) => b.id - a.id), () => [...versions].sort((a, b) => b.version_no - a.version_no),
[versions], [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( const selectedVersion = useMemo(
() => sortedVersions.find((v) => String(v.id) === selectedId) ?? null, () => sortedVersions.find((v) => String(v.id) === selectedId) ?? null,
[selectedId, sortedVersions], [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) { function switchTo(id: number) {
onSelectedIdChange(String(id)); onSelectedIdChange(String(id));
setSheetOpen(false); setOpen(false);
} }
async function confirmDelete() { async function confirmDelete() {
@@ -143,127 +90,72 @@ export function ConfigVersionSwitcher({
} }
} }
const triggerLabel = loading
? t("versionSwitcher.loading", { ns: "config" })
: selectedVersion
? `v${selectedVersion.version_no}`
: t("versionSwitcher.noneSelected", { ns: "config" });
return ( return (
<> <>
<div className={cn("flex min-w-0 items-center gap-3", className)}> <Popover open={open} onOpenChange={setOpen}>
<div className="flex min-w-0 flex-1 items-center gap-2"> <PopoverTrigger
{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"
disabled={loading || sortedVersions.length === 0} disabled={loading || sortedVersions.length === 0}
onClick={() => setSheetOpen(true)} className={cn(
className="h-8 shrink-0 gap-1.5 text-muted-foreground hover:text-foreground" "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 /> <span className="flex min-w-0 items-center gap-2">
{t("versionSwitcher.switch", { ns: "config" })} <span className="truncate font-mono tabular-nums">{triggerLabel}</span>
</Button> {selectedVersion ? (
</div> <ConfigStatusBadge status={selectedVersion.status} className="h-5 shrink-0 px-1.5 text-[11px]" />
<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>
) : null} ) : 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 ? ( {sortedVersions.length === 0 ? (
<AdminNoResourceState compact className="px-2 py-8" /> <AdminNoResourceState compact className="px-3 py-6" />
) : ( ) : (
<div className="space-y-5"> <ul className="p-1">
{visibleSections.map((section) => ( {sortedVersions.map((v) => {
<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 isCurrent = selectedId === String(v.id);
const note = formatVersionNote(v.reason, formatDt); const secondary =
const effectiveLabel = v.effective_at v.status === "active" && v.effective_at ? formatDt(v.effective_at) : null;
? formatDt(v.effective_at)
: null;
const meta = [effectiveLabel, note].filter(Boolean).join(" · ");
const showMenu = const showMenu =
(onDeleteVersion && v.status !== "active") || (onDeleteVersion && v.status !== "active") ||
(onRollbackVersion && v.status !== "draft"); (onRollbackVersion && v.status !== "draft");
return ( return (
<li key={v.id}> <li key={v.id} className="flex items-center gap-0.5">
<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 <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left" 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)} onClick={() => switchTo(v.id)}
> >
<div className="min-w-0 flex-1"> <span className="font-mono tabular-nums">v{v.version_no}</span>
<div className="flex items-center gap-2"> <ConfigStatusBadge status={v.status} className="h-5 px-1.5 text-[11px]" />
<span className="font-mono text-[15px] font-semibold tabular-nums text-foreground"> {secondary ? (
v{v.version_no} <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
</span> {secondary}
<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> </span>
) : ( ) : (
<ChevronRight <span className="flex-1" />
className="size-4 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground"
aria-hidden
/>
)} )}
{isCurrent ? <Check className="size-4 shrink-0 text-foreground" aria-hidden /> : null}
</button> </button>
{showMenu ? ( {showMenu ? (
<div className="flex shrink-0 items-center pr-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <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" 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", { aria-label={t("versionSwitcher.moreActions", {
ns: "config", ns: "config",
version: v.version_no, version: v.version_no,
@@ -272,13 +164,13 @@ export function ConfigVersionSwitcher({
> >
<MoreHorizontal className="size-4" /> <MoreHorizontal className="size-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-36"> <DropdownMenuContent align="end" className="min-w-32">
{onRollbackVersion && v.status !== "draft" ? ( {onRollbackVersion && v.status !== "draft" ? (
<DropdownMenuItem <DropdownMenuItem
disabled={rollbackBusy} disabled={rollbackBusy}
onClick={() => { onClick={() => {
onRollbackVersion(v); onRollbackVersion(v);
setSheetOpen(false); setOpen(false);
}} }}
> >
{t("versionSwitcher.rollback", { ns: "config" })} {t("versionSwitcher.rollback", { ns: "config" })}
@@ -295,29 +187,23 @@ export function ConfigVersionSwitcher({
) : null} ) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
) : null} ) : null}
</div>
</li> </li>
); );
})} })}
</ul> </ul>
</section>
))}
</div>
)} )}
</div> </ScrollArea>
</SheetContent> </PopoverContent>
</Sheet> </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"> <DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle> <DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("versionSwitcher.deleteConfirmDescription", { {t("versionSwitcher.deleteConfirmDescription", {
ns: "config", ns: "config",
id: deleteTarget?.id,
version: deleteTarget?.version_no, version: deleteTarget?.version_no,
})} })}
</DialogDescription> </DialogDescription>

View File

@@ -18,10 +18,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group"; import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -37,7 +34,7 @@ import { ratioToPercentUi } from "@/lib/admin-rate-percent";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value"; import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions"; import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"; 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 { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useTranslationRef } from "@/hooks/use-translation-ref";
@@ -56,9 +53,7 @@ import type {
} from "@/types/api/admin-config"; } from "@/types/api/admin-config";
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar"; 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 { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
import { OddsConfigSummaryPanel } from "@/modules/config/doc/odds-config-summary-panel";
import { import {
Table, Table,
TableBody, TableBody,
@@ -120,7 +115,6 @@ export function OddsConfigDocScreen({
const tRef = useTranslationRef(["config", "common"]); const tRef = useTranslationRef(["config", "common"]);
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]); const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]); const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]); const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [internalSelectedId, setInternalSelectedId] = useState(""); const [internalSelectedId, setInternalSelectedId] = useState("");
@@ -465,8 +459,6 @@ export function OddsConfigDocScreen({
} }
} }
const activeHead = resolvedList.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) { async function handleDeleteVersion(row: ConfigVersionSummary) {
try { try {
await deleteOddsVersion(row.id); await deleteOddsVersion(row.id);
@@ -509,18 +501,10 @@ export function OddsConfigDocScreen({
{ id: "d2", label: "2D" }, { id: "d2", label: "2D" },
]; ];
const activeCatLabel = catTabs.find((tab) => tab.id === catTab)?.label ?? catTab;
const activePlayLabel = resolvedPlayCode const activePlayLabel = resolvedPlayCode
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === 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 = ( const filtersInner = (
<> <>
<ConfigChipGroup label={t("odds.category", { ns: "config" })}> <ConfigChipGroup label={t("odds.category", { ns: "config" })}>
@@ -602,8 +586,7 @@ export function OddsConfigDocScreen({
selectedId={selectedId} selectedId={selectedId}
onSelectedIdChange={setSelectedId} onSelectedIdChange={setSelectedId}
loading={resolvedLoadingList} loading={resolvedLoadingList}
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={embedded ? undefined : t("odds.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion} onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback} onRollbackVersion={requestRollback}
rollbackBusy={saving} rollbackBusy={saving}
@@ -623,58 +606,9 @@ export function OddsConfigDocScreen({
onPublish={() => void requestPublishConfirm()} 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 scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope]; const row = scopeRows[scope];
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope]; const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
@@ -726,6 +660,26 @@ export function OddsConfigDocScreen({
</TableCell> </TableCell>
</TableRow> </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> </TableBody>
</Table> </Table>
); );
@@ -798,19 +752,7 @@ export function OddsConfigDocScreen({
/> />
) : resolvedPlayCode ? ( ) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}> <div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
{mergedLayout ? ( {mergedLayout ? mergedOddsTable : classicOddsGrid}
<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}
</>
)}
</div> </div>
) : null} ) : null}
</> </>
@@ -889,11 +831,8 @@ export function OddsConfigDocScreen({
if (embedded && mergedLayout) { if (embedded && mergedLayout) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">{toolbarBlock}</div> {toolbarBlock}
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<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"> <aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<OddsConfigPlayNav <OddsConfigPlayNav
catTab={catTab} catTab={catTab}
@@ -903,22 +842,13 @@ export function OddsConfigDocScreen({
resolvedPlayCode={resolvedPlayCode} resolvedPlayCode={resolvedPlayCode}
/> />
</aside> </aside>
<div className="min-w-0"> <div className="min-w-0">
<div className="border-b border-border/50 px-4 py-3 sm:px-5"> <div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3> <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>
<div className="px-4 py-4 sm:px-5">{mainBlock}</div> <div className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? ( {isDraft && canManage ? (
<OddsConfigDraftBar <OddsConfigDraftBar
isDirty={isDirty}
saving={saving} saving={saving}
loadingDetail={resolvedLoadingDetail} loadingDetail={resolvedLoadingDetail}
onSave={() => void handleSave()} onSave={() => void handleSave()}
@@ -927,14 +857,6 @@ export function OddsConfigDocScreen({
) : null} ) : null}
</div> </div>
</div> </div>
</div>
<OddsConfigSummaryPanel
compact
detail={resolvedDetail}
activeHead={activeHead ?? null}
/>
</div>
{dialogs} {dialogs}
</div> </div>
); );

View File

@@ -1,13 +1,12 @@
"use client"; "use client";
import { Rocket, Save } from "lucide-react"; import { Save } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type OddsConfigDraftBarProps = { type OddsConfigDraftBarProps = {
isDirty: boolean;
saving: boolean; saving: boolean;
loadingDetail?: boolean; loadingDetail?: boolean;
onSave: () => void; onSave: () => void;
@@ -16,7 +15,6 @@ type OddsConfigDraftBarProps = {
}; };
export function OddsConfigDraftBar({ export function OddsConfigDraftBar({
isDirty,
saving, saving,
loadingDetail = false, loadingDetail = false,
onSave, onSave,
@@ -29,23 +27,17 @@ export function OddsConfigDraftBar({
return ( return (
<div <div
className={cn( 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, 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}> <Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
<Save className="size-3.5" aria-hidden /> <Save className="size-3.5" aria-hidden />
{t("versionActions.saveDraft")} {t("versionActions.saveDraft")}
</Button> </Button>
<Button type="button" size="sm" disabled={busy} onClick={onPublish}> <Button type="button" size="sm" disabled={busy} onClick={onPublish}>
<Rocket className="size-3.5" aria-hidden />
{t("versionActions.publishCurrent")} {t("versionActions.publishCurrent")}
</Button> </Button>
</div> </div>
</div>
); );
} }

View File

@@ -136,8 +136,8 @@ export function OddsConfigPlayNav({
className={cn( className={cn(
"w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors", "w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors",
active active
? "bg-primary font-medium text-primary-foreground shadow-sm" ? "bg-muted font-medium text-foreground"
: "text-foreground hover:bg-muted/80", : "text-foreground hover:bg-muted/60",
)} )}
onClick={() => onPlayCodeChange(type.play_code)} onClick={() => onPlayCodeChange(type.play_code)}
aria-current={active ? "true" : undefined} 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 { Checkbox } from "@/components/ui/checkbox";
import { ConfigChipGroup } from "@/modules/config/config-chip-group"; import { ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; 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 { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
import { import {
Dialog, Dialog,
@@ -52,7 +48,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value"; import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions"; import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"; import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useTranslationRef } from "@/hooks/use-translation-ref";
@@ -152,7 +148,6 @@ export function PlayConfigDocScreen() {
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]); const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]); const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState(""); const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null); const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
@@ -429,8 +424,6 @@ export function PlayConfigDocScreen() {
return <span>{name || row.play_code}</span>; return <span>{name || row.play_code}</span>;
} }
const activeHead = list.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) { async function handleDeleteVersion(row: ConfigVersionSummary) {
try { try {
await deletePlayConfigVersion(row.id); await deletePlayConfigVersion(row.id);
@@ -488,7 +481,7 @@ export function PlayConfigDocScreen() {
selectedId={selectedId} selectedId={selectedId}
onSelectedIdChange={setSelectedId} onSelectedIdChange={setSelectedId}
loading={loadingList} loading={loadingList}
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion} onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback} onRollbackVersion={requestRollback}
rollbackBusy={saving} rollbackBusy={saving}
@@ -515,53 +508,20 @@ 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 ? ( {detail ? (
<ConfigSection <div className="space-y-3">
title={t("play.filters.sectionTitle", { ns: "config" })} <div className="flex flex-wrap items-center gap-2">
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 <Input
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })} placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
className="h-8" className="h-8 w-full min-w-[12rem] max-w-xs"
/> />
</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")}> <Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
<SelectTrigger className="h-8"> <SelectTrigger className="h-8 w-[8.5rem]">
<SelectValue> <SelectValue>
{categoryFilter === "all" {categoryFilter === "all"
? t("play.filters.allCategories", { ns: "config" }) ? t("play.filters.allCategories", { ns: "config" })
@@ -580,14 +540,11 @@ export function PlayConfigDocScreen() {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </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 <Select
value={statusFilter} value={statusFilter}
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")} onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
> >
<SelectTrigger className="h-8"> <SelectTrigger className="h-8 w-[6.5rem]">
<SelectValue> <SelectValue>
{statusFilter === "all" {statusFilter === "all"
? t("play.filters.allStatuses", { ns: "config" }) ? t("play.filters.allStatuses", { ns: "config" })
@@ -602,13 +559,12 @@ export function PlayConfigDocScreen() {
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem> <SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> {(keyword || categoryFilter !== "all" || statusFilter !== "all") ? (
</div>
<div className="flex items-end justify-start lg:flex-none">
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="ghost"
className="h-8 px-2"
onClick={() => { onClick={() => {
setKeyword(""); setKeyword("");
setCategoryFilter("all"); setCategoryFilter("all");
@@ -617,42 +573,20 @@ export function PlayConfigDocScreen() {
> >
{t("play.filters.reset", { ns: "config" })} {t("play.filters.reset", { ns: "config" })}
</Button> </Button>
</div> ) : null}
</div> </div>
{isDraft ? ( {isDraft ? (
<div className="space-y-2 border-t border-border/60 pt-3"> <ConfigChipGroup label={t("play.batchSwitchesTitle", { ns: "config" })}>
<div className="text-xs font-medium text-muted-foreground">
{t("play.batchSwitchesTitle", { ns: "config" })}
</div>
<ConfigChipGroup>
{batchSwitchStates.map((group) => { {batchSwitchStates.map((group) => {
const groupOn = group.allEnabled; const groupOn = group.allEnabled;
const isPartial = const isPartial =
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total; group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
return ( return (
<div <label
key={group.key} key={group.key}
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-card px-3 py-2" className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm"
> >
<div className="min-w-0"> <span>{group.label}</span>
<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 <Checkbox
checked={groupOn} checked={groupOn}
indeterminate={isPartial} indeterminate={isPartial}
@@ -679,14 +613,12 @@ export function PlayConfigDocScreen() {
}); });
}} }}
/> />
</div> </label>
</div>
); );
})} })}
</ConfigChipGroup> </ConfigChipGroup>
</div>
) : null} ) : null}
</ConfigSection> </div>
) : null} ) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null} {error ? <p className="text-sm text-destructive">{error}</p> : null}

View File

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

View File

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

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

View File

@@ -38,7 +38,6 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { import type {
AdminDashboardDrawPanel,
AdminDashboardLifetimeFinance, AdminDashboardLifetimeFinance,
AdminDashboardPlatformRisk, AdminDashboardPlatformRisk,
AdminDashboardResultBatchQueue, AdminDashboardResultBatchQueue,
@@ -172,7 +171,6 @@ export function DashboardConsole(): ReactElement {
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null); const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null); const [drawId, setDrawId] = useState<number | null>(null);
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null); const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null); const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
const [resultBatchQueue, setResultBatchQueue] = useState<AdminDashboardResultBatchQueue | null>( const [resultBatchQueue, setResultBatchQueue] = useState<AdminDashboardResultBatchQueue | null>(
@@ -201,7 +199,6 @@ export function DashboardConsole(): ReactElement {
setError(null); setError(null);
setFinance(null); setFinance(null);
setCapabilities(null); setCapabilities(null);
setDrawPanel(null);
setResultBatchQueue(null); setResultBatchQueue(null);
setLifetimeFinance(null); setLifetimeFinance(null);
setTodayFinance(null); setTodayFinance(null);
@@ -230,9 +227,6 @@ export function DashboardConsole(): ReactElement {
setTodayFinance(d.today_finance); setTodayFinance(d.today_finance);
setApiWarnings(d.warnings ?? []); setApiWarnings(d.warnings ?? []);
setPlatformRisk(d.platform_risk); setPlatformRisk(d.platform_risk);
if (d.draw != null) {
setDrawPanel(d.draw);
}
if (d.risk != null) { if (d.risk != null) {
setRiskLocked(d.risk.locked_amount); setRiskLocked(d.risk.locked_amount);
setRiskCap(d.risk.cap_amount); setRiskCap(d.risk.cap_amount);
@@ -544,6 +538,23 @@ export function DashboardConsole(): ReactElement {
</Card> </Card>
</div> </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 ? ( {!showAnalytics ? (
<Card className="admin-list-card min-w-0 py-0"> <Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0"> <CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
@@ -566,21 +577,6 @@ export function DashboardConsole(): ReactElement {
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4"> <aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
<DashboardPlayRankingCard analytics={analytics} /> <DashboardPlayRankingCard analytics={analytics} />
<DashboardAgentRankingCard 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>
</aside> </aside>
) : null} ) : null}
</section> </section>

View File

@@ -4,7 +4,13 @@ import Link from "next/link";
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react"; import {
AlertTriangle,
ArrowRightIcon,
CheckCircle2,
ChevronRightIcon,
type LucideIcon,
} from "lucide-react";
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -19,7 +25,7 @@ import {
YAxis, YAxis,
} from "recharts"; } 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 { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display"; import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -268,6 +274,7 @@ export function DashboardKpiCard({
currencyCode, currencyCode,
sparklineValues, sparklineValues,
deltaLabel, deltaLabel,
href,
}: { }: {
label: string; label: string;
value?: ReactNode; value?: ReactNode;
@@ -281,6 +288,8 @@ export function DashboardKpiCard({
currencyCode?: string | null; currencyCode?: string | null;
sparklineValues?: number[]; sparklineValues?: number[];
deltaLabel?: ReactNode; deltaLabel?: ReactNode;
/** 整张卡片可点击跳转 */
href?: string;
}): ReactElement { }): ReactElement {
const resolvedValue = const resolvedValue =
typeof signedAmountMinor === "number" && currencyCode !== undefined typeof signedAmountMinor === "number" && currencyCode !== undefined
@@ -295,18 +304,31 @@ export function DashboardKpiCard({
? String(resolvedValue) ? String(resolvedValue)
: undefined; : undefined;
return ( const card = (
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
<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 <div
className={cn( className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4", "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="flex shrink-0 items-center gap-1">
<div
className={cn(
"flex size-9 items-center justify-center rounded-lg [&_svg]:size-4",
kpiAccentClass(accent), kpiAccentClass(accent),
)} )}
> >
{icon} {icon}
</div> </div>
{href ? (
<ChevronRightIcon
className="size-4 text-muted-foreground/50 transition group-hover/kpi:text-primary"
aria-hidden
/>
) : null}
</div>
</div> </div>
<p <p
title={valueTitle} title={valueTitle}
@@ -336,10 +358,126 @@ export function DashboardKpiCard({
</div> </div>
) : null} ) : null}
{hint ? ( {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} ) : null}
</div> </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({ 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
DashboardKpiCard,
DashboardScopeMetric,
} from "@/modules/dashboard/dashboard-visuals";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import type { import type {
@@ -136,18 +133,21 @@ export function SiteCsDashboardConsole(): ReactElement {
value={overview.player_count} value={overview.player_count}
icon={<Users className="size-4" />} icon={<Users className="size-4" />}
hint={t("cs.playerCountHint")} hint={t("cs.playerCountHint")}
href="/admin/players"
/> />
<DashboardKpiCard <DashboardKpiCard
label={t("cs.ticketsToday")} label={t("cs.ticketsToday")}
value={overview.ticket_order_count_today} value={overview.ticket_order_count_today}
icon={<ClipboardList className="size-4" />} icon={<ClipboardList className="size-4" />}
hint={activityHint} hint={activityHint}
href="/admin/tickets"
/> />
<DashboardKpiCard <DashboardKpiCard
label={t("cs.activePlayersToday")} label={t("cs.activePlayersToday")}
value={overview.active_player_count_today} value={overview.active_player_count_today}
icon={<Search className="size-4" />} icon={<Search className="size-4" />}
hint={t("cs.activePlayersHint")} hint={t("cs.activePlayersHint")}
href="/admin/tickets"
/> />
</div> </div>
@@ -172,19 +172,6 @@ export function SiteCsDashboardConsole(): ReactElement {
})} })}
</CardContent> </CardContent>
</Card> </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> </section>
) : ( ) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground"> <AdminNoResourceState className="py-12 text-sm text-muted-foreground">

View File

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

View File

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

View File

@@ -4,11 +4,9 @@ import Link from "next/link";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
getAdminDraw,
getAdminDrawFinanceSummary, getAdminDrawFinanceSummary,
postAdminCancelDraw, postAdminCancelDraw,
postAdminManualCloseDraw, postAdminManualCloseDraw,
@@ -17,23 +15,20 @@ import {
} from "@/api/admin-draws"; } from "@/api/admin-draws";
import { postAdminRunDrawSettlement } from "@/api/admin-settlement"; import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
import { Button } from "@/components/ui/button"; 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 { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { canManageDrawResults } from "@/lib/draw-access"; import { canManageDrawResults } from "@/lib/draw-access";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { signedMoneyClass } from "@/lib/admin-signed-money"; import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money"; 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 { import {
PRD_DRAW_REOPEN_MANAGE, PRD_DRAW_REOPEN_MANAGE,
PRD_PAYOUT_MANAGE, PRD_PAYOUT_MANAGE,
@@ -50,28 +45,20 @@ function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
const formatDt = useAdminDateTimeFormatter(); const formatDt = useAdminDateTimeFormatter();
return ( return (
<ol className="grid gap-3 sm:grid-cols-3"> <ol className="grid gap-2 sm:grid-cols-3">
{steps.map((step, index) => ( {steps.map((step) => (
<li <li key={step.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
key={step.key} <p className="text-xs text-muted-foreground">{step.label}</p>
className={cn( <p className="mt-0.5 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
"relative rounded-lg border bg-muted/20 px-3 py-2.5",
index < steps.length - 1 &&
"sm:after:absolute sm:after:top-1/2 sm:after:left-full sm:after:h-px sm:after:w-3 sm:after:-translate-y-1/2 sm:after:bg-border",
)}
>
<p className="text-xs font-medium text-muted-foreground">{step.label}</p>
<p className="mt-1 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
</li> </li>
))} ))}
</ol> </ol>
); );
} }
export function DrawDetailConsole({ drawId }: { drawId: string }) { export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]); const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]); const { draw: data, loading, error, refresh, drawId: idNum } = useDrawDetail();
const idNum = Number(drawId);
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManageDraw = canManageDrawResults(profile?.permissions); const canManageDraw = canManageDrawResults(profile?.permissions);
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]); const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
@@ -79,41 +66,29 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
PRD_PAYOUT_MANAGE, PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW, 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 [acting, setActing] = useState<string | null>(null);
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null); const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => { const loadFinance = useCallback(async () => {
if (!Number.isFinite(idNum)) { if (!Number.isFinite(idNum) || !data) {
setError(tRef.current("invalidDrawId")); setFinanceSummary(null);
setLoading(false); return;
}
if (data.capabilities?.can_view_draw_finance === false) {
setFinanceSummary(null);
return; return;
} }
setLoading(true);
setError(null);
try {
const draw = await getAdminDraw(idNum);
setData(draw);
if (draw.capabilities?.can_view_draw_finance !== false) {
try { try {
setFinanceSummary(await getAdminDrawFinanceSummary(idNum)); setFinanceSummary(await getAdminDrawFinanceSummary(idNum));
} catch { } catch {
setFinanceSummary(null); setFinanceSummary(null);
} }
} else { }, [data, idNum]);
setFinanceSummary(null);
} useAsyncEffect(() => {
} catch (e) { void loadFinance();
setData(null); }, [loadFinance]);
setFinanceSummary(null);
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum, tRef]);
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> { async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
if (!Number.isFinite(idNum)) return; if (!Number.isFinite(idNum)) return;
@@ -121,7 +96,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
try { try {
await action(); await action();
toast.success(t("actionSuccess", { name })); toast.success(t("actionSuccess", { name }));
await load(); await refresh();
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name })); toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name }));
} finally { } finally {
@@ -129,10 +104,6 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
} }
} }
useAsyncEffect(() => {
void load();
}, [idNum]);
const scheduleSteps = useMemo((): ScheduleStep[] => { const scheduleSteps = useMemo((): ScheduleStep[] => {
if (!data) return []; if (!data) return [];
const steps: ScheduleStep[] = [ const steps: ScheduleStep[] = [
@@ -241,57 +212,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const pendingReview = batch.pending_review ?? 0; const pendingReview = batch.pending_review ?? 0;
const totalBatches = batch.total ?? batch.published; const totalBatches = batch.total ?? batch.published;
const financeCurrency = financeSummary?.currency_code ?? "NPR"; const financeCurrency = financeSummary?.currency_code ?? "NPR";
const hasResultActivity = const hasResultActivity = totalBatches > 0 || pendingReview > 0 || batch.published > 0;
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
const showActions =
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
return ( return (
<div className="space-y-4"> <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="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2.5"> <div className="rounded-lg border border-border/60 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewBetTotal")}</p> <p className="text-xs text-muted-foreground">{t("overviewBetTotal")}</p>
<p className="mt-1 font-mono text-sm tabular-nums"> <p className="mt-1 font-mono text-sm tabular-nums">
{formatAdminMinorUnits( {formatAdminMinorUnits(
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0, financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
@@ -299,8 +226,8 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
)} )}
</p> </p>
</div> </div>
<div className="rounded-lg border bg-muted/20 px-3 py-2.5"> <div className="rounded-lg border border-border/60 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewPayoutTotal")}</p> <p className="text-xs text-muted-foreground">{t("overviewPayoutTotal")}</p>
<p className="mt-1 font-mono text-sm tabular-nums"> <p className="mt-1 font-mono text-sm tabular-nums">
{formatAdminMinorUnits( {formatAdminMinorUnits(
financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0, financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0,
@@ -308,8 +235,8 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
)} )}
</p> </p>
</div> </div>
<div className="rounded-lg border bg-muted/20 px-3 py-2.5"> <div className="rounded-lg border border-border/60 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p> <p className="text-xs text-muted-foreground">{t("overviewProfitLoss")}</p>
<p <p
className={cn( className={cn(
"mt-1 font-mono text-sm tabular-nums", "mt-1 font-mono text-sm tabular-nums",
@@ -326,72 +253,52 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</p> </p>
</div> </div>
</div> </div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
<ScheduleTimeline steps={scheduleSteps} /> <ScheduleTimeline steps={scheduleSteps} />
</section>
<section className="space-y-3">
<h3 className="text-sm font-medium">{t("resultBatchesTitle")}</h3>
{hasResultActivity ? ( {hasResultActivity ? (
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
{canManageDraw ? ( {canManageDraw && totalBatches > 0 ? (
<span className="rounded-md bg-muted px-2.5 py-1"> <span className="rounded-md bg-muted px-2 py-1 tabular-nums">
{t("batchSummaryTotal", { count: totalBatches })} {t("batchSummaryTotal", { count: totalBatches })}
</span> </span>
) : null} ) : null}
{canManageDraw ? ( {canManageDraw && pendingReview > 0 ? (
pendingReview > 0 ? (
<Link <Link
href={`/admin/draws/${drawId}/review`} 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" className="rounded-md bg-amber-500/15 px-2 py-1 font-medium text-amber-800 dark:text-amber-200"
> >
{t("batchSummaryPending", { count: pendingReview })} {t("batchSummaryPending", { count: pendingReview })}
</Link> </Link>
) : (
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
{t("batchSummaryPending", { count: 0 })}
</span>
)
) : null} ) : null}
{batch.published > 0 ? ( {batch.published > 0 ? (
<Link <Link
href={`/admin/draws/${drawId}/results`} 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" 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 })} {t("batchSummaryPublished", { count: batch.published })}
</Link> </Link>
) : ( ) : null}
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground"> {data.capabilities?.can_view_draw_finance !== false ? (
{t("batchSummaryPublished", { count: 0 })} <Link
</span> href={`/admin/draws/${drawId}/finance`}
)} className="text-sm font-medium text-primary hover:underline"
>
{t("viewFinance")}
</Link>
) : null}
</div> </div>
) : ( ) : canManageDraw ? (
<p className="text-sm text-muted-foreground">
{t("noResultBatchesYet")}
<span className="ml-1">{t("reviewQueueHint")}</span>
{canManageDraw ? (
<>
{" "}
<Link <Link
href={`/admin/draws/${drawId}/review`} href={`/admin/draws/${drawId}/review`}
className="font-medium text-primary underline-offset-4 hover:underline" className="text-sm font-medium text-primary hover:underline"
> >
{t("goToReviewTab")} {t("goToReviewTab")}
</Link> </Link>
</>
) : null} ) : null}
</p>
)}
</section>
{showActions ? ( {availableActions.length > 0 ? (
<section className="space-y-3 border-t pt-6"> <div className="flex flex-wrap gap-2 border-t border-border/60 pt-4">
<h3 className="text-sm font-medium">{t("drawActions")}</h3>
<div className="flex flex-wrap gap-2">
{availableActions.map((action) => ( {availableActions.map((action) => (
<Button <Button
key={action.key} key={action.key}
@@ -412,10 +319,8 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</Button> </Button>
))} ))}
</div> </div>
</section>
) : null} ) : null}
</CardContent>
</Card>
<ConfirmDialog /> <ConfirmDialog />
</div> </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 { Button, buttonVariants } from "@/components/ui/button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-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 { useExportLabels } from "@/hooks/use-export-labels";
import { formatAdminMinorUnits } from "@/lib/money"; 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"; import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement { 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); const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<Card> <div className="grid gap-3 text-sm sm:grid-cols-3">
<CardHeader> <div className="rounded-lg border border-border/60 px-3 py-2">
<CardTitle className="text-lg">{t("financeOverview")}</CardTitle> <p className="text-xs text-muted-foreground">{t("orderAndItemCount")}</p>
</CardHeader> <p className="mt-0.5 tabular-nums font-medium">
<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} {data.order_count} / {data.ticket_item_count}
</p> </p>
</div> </div>
<div> <div className="rounded-lg border border-border/60 px-3 py-2">
<span className="text-muted-foreground">{t("actualBet")}</span> <p className="text-xs text-muted-foreground">{t("actualBet")}</p>
<p className="tabular-nums font-medium">{formatMoney(data.total_bet_minor)}</p> <p className="mt-0.5 tabular-nums font-medium">{formatMoney(data.total_bet_minor)}</p>
</div> </div>
<div> <div className="rounded-lg border border-border/60 px-3 py-2">
<span className="text-muted-foreground">{t("currentPayout")}</span> <p className="text-xs text-muted-foreground">{t("grossProfit")}</p>
<p className="tabular-nums font-medium">{formatMoney(data.total_payout_minor)}</p> <p className={cn("mt-0.5 tabular-nums font-semibold", signedMoneyClass(data.approx_house_gross_minor, true))}>
</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)} {formatMoney(data.approx_house_gross_minor)}
</p> </p>
</div> </div>
</CardContent> </div>
</Card>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}> <Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
@@ -174,11 +155,11 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</Link> </Link>
</div> </div>
<Card> <div className="rounded-lg border border-border/60">
<CardHeader> <div className="border-b border-border/60 px-3 py-2.5">
<CardTitle className="text-base">{t("relatedSettlementBatches")}</CardTitle> <h2 className="text-sm font-semibold">{t("relatedSettlementBatches")}</h2>
</CardHeader> </div>
<CardContent> <div className="p-3">
{data.settlement_batches.length === 0 ? ( {data.settlement_batches.length === 0 ? (
<AdminNoResourceState className="py-4" /> <AdminNoResourceState className="py-4" />
) : ( ) : (
@@ -232,8 +213,8 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</Table> </Table>
</div> </div>
)} )}
</CardContent> </div>
</Card> </div>
<ConfirmDialog /> <ConfirmDialog />
</div> </div>
); );

View File

@@ -1,57 +1,30 @@
"use client"; "use client";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import { import { getAdminDrawResultBatches } from "@/api/admin-draws";
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 { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
Table, import { useDrawDetail } from "@/modules/draws/draw-detail-context";
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 { LotteryApiBizError } from "@/types/api/errors"; 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"; /** 深链兼容:/publish/[batchId] 直接打开发布弹窗 */
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd"; export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }): React.ReactElement {
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
const { t } = useTranslation(["draws", "common"]); const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]); const tRef = useTranslationRef(["draws", "common"]);
const router = useRouter(); const router = useRouter();
const profile = useAdminProfile(); const { refresh: refreshDraw } = useDrawDetail();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
]);
const idNum = Number(drawId); const idNum = Number(drawId);
const batchNum = Number(batchId); const batchNum = Number(batchId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null); const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [publishing, setPublishing] = useState(false);
const [discarding, setDiscarding] = useState(false);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => { const load = useCallback(async () => {
if (!Number.isFinite(idNum)) { if (!Number.isFinite(idNum)) {
@@ -69,53 +42,17 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [idNum]); }, [idNum, tRef]);
useAsyncEffect(() => { useAsyncEffect(() => {
void load(); void load();
}, [idNum]); }, [load]);
const batch: AdminDrawBatchRow | undefined = useMemo(() => { const batch = useMemo(() => {
if (!Number.isFinite(batchNum)) return undefined; if (!Number.isFinite(batchNum)) return undefined;
return data?.batches.find((b) => b.id === batchNum); return data?.batches.find((b) => b.id === batchNum);
}, [batchNum, data]); }, [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) { if (loading && !data) {
return <AdminLoadingState minHeight="6rem" className="py-6" />; return <AdminLoadingState minHeight="6rem" className="py-6" />;
} }
@@ -123,131 +60,29 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
if (error) { if (error) {
return <p className="text-sm text-destructive">{error}</p>; return <p className="text-sm text-destructive">{error}</p>;
} }
if (!data) {
return <AdminNoResourceState />;
}
if (!batch) { if (!batch) {
return ( return <AdminNoResourceState message={t("batchNotFound")} />;
<Alert variant="destructive">
<AlertTitle>{t("batchNotFound")}</AlertTitle>
<AlertDescription>{t("batchNotFoundDesc")}</AlertDescription>
</Alert>
);
} }
const canPublish =
canManageDraw && batch.status === "pending_review";
return ( return (
<div className="space-y-4"> <DrawPublishDialog
<div className="flex flex-wrap items-center justify-between gap-2"> open
<Link href={`/admin/draws/${drawId}/review`} className={buttonVariants({ variant: "ghost", size: "sm" })}> onOpenChange={(open) => {
{t("backToReviewQueue")} if (!open) {
</Link> router.replace(`/admin/draws/${drawId}/review`);
</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")} drawId={idNum}
</Button> batch={batch}
) : null} onPublished={() => {
<Button void refreshDraw();
type="button" router.replace(`/admin/draws/${drawId}/results`);
disabled={!canPublish || publishing || discarding} }}
onClick={() => onDiscarded={() => {
requestConfirm({ void refreshDraw();
title: t("confirm.publishTitle"), router.replace(`/admin/draws/${drawId}/review`);
description: t("confirm.publishDescription"), }}
confirmVariant: "destructive", />
onConfirm: () => publish(),
})
}
>
{publishing ? t("submitting") : t("confirmPublish")}
</Button>
</CardFooter>
</Card>
<ConfirmDialog />
</div>
); );
} }

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, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; 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 { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws"; import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawPrizeTypeLabel } from "./draw-display"; import { drawPrizeTypeLabel } from "./draw-display";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawResultsConsole({ drawId }: { drawId: string }) { export function DrawResultsConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]); const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]); const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = canManageDrawResults(profile?.permissions);
const idNum = Number(drawId); const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null); const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | 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"); const published = data.batches.filter((b) => b.status === "published");
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<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>
{published.length === 0 ? ( {published.length === 0 ? (
<Card> <AdminNoResourceState message={t("noPublishedBatch")} />
<CardContent className="py-4">
<AdminNoResourceState />
</CardContent>
</Card>
) : ( ) : (
published.map((batch) => ( published.map((batch) => (
<BatchTable key={batch.id} batch={batch} showOperationalMeta={canManageDraw} /> <BatchTable key={batch.id} batch={batch} />
)) ))
)} )}
</div> </div>
); );
} }
function BatchTable({ function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
batch,
showOperationalMeta,
}: {
batch: AdminDrawBatchRow;
showOperationalMeta: boolean;
}) {
const { t } = useTranslation("draws"); const { t } = useTranslation("draws");
const formatDt = useAdminDateTimeFormatter(); const formatDt = useAdminDateTimeFormatter();
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{t("version", { version: batch.result_version })}</CardTitle> <CardTitle className="text-sm">{t("version", { version: batch.result_version })}</CardTitle>
{showOperationalMeta ? (
<p className="font-mono text-xs text-muted-foreground"> <p className="font-mono text-xs text-muted-foreground">
{t("sourceType", { {formatDt(batch.confirmed_at)}
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>
) : (
<p className="text-xs text-muted-foreground">
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
</p>
)}
</CardHeader> </CardHeader>
<CardContent className="overflow-x-auto pt-0"> <CardContent className="overflow-x-auto pt-0">
<Table> <Table>

View File

@@ -1,6 +1,6 @@
"use client"; "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 { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -14,10 +14,9 @@ import {
} from "@/api/admin-draws"; } from "@/api/admin-draws";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; 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 { import {
Table, Table,
TableBody, TableBody,
@@ -28,13 +27,14 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors"; 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 { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
const RESULT_SLOTS = [ const RESULT_SLOTS = [
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" }, { 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"); 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 { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]); const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [ const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
PRD_DRAW_RESULT_MANAGE, const { refresh: refreshDraw } = useDrawDetail();
]);
const idNum = Number(drawId); const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null); const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingManual, setSavingManual] = useState(false); const [savingManual, setSavingManual] = useState(false);
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null); 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 { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [manualNumbers, setManualNumbers] = useState<string[]>( const [manualNumbers, setManualNumbers] = useState<string[]>(() => RESULT_SLOTS.map(() => ""));
() => RESULT_SLOTS.map(() => ""),
);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!Number.isFinite(idNum)) { if (!Number.isFinite(idNum)) {
@@ -92,15 +91,13 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [idNum]); }, [idNum, tRef]);
useAsyncEffect(() => { useAsyncEffect(() => {
void load(); void load();
}, [idNum]); }, [load]);
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [ const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [data]);
data,
]);
function fillRandomManualNumbers(): void { function fillRandomManualNumbers(): void {
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d())); setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d()));
@@ -113,10 +110,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
await deleteAdminPendingResultBatch(idNum, batchId); await deleteAdminPendingResultBatch(idNum, batchId);
toast.success(t("discardPendingBatchSuccess")); toast.success(t("discardPendingBatchSuccess"));
await load(); await load();
await refreshDraw();
} catch (e) { } catch (e) {
toast.error( toast.error(e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"));
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
);
} finally { } finally {
setDiscardingBatchId(null); setDiscardingBatchId(null);
} }
@@ -141,7 +137,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
}); });
toast.success(t("draftSaved", { version: res.batch.result_version })); toast.success(t("draftSaved", { version: res.batch.result_version }));
setManualNumbers(RESULT_SLOTS.map(() => "")); setManualNumbers(RESULT_SLOTS.map(() => ""));
setManualOpen(false);
await load(); await load();
await refreshDraw();
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed")); toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally { } finally {
@@ -149,6 +147,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
} }
} }
async function onPublishFlowDone(): Promise<void> {
await load();
await refreshDraw();
}
if (loading && !data) { if (loading && !data) {
return <AdminLoadingState minHeight="6rem" className="py-6" />; return <AdminLoadingState minHeight="6rem" className="py-6" />;
} }
@@ -161,100 +164,29 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<Card> <div className="rounded-lg border border-border/60">
<CardHeader> <div className="border-b border-border/60 px-3 py-2.5">
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle> <h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
<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>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="p-3">
<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 ? ( {pending.length === 0 ? (
<AdminNoResourceState className="py-6" /> <AdminNoResourceState className="py-6" message={t("noPendingBatches")} />
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("batchId")}</TableHead>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead> <TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</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> <TableHead className="w-14 text-center">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pending.map((b) => ( {pending.map((b) => (
<TableRow key={b.id}> <TableRow key={b.id}>
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell>v{b.result_version}</TableCell> <TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell> <TableCell className="tabular-nums">{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)]"> <TableCell className="text-center">
{canManageDraw ? ( {canManageDraw ? (
<AdminRowActionsMenu <AdminRowActionsMenu
busy={discardingBatchId === b.id} busy={discardingBatchId === b.id}
@@ -263,7 +195,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
key: "publish", key: "publish",
label: t("reviewAndPublishAction"), label: t("reviewAndPublishAction"),
icon: Rocket, icon: Rocket,
href: `/admin/draws/${drawId}/publish/${b.id}`, onClick: () => setPublishBatch(b),
}, },
{ {
key: "discard", key: "discard",
@@ -282,7 +214,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
]} ]}
/> />
) : ( ) : (
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span> <span className="text-xs text-muted-foreground"></span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -290,8 +222,101 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
</TableBody> </TableBody>
</Table> </Table>
)} )}
</CardContent> </div>
</Card> </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 /> <ConfirmDialog />
</div> </div>
); );

View File

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

View File

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

View File

@@ -1,52 +1,52 @@
"use client"; "use client";
import { useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-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() { export function JackpotConfigScreen() {
const { t } = useTranslation("jackpot"); const { t } = useTranslation("jackpot");
const [tab, setTab] = useState<JackpotTab>("config");
useEffect(() => { useEffect(() => {
const scrollToRecords = () => { const sync = () => setTab(readTabFromHash());
if (window.location.hash !== "#records") { 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; return;
} }
document.getElementById("jackpot-records")?.scrollIntoView({ behavior: "smooth", block: "start" }); window.history.replaceState(null, "", path);
};
scrollToRecords();
window.addEventListener("hashchange", scrollToRecords);
return () => window.removeEventListener("hashchange", scrollToRecords);
}, []); }, []);
return ( return (
<div className="flex w-full max-w-none flex-col gap-6"> <div className="flex w-full max-w-none flex-col gap-4">
<AdminPageCard title={t("poolsSectionTitle")}> <AdminSubnav aria-label={t("pageTabs")}>
<Alert className="mb-4 border-primary/20 bg-primary/5 text-foreground"> <AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
<Info className="size-4" aria-hidden /> {t("tabConfig")}
<AlertTitle>{t("rulesTitle")}</AlertTitle> </AdminSubnavButton>
<AlertDescription className="space-y-1 text-xs leading-5"> <AdminSubnavButton active={tab === "records"} onClick={() => switchTab("records")}>
<p>{t("rulesJoin")}</p> {t("tabRecords")}
<p>{t("rulesBurst")}</p> </AdminSubnavButton>
<p>{t("rulesManual")}</p> </AdminSubnav>
</AlertDescription>
</Alert>
<JackpotPoolsConsole embedded />
</AdminPageCard>
<AdminPageCard {tab === "config" ? <JackpotPoolsConsole embedded /> : <JackpotRecordsConsole embedded />}
id="jackpot-records"
title={t("recordsSectionTitle")}
description={t("recordsSectionDescription")}
>
<JackpotRecordsConsole embedded />
</AdminPageCard>
</div> </div>
); );
} }

View File

@@ -16,9 +16,9 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money"; import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd"; 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 { 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 { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -44,12 +44,8 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { import { percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
formatRatioAsPercent,
percentUiToRatio,
ratioToPercentUi,
} from "@/lib/admin-rate-percent";
type Draft = { type Draft = {
contribution_rate: string; contribution_rate: string;
@@ -82,7 +78,6 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
} }
type JackpotPoolsConsoleProps = { type JackpotPoolsConsoleProps = {
/** 嵌入运营配置单页时去掉外层脚手架与重复标题 */
embedded?: boolean; embedded?: boolean;
}; };
@@ -102,6 +97,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
const [adjustmentDrafts, setAdjustmentDrafts] = useState<Record<number, AdjustmentDraft>>({}); const [adjustmentDrafts, setAdjustmentDrafts] = useState<Record<number, AdjustmentDraft>>({});
const [adjustmentRows, setAdjustmentRows] = useState<Record<number, AdminJackpotPoolAdjustmentRow[]>>({}); const [adjustmentRows, setAdjustmentRows] = useState<Record<number, AdminJackpotPoolAdjustmentRow[]>>({});
const [adjustingId, setAdjustingId] = useState<number | null>(null); const [adjustingId, setAdjustingId] = useState<number | null>(null);
const [adjustmentOpenId, setAdjustmentOpenId] = useState<number | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -110,31 +106,32 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
setItems(res.items); setItems(res.items);
const d: Record<number, Draft> = {}; const d: Record<number, Draft> = {};
const adjDrafts: Record<number, AdjustmentDraft> = {}; const adjDrafts: Record<number, AdjustmentDraft> = {};
const adjRows: Record<number, AdminJackpotPoolAdjustmentRow[]> = {};
for (const p of res.items) { for (const p of res.items) {
d[p.id] = toDraft(p); d[p.id] = toDraft(p);
adjDrafts[p.id] = { direction: "increase", amount: "", reason: "" }; 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); setDrafts(d);
setAdjustmentDrafts(adjDrafts); setAdjustmentDrafts(adjDrafts);
setAdjustmentRows(adjRows);
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed")); toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [tRef]);
useAsyncEffect(() => { useAsyncEffect(() => {
void load(); 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>) => { const updateDraft = (id: number, patch: Partial<Draft>) => {
setDrafts((prev) => ({ setDrafts((prev) => ({
...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 save = async (p: AdminJackpotPoolRow) => {
const d = drafts[p.id]; const d = drafts[p.id];
if (!d) return; if (!d) return;
@@ -203,8 +207,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
), ),
); );
updateAdjustmentDraft(p.id, { amount: "", reason: "" }); updateAdjustmentDraft(p.id, { amount: "", reason: "" });
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 }); await loadAdjustments(p.id);
setAdjustmentRows((prev) => ({ ...prev, [p.id]: ledger.items }));
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("adjustmentFailed")); toast.error(e instanceof LotteryApiBizError ? e.message : t("adjustmentFailed"));
} finally { } finally {
@@ -237,68 +240,134 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
}; };
const poolList = ( 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 ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
{!loading && items.length === 0 ? ( {!loading && items.length === 0 ? <AdminNoResourceState /> : null}
<AdminNoResourceState />
) : null}
{items.map((p) => { {items.map((p) => {
const d = drafts[p.id] ?? toDraft(p); const d = drafts[p.id] ?? toDraft(p);
const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" }; const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" };
const ledger = adjustmentRows[p.id] ?? []; const ledger = adjustmentRows[p.id] ?? [];
const currentAmount = formatAdminMinorDecimal(p.current_amount, p.currency_code); 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 statusOn = d.status === "1";
const adjustmentOpen = adjustmentOpenId === p.id;
return ( return (
<div <AdminPageCard
key={p.id} key={p.id}
className="space-y-3 rounded-xl border border-border/60 bg-background p-3 shadow-sm" title={p.currency_code}
> actions={
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-center gap-2">
<div> <div className="flex items-center gap-2">
<h3 className="text-base font-semibold">{p.currency_code}</h3> <Label htmlFor={`status-${p.id}`} className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-xs">{t("configTitle")}</p> {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>
<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>
</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 ? ( {canManageJackpot ? (
<div className="space-y-2 rounded-lg border border-border/60 bg-background p-3"> <Button
<p className="text-sm font-medium">{t("balanceAdjustmentTitle")}</p> type="button"
<p className="text-muted-foreground text-xs">{t("balanceAdjustmentHint")}</p> size="sm"
<div className="grid gap-2 sm:grid-cols-2"> 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"> <div className="space-y-1.5">
<Label>{t("adjustmentDirection")}</Label> <Label>{t("adjustmentDirection")}</Label>
<Select <Select
@@ -308,15 +377,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
updateAdjustmentDraft(p.id, { direction: value }); updateAdjustmentDraft(p.id, { direction: value });
}} }}
> >
<SelectTrigger className="w-full min-w-0 sm:max-w-[12rem]"> <SelectTrigger className="w-full min-w-0">
<SelectValue> <SelectValue>
{(value) => {(value) =>
value === "increase" value === "increase"
? t("adjustmentIncrease") ? t("adjustmentIncrease")
: value === "decrease" : value === "decrease"
? t("adjustmentDecrease") ? t("adjustmentDecrease")
: value != null
? String(value)
: t("adjustmentIncrease") : t("adjustmentIncrease")
} }
</SelectValue> </SelectValue>
@@ -333,7 +400,6 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
id={`adj-amt-${p.id}`} id={`adj-amt-${p.id}`}
className="font-mono" className="font-mono"
value={adj.amount} value={adj.amount}
placeholder={t("adjustmentAmountPlaceholder")}
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })} onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
/> />
</div> </div>
@@ -341,17 +407,16 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label> <Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
<Textarea <Textarea
id={`adj-reason-${p.id}`} id={`adj-reason-${p.id}`}
rows={1} rows={2}
value={adj.reason} value={adj.reason}
placeholder={t("adjustmentReasonPlaceholder")}
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })} onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
/> />
</div> </div>
</div> <div className="flex justify-end sm:col-span-2">
<div className="flex justify-end">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
size="sm"
disabled={adjustingId === p.id} disabled={adjustingId === p.id}
onClick={() => onClick={() =>
requestConfirm({ requestConfirm({
@@ -365,155 +430,29 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
{adjustingId === p.id ? t("processing") : t("submitAdjustment")} {adjustingId === p.id ? t("processing") : t("submitAdjustment")}
</Button> </Button>
</div> </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 ? ( {ledger.length > 0 ? (
<ul className="max-h-60 space-y-2 overflow-y-auto pr-1"> <ul className="space-y-1.5 sm:col-span-2">
{ledger.map((row) => ( {ledger.map((row) => (
<li key={row.id} className="rounded-md border border-border/60 bg-muted/20 p-2"> <li
<div className="flex items-center justify-between gap-2"> key={row.id}
<span className="font-mono text-xs">{row.adjustment_no}</span> className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-xs"
<span className="text-sm font-semibold"> >
<span className="truncate text-muted-foreground">{row.reason}</span>
<span className="shrink-0 font-mono font-medium">
{row.amount_delta > 0 ? "+" : ""} {row.amount_delta > 0 ? "+" : ""}
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)} {formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
</span> </span>
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{row.reason}</p>
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : null}
<p className="text-muted-foreground text-xs"></p>
)}
</div> </div>
) : null}
</div>
) : null}
<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 ? ( {canManualBurst ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3"> <div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-end">
<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"> <div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label> <Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
<Input <Input
@@ -526,16 +465,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
className="shrink-0 sm:ml-auto" size="sm"
disabled={burstingId === p.id} disabled={burstingId === p.id}
onClick={() => setConfirmBurstPoolId(p.id)} onClick={() => setConfirmBurstPoolId(p.id)}
> >
{burstingId === p.id ? t("processing") : t("manualBurst")} {burstingId === p.id ? t("processing") : t("manualBurst")}
</Button> </Button>
</div> </div>
</div>
) : null} ) : null}
</div> </AdminPageCard>
); );
})} })}
</div> </div>

View File

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

View File

@@ -1,6 +1,7 @@
"use client"; "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 { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -17,19 +18,18 @@ import {
manuallyProcessTransferOrder, manuallyProcessTransferOrder,
reverseTransferOrder, reverseTransferOrder,
} from "@/api/admin-wallet"; } from "@/api/admin-wallet";
import { ReconcileItemActions } from "@/modules/reconcile/reconcile-item-actions";
import { getAdminPlayers } from "@/api/admin-player"; import { getAdminPlayers } from "@/api/admin-player";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; 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 { 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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { import {
Table, Table,
TableBody, TableBody,
@@ -51,103 +51,15 @@ import type {
AdminReconcileItemsData, AdminReconcileItemsData,
AdminReconcileJobListData, AdminReconcileJobListData,
} from "@/types/api/admin-reconcile"; } from "@/types/api/admin-reconcile";
import {
getJobSummaryValue,
jobStatusLabel,
renderPeriodRange,
} from "@/modules/reconcile/reconcile-labels";
const MANAGE = ["prd.wallet_reconcile.manage"] as const; const MANAGE = ["prd.wallet_reconcile.manage"] as const;
/** 与后端 reconcile_type 对齐;扩展时在 API 与下拉同步增加 */
const RECONCILE_TYPE = "wallet_transfer" as const; 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 { export function ReconcileConsole(): React.ReactElement {
const { t } = useTranslation(["reconcile", "common"]); const { t } = useTranslation(["reconcile", "common"]);
const tRef = useTranslationRef(["reconcile", "common"]); const tRef = useTranslationRef(["reconcile", "common"]);
@@ -163,18 +75,20 @@ export function ReconcileConsole(): React.ReactElement {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10); const [perPage, setPerPage] = useState(10);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedJob, setSelectedJob] = useState<AdminReconcileJobRow | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [items, setItems] = useState<AdminReconcileItemsData | null>(null); const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
const [itemsPage, setItemsPage] = useState(1); const [itemsPage, setItemsPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(20);
const [itemsLoading, setItemsLoading] = useState(false); const [itemsLoading, setItemsLoading] = useState(false);
const [itemsFilter, setItemsFilter] = useState<ReconcileItemsFilter>("open");
const [scanOpen, setScanOpen] = useState(false);
const [dateFrom, setDateFrom] = useState(""); const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState(""); const [dateTo, setDateTo] = useState("");
const [playerSearch, setPlayerSearch] = useState(""); const [playerSearch, setPlayerSearch] = useState("");
const [playerResults, setPlayerResults] = useState<AdminPlayerRow[]>([]); const [playerResults, setPlayerResults] = useState<AdminPlayerRow[]>([]);
const [playerLoading, setPlayerLoading] = useState(false); const [playerLoading, setPlayerLoading] = useState(false);
const [playerPickerOpen, setPlayerPickerOpen] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null); const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
@@ -191,20 +105,20 @@ export function ReconcileConsole(): React.ReactElement {
} finally { } finally {
setJobsLoading(false); setJobsLoading(false);
} }
}, [page, perPage]); }, [page, perPage, tRef]);
useAsyncEffect(() => { useAsyncEffect(() => {
void loadJobs(); void loadJobs();
}, [page, perPage]); }, [page, perPage]);
const loadItems = useCallback(async () => { const loadItems = useCallback(async () => {
if (selectedId == null) { if (selectedJob == null) {
setItems(null); setItems(null);
return; return;
} }
setItemsLoading(true); setItemsLoading(true);
try { try {
const d = await getAdminReconcileJobItems(selectedId, { const d = await getAdminReconcileJobItems(selectedJob.id, {
page: itemsPage, page: itemsPage,
per_page: itemsPerPage, per_page: itemsPerPage,
}); });
@@ -215,11 +129,11 @@ export function ReconcileConsole(): React.ReactElement {
} finally { } finally {
setItemsLoading(false); setItemsLoading(false);
} }
}, [selectedId, itemsPage, itemsPerPage]); }, [selectedJob, itemsPage, itemsPerPage, tRef]);
useAsyncEffect(() => { useAsyncEffect(() => {
void loadItems(); void loadItems();
}, [selectedId, itemsPage, itemsPerPage]); }, [selectedJob, itemsPage, itemsPerPage]);
const loadPlayers = useCallback(async (keyword: string) => { const loadPlayers = useCallback(async (keyword: string) => {
const q = keyword.trim(); const q = keyword.trim();
@@ -239,15 +153,14 @@ export function ReconcileConsole(): React.ReactElement {
}, []); }, []);
useEffect(() => { useEffect(() => {
const q = playerSearch.trim(); if (!playerPickerOpen) {
if (q === "") {
return; return;
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
void loadPlayers(q); void loadPlayers(playerSearch);
}, 250); }, 250);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [loadPlayers, playerSearch]); }, [loadPlayers, playerPickerOpen, playerSearch]);
async function onCreate(): Promise<void> { async function onCreate(): Promise<void> {
if (!dateFrom.trim() || !dateTo.trim()) { if (!dateFrom.trim() || !dateTo.trim()) {
@@ -273,6 +186,7 @@ export function ReconcileConsole(): React.ReactElement {
: resp.item_count ?? 0; : resp.item_count ?? 0;
toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty")); toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty"));
setPage(1); setPage(1);
setScanOpen(false);
setDateFrom(""); setDateFrom("");
setDateTo(""); setDateTo("");
setPlayerSearch(""); 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 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 ( return (
<div className="flex w-full max-w-none flex-col gap-6"> <div className="flex w-full flex-col gap-4">
{canCreate ? ( <div className="flex flex-wrap items-center justify-between gap-3">
<Card className="admin-list-card"> <p className="text-sm text-muted-foreground">{t("workflowHint")}</p>
<CardHeader className="admin-list-header"> <Link
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle> href="/admin/wallet/transfer-orders?abnormal=1"
<p className="text-sm text-muted-foreground">{t("createHint")}</p> title={t("shortcutAbnormalTransfers")}
</CardHeader> className="inline-flex shrink-0 items-center gap-1 text-sm font-medium hover:underline"
<CardContent className="admin-list-content"> >
<div className="admin-list-toolbar"> {t("shortcutAbnormalTransfers")}
<div className="admin-list-field"> <ArrowUpRight className="size-3.5" />
<span className="text-sm font-medium leading-none sm:shrink-0">{t("reconcileType")}</span> </Link>
<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>
<div className="admin-list-field">
<Label htmlFor="rc-date-range" className="sm:shrink-0"> {canCreate ? (
{t("dateRange")} <div className="rounded-lg border border-border/60">
</Label> <button
<div className="min-w-0 w-full sm:w-60"> 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 <AdminDateRangeField
id="rc-date-range" id="rc-date-range"
label={t("dateRange")}
from={dateFrom} from={dateFrom}
to={dateTo} to={dateTo}
onRangeChange={({ from, to }) => { onRangeChange={({ from, to }) => {
@@ -366,23 +294,66 @@ export function ReconcileConsole(): React.ReactElement {
}} }}
/> />
</div> </div>
</div> <div className="min-w-[12rem] flex-1">
<div className="admin-list-field min-w-0 flex-1"> <Label htmlFor="rc-player-search" className="mb-1.5 block text-sm">
<Label htmlFor="rc-player-search" className="sm:shrink-0">
{t("playerSearch")} {t("playerSearch")}
</Label> </Label>
<Popover open={playerPickerOpen} onOpenChange={setPlayerPickerOpen} modal={false}>
<div className="flex gap-1.5">
<Input <Input
id="rc-player-search" id="rc-player-search"
className="w-full sm:w-52" className="h-8"
value={playerSearch} value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)} onChange={(e) => setPlayerSearch(e.target.value)}
placeholder={t("playerSearchPlaceholder")} placeholder={t("playerSearchPlaceholder")}
/> />
</div> <PopoverTrigger
<div className="admin-list-actions"> render={
<Button <Button
type="button" type="button"
className="w-full sm:w-auto" 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"
size="sm"
className="h-8"
disabled={submitting} disabled={submitting}
onClick={() => onClick={() =>
requestConfirm({ requestConfirm({
@@ -399,178 +370,107 @@ export function ReconcileConsole(): React.ReactElement {
{submitting ? t("submitting") : t("createTask")} {submitting ? t("submitting") : t("createTask")}
</Button> </Button>
</div> </div>
</div>
{selectedPlayer ? ( {selectedPlayer ? (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm"> <div className="flex items-center justify-between gap-2 rounded-md bg-muted/40 px-2.5 py-1.5 text-sm">
<div className="min-w-0 truncate font-medium text-foreground"> <span className="min-w-0 truncate">
{selectedPlayer.site_player_id} {selectedPlayer.site_player_id}
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""} {selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""} </span>
{` · ${selectedPlayer.site_code}`}
</div>
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="ghost"
className="h-7 px-2"
onClick={() => { onClick={() => {
setSelectedPlayer(null); setSelectedPlayer(null);
setPlayerSearch(""); setPlayerSearch("");
setPlayerResults([]);
}} }}
> >
{t("playerClear")} {t("playerClear")}
</Button> </Button>
</div> </div>
) : null} ) : 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> </div>
) : null} ) : null}
</CardContent> </div>
</Card>
) : ( ) : (
<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"> <div className="rounded-lg border border-border/60">
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4"> <div className="flex items-center justify-between gap-3 border-b border-border/60 px-3 py-2.5">
<div> <h2 className="text-sm font-semibold">{t("jobsTitle")}</h2>
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle> <Button type="button" variant="ghost" size="sm" className="h-8" disabled={jobsLoading} onClick={() => void loadJobs()}>
</div> <RefreshCw className={cn("size-3.5", jobsLoading && "animate-spin")} />
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
{t("refresh")} {t("refresh")}
</Button> </Button>
</CardHeader> </div>
<CardContent className="admin-list-content pt-4"> <div className="p-3 pt-2">
{jobsErr ? <p className="text-sm text-destructive">{jobsErr}</p> : null} {jobsErr ? <p className="mb-2 text-sm text-destructive">{jobsErr}</p> : null}
{jobs ? ( {jobs ? (
<> <>
<div className="admin-table-shell">
<Table id="reconcile-jobs-table"> <Table id="reconcile-jobs-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="sticky left-0 z-20 w-24 bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]"> <TableHead>{t("jobNo")}</TableHead>
{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>{t("status")}</TableHead>
<TableHead className="text-center">{t("itemCount")}</TableHead>
<TableHead className="text-center">{t("mismatchCount")}</TableHead> <TableHead className="text-center">{t("mismatchCount")}</TableHead>
<TableHead>{t("period")}</TableHead> <TableHead>{t("period")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead> <TableHead>{t("finishedAt")}</TableHead>
<TableHead>{t("createdAt")}</TableHead> <TableHead className="w-[6.5rem] text-center">{t("operate")}</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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{jobsLoading && !jobs ? ( {jobsLoading && jobs.items.length === 0 ? (
<AdminTableLoadingRow colSpan={10} /> <AdminTableLoadingRow colSpan={6} />
) : jobs.items.length === 0 ? ( ) : jobs.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" /> <AdminTableNoResourceRow colSpan={6} />
) : ( ) : (
jobs.items.map((row) => ( jobs.items.map((row) => {
<TableRow key={row.id}> const selected = selectedJob?.id === row.id;
<TableCell className="sticky left-0 z-10 bg-card tabular-nums shadow-[1px_0_0_rgba(226,232,240,0.9)]"> const mismatchCount = getJobSummaryValue(row.summary_json, "mismatch_count");
{row.id} return (
</TableCell> <TableRow key={row.id} className={cn(selected && "bg-primary/[0.04]")}>
<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)]"> <TableCell className="font-mono text-xs">{row.job_no}</TableCell>
{row.job_no}
</TableCell>
<TableCell className="text-sm">{reconcileTypeLabel(row.reconcile_type, t)}</TableCell>
<TableCell> <TableCell>
<AdminStatusBadge status={row.status}> <AdminStatusBadge status={row.status}>
{jobStatusLabel(row.status, t)} {jobStatusLabel(row.status, t)}
</AdminStatusBadge> </AdminStatusBadge>
</TableCell> </TableCell>
<TableCell className="text-center tabular-nums">
{getJobSummaryValue(row.summary_json, "item_count")}
</TableCell>
<TableCell className="text-center tabular-nums"> <TableCell className="text-center tabular-nums">
<span <span
className={cn( className={cn(
getJobSummaryValue(row.summary_json, "mismatch_count") > 0 mismatchCount > 0 ? "font-medium text-amber-700" : "text-muted-foreground",
? "font-medium text-amber-700"
: "text-muted-foreground",
)} )}
> >
{getJobSummaryValue(row.summary_json, "mismatch_count")} {mismatchCount}
</span> </span>
</TableCell> </TableCell>
<TableCell className="max-w-[16rem] text-xs text-muted-foreground"> <TableCell className="max-w-[14rem] text-xs text-muted-foreground">
<span className="line-clamp-2">
{renderPeriodRange(row, formatTs)} {renderPeriodRange(row, formatTs)}
</span>
</TableCell> </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)} {formatTs(row.finished_at)}
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground"> <TableCell className="text-center">
{formatTs(row.created_at)} <Button
</TableCell> type="button"
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(226,232,240,0.9)]"> size="sm"
<AdminRowActionsMenu variant={selected ? "default" : "outline"}
actions={[ className="h-8"
{ onClick={() => openJobDetail(row)}
key: "view-details", >
label: t("viewDetails"), {t("viewDetails")}
icon: Eye, </Button>
onClick: () => {
setSelectedId(row.id);
setItemsPage(1);
setDetailOpen(true);
},
},
]}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) );
})
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div>
{jm ? ( {jm ? (
<div className="mt-3 border-t border-border/60 pt-2">
<AdminListPaginationFooter <AdminListPaginationFooter
selectId="reconcile-jobs-per-page" selectId="reconcile-jobs-per-page"
total={jm.total} total={jm.total}
@@ -584,167 +484,46 @@ export function ReconcileConsole(): React.ReactElement {
}} }}
onPageChange={setPage} onPageChange={setPage}
/> />
</div>
) : null} ) : null}
</> </>
) : jobsLoading ? (
<AdminNoResourceState message={t("loadFailed")} />
) : null} ) : null}
</CardContent> </div>
</Card> </div>
<Dialog <Dialog
open={detailOpen} open={selectedJob != null}
onOpenChange={(open) => { onOpenChange={(open) => {
setDetailOpen(open);
if (!open) { if (!open) {
setSelectedId(null); closeJobDetail();
setItems(null);
} }
}} }}
> >
<DialogContent <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 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 ? ( {selectedJob ? (
<AdminStatusBadge status={selectedJob.status}> <ReconcileJobDetailPanel
{jobStatusLabel(selectedJob.status, t)} job={selectedJob}
</AdminStatusBadge> items={items}
) : ( itemsLoading={itemsLoading}
"—" itemsFilter={itemsFilter}
)} onItemsFilterChange={setItemsFilter}
</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} canWriteWallet={canWriteWallet}
busy={actionBusy} actionBusy={actionBusy}
onCompleteCredit={(transferNo) => void runTransferAction(transferNo, "complete_credit")} onCompleteCredit={(transferNo) => void runTransferAction(transferNo, "complete_credit")}
onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")} onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")}
onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")} onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")}
onItemsPageChange={setItemsPage}
onItemsPerPageChange={setItemsPerPage}
/> />
</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}
</>
) : null}
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog /> <ConfirmDialog />
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { RotateCcw, Wrench } from "lucide-react"; import { ExternalLink, RotateCcw, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
@@ -19,6 +19,7 @@ type ReconcileItemActionsProps = {
row: AdminReconcileItemRow; row: AdminReconcileItemRow;
canWriteWallet: boolean; canWriteWallet: boolean;
busy: boolean; busy: boolean;
compact?: boolean;
onCompleteCredit: (transferNo: string) => void; onCompleteCredit: (transferNo: string) => void;
onReverse: (transferNo: string) => void; onReverse: (transferNo: string) => void;
onManualProcess: (transferNo: string) => void; onManualProcess: (transferNo: string) => void;
@@ -28,6 +29,7 @@ export function ReconcileItemActions({
row, row,
canWriteWallet, canWriteWallet,
busy, busy,
compact = false,
onCompleteCredit, onCompleteCredit,
onReverse, onReverse,
onManualProcess, onManualProcess,
@@ -45,12 +47,9 @@ export function ReconcileItemActions({
can_manually_process: row.can_manually_process, can_manually_process: row.can_manually_process,
}; };
return ( const hasWalletAction = transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet);
<div className="flex flex-wrap gap-2">
{transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet) ? ( const menuActions = [
<AdminRowActionsMenu
busy={busy}
actions={[
{ {
key: "complete", key: "complete",
label: t("completeCredit", { ns: "wallet" }), label: t("completeCredit", { ns: "wallet" }),
@@ -72,9 +71,36 @@ export function ReconcileItemActions({
hidden: !canReverseTransferOrder(actionRow, canWriteWallet), hidden: !canReverseTransferOrder(actionRow, canWriteWallet),
onClick: () => onReverse(transferNo), onClick: () => onReverse(transferNo),
}, },
]} {
/> key: "transfer",
) : null} 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">
{hasWalletAction ? <AdminRowActionsMenu busy={busy} actions={menuActions.filter((a) => a.key !== "transfer" && a.key !== "txn")} /> : null}
{transferNo !== "" ? ( {transferNo !== "" ? (
<Link <Link
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`} href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`}

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"; "use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { ArrowUpRight, Database, FileDown, FileSpreadsheet, Search } from "lucide-react";
CalendarDays,
Database,
FileDown,
FileSpreadsheet,
ListFilter,
Search,
ShieldAlert,
ShieldCheck,
Ticket,
Users,
WalletCards,
} from "lucide-react";
import { getAdminAuditLogs } from "@/api/admin-audit"; 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 { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref"; 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 { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { drawStatusLabel } from "@/modules/draws/draw-display"; import { drawStatusLabel } from "@/modules/draws/draw-display";
import { getAdminPlayers } from "@/api/admin-player"; import { getAdminPlayers } from "@/api/admin-player";
@@ -42,25 +30,17 @@ import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
import { getAdminUsers } from "@/api/admin-users"; import { getAdminUsers } from "@/api/admin-users";
import { getAdminTransferOrders } from "@/api/admin-wallet"; import { getAdminTransferOrders } from "@/api/admin-wallet";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { import { PRD_REPORT_EXPORT } from "@/lib/admin-prd";
PRD_AUDIT_VIEW,
PRD_REPORT_EXPORT,
PRD_REPORT_VIEW,
PRD_RISK_ACCESS_ANY,
PRD_WALLET_TRANSFER_ACCESS_ANY,
} from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -68,559 +48,87 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
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 { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminAuditLogRow } from "@/types/api/admin-audit"; import { getAdminDraws } from "@/api/admin-draws";
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";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit"; import { ReportPreviewTable } from "@/modules/reports/report-preview-tables";
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period"; import {
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period"; CATEGORY_SHORTCUT_HREF,
type ExportFormat = "csv" | "excel"; REPORT_CATEGORY_ORDER,
type ExportCell = string | number | null; REPORT_DEFINITIONS,
type ExportRow = Record<string, ExportCell>; type FieldKey,
type SearchKind = "draw" | "player" | "operator"; 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 = export type { ReportCategory } from "@/modules/reports/reports-definitions";
| "draw_profit"
| "daily_profit"
| "player_win_loss"
| "player_transfer"
| "hot_number_risk"
| "play_dimension"
| "sold_out_number"
| "admin_audit";
type ReportDefinition = { type ReportsConsoleProps = {
key: ReportKey; initialCategory?: ReportCategory;
category: ReportCategory;
icon: typeof FileSpreadsheet;
filterKind: FilterKind;
scope: string;
fields: FieldKey[];
connected: boolean;
requiredAny: readonly string[];
}; };
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const; export function ReportsConsole({ initialCategory = "profit" }: ReportsConsoleProps) {
const { t } = useTranslation(["reports", "common"]);
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"]);
const profile = useAdminProfile(); 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 permissionSlugs = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const canExportReports = adminHasAnyPermission(permissionSlugs, [PRD_REPORT_EXPORT]);
useAdminCurrencyCatalog(); useAdminCurrencyCatalog();
useAdminPlayTypeCatalog(); useAdminPlayTypeCatalog();
const playCodeLabel = useAdminPlayCodeLabel(); const playOptions = useCachedPlayTypeOptions();
const formatTs = useAdminDateTimeFormatter(); const tRef = useTranslationRef(["reports", "common"]);
const filteredReports = useMemo(() => { const searchParams = useSearchParams();
const visible = REPORTS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny)); const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
return initialCategory ? visible.filter((report) => report.category === initialCategory) : visible;
}, [initialCategory, permissionSlugs]); const visibleReports = useMemo(
const [selectedKey, setSelectedKey] = useState<ReportKey>( () => REPORT_DEFINITIONS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny)),
filteredReports[0]?.key ?? REPORTS[0].key, [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 [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null)); const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
const [result, setResult] = useState<ReportResult | null>(null); const [result, setResult] = useState<ReportResult | null>(null);
@@ -630,128 +138,26 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const [perPage, setPerPage] = useState(20); const [perPage, setPerPage] = useState(20);
const [exporting, setExporting] = useState<ExportFormat | null>(null); const [exporting, setExporting] = useState<ExportFormat | null>(null);
const [search, setSearch] = useState<SearchState>(emptySearch); 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(() => { useEffect(() => {
if (!filteredReports.some((report) => report.key === selectedKey)) { if (categoryReports.length === 0) {
setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key); return;
} }
}, [filteredReports, selectedKey]); if (!categoryReports.some((report) => report.key === selectedKey)) {
setSelectedKey(categoryReports[0].key);
}
}, [categoryReports, selectedKey]);
const pageScopedLabel = useCallback( const pageScopedLabel = useCallback(
(statKey: string) => t(`preview.stats.${statKey}`), (statKey: string) => t(`preview.stats.${statKey}`),
[t], [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 exportFileBase = useMemo(() => {
const segments: string[] = [selectedReport.key]; const segments: string[] = [selectedReport.key];
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim()); if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
@@ -795,7 +201,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}, [search.open, search.query, loadSearchOptions]); }, [search.open, search.query, loadSearchOptions]);
const queryReport = useCallback(async () => { const queryReport = useCallback(async () => {
if (!canViewReports) { if (!canQuerySelected) {
return; return;
} }
setLoading(true); setLoading(true);
@@ -816,8 +222,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
rows: drawRowsFromSummary(summary), rows: drawRowsFromSummary(summary),
meta: null, meta: null,
summary: [ 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"), label: t("preview.stats.houseGross"),
value: formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code), value: formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code),
@@ -829,12 +233,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
break; break;
} }
case "daily_profit": { case "daily_profit": {
const payload = await getAdminReportDailyProfit( const payload = await getAdminReportDailyProfit(reportListParams(filters, page, perPage));
reportListParams(filters, page, perPage),
);
const currencyCode = resolveDisplayCurrency(payload.currency_code); const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode); 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({ setResult({
key: "daily_profit", key: "daily_profit",
raw: payload.items, raw: payload.items,
@@ -845,38 +253,28 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
break; break;
} }
case "player_win_loss": { case "player_win_loss": {
const payload = await getAdminReportPlayerWinLoss( const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
reportListParams(filters, page, perPage),
);
const currencyCode = resolveDisplayCurrency(payload.currency_code); const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode); setDisplayCurrency(currencyCode);
const rows = payload.items.map((item) => ({ const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
setResult({
key: "player_win_loss",
raw: payload.items,
rows: payload.items.map((item) => ({
player_id: item.player_id, player_id: item.player_id,
username: item.username, username: item.username,
total_bet_minor: item.total_bet_minor, total_bet_minor: item.total_bet_minor,
total_payout_minor: item.total_payout_minor, total_payout_minor: item.total_payout_minor,
net_win_loss_minor: item.net_win_loss_minor, net_win_loss_minor: item.net_win_loss_minor,
})); })),
setResult({
key: "player_win_loss",
raw: payload.items,
rows,
meta: metaFromList(payload.meta), meta: metaFromList(payload.meta),
summary: [ summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) }, { label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ {
label: pageScopedLabel("houseGross"), label: pageScopedLabel("houseGross"),
value: formatPlainMoney( value: formatPlainMoney(houseGross, currencyCode),
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0), tone: houseGross >= 0 ? "good" : "bad",
currencyCode,
),
tone: (() => {
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
return houseGross >= 0 ? "good" : "bad";
})(),
}, },
{ label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) },
], ],
}); });
break; break;
@@ -897,7 +295,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
payload.page, payload.page,
payload.per_page, payload.per_page,
t, t,
pageScopedLabel,
); );
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code)); setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
setResult({ setResult({
@@ -920,45 +317,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}); });
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage }); const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
setDisplayCurrency(resolveDisplayCurrency(detail.currency_code)); 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({ setResult({
key: "hot_number_risk", key: "hot_number_risk",
raw: detail, raw: detail,
rows, rows: [],
meta: metaFromList(detail.logs.meta), meta: metaFromList(detail.logs.meta),
summary: [ 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"),
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" }, 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) }, { label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
], ],
}); });
@@ -970,42 +339,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
drawNoNotFound: (drawNo) => drawNoNotFound: (drawNo) =>
tRef.current("validation.drawNoNotFound", { ns: "reports", 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)); 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({ setResult({
key: "sold_out_number", key: "sold_out_number",
raw: payload.items, raw: payload.items,
rows, rows: payload.items.map((item) => ({ normalized_number: item.normalized_number })),
meta: metaFromList(payload.meta), meta: metaFromList(payload.meta),
summary: [ 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.drawNo"), value: payload.draw_no },
{ label: t("preview.stats.currency"), value: payload.currency_code || "-" },
], ],
}); });
break; break;
} }
case "play_dimension": { case "play_dimension": {
const payload = await getAdminReportPlayDimension( const payload = await getAdminReportPlayDimension(reportListParams(filters, page, perPage));
reportListParams(filters, page, perPage),
);
const currencyCode = resolveDisplayCurrency(payload.currency_code); const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode); setDisplayCurrency(currencyCode);
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode); const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, currencyCode);
setResult({ setResult({
key: "play_dimension", key: "play_dimension",
raw: payload.items, raw: payload.items,
@@ -1025,28 +386,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
start_date: filters.dateFrom || undefined, start_date: filters.dateFrom || undefined,
end_date: filters.dateTo || 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({ setResult({
key: "admin_audit", key: "admin_audit",
raw: payload.items, raw: payload.items,
rows, rows: payload.items.map((item) => ({ id: item.id })),
meta: metaFromList(payload.meta), meta: metaFromList(payload.meta),
summary: [ summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) }, { 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"),
{ label: t("preview.stats.operators"), value: String(new Set(payload.items.map((item) => item.operator_id)).size) }, value: String(new Set(payload.items.map((item) => item.operator_id)).size),
},
], ],
}); });
break; break;
@@ -1061,14 +411,12 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [canViewReports, filters, page, perPage, selectedReport]); }, [canQuerySelected, filters, page, perPage, pageScopedLabel, selectedReport.key, t, tRef]);
useEffect(() => { useEffect(() => {
queueMicrotask(() => {
setResult(null); setResult(null);
setError(null); setError(null);
setPage(1); setPage(1);
});
}, [selectedKey]); }, [selectedKey]);
useEffect(() => { useEffect(() => {
@@ -1076,25 +424,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
...prev, ...prev,
drawNo: drawNoFromUrl || prev.drawNo, 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"); setSelectedKey("draw_profit");
} }
}, [drawNoFromUrl, filteredReports]); }, [drawNoFromUrl, visibleReports]);
useEffect(() => { useEffect(() => {
queueMicrotask(() => { if (!result || result.key !== selectedReport.key || !selectedReport.connected) {
setResult(null); return;
setError(null);
setPage(1);
});
}, []);
useEffect(() => {
if (result && result.key === selectedReport.key && selectedReport.connected) {
queueMicrotask(() => {
void queryReport();
});
} }
void queryReport();
}, [page, perPage]); }, [page, perPage]);
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void { function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
@@ -1152,26 +491,23 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const open = search.open === kind; const open = search.open === kind;
return ( return (
<div className="grid gap-1.5"> <div className="min-w-[12rem] flex-1">
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label> <Label className="sr-only" htmlFor={`report-${kind}`}>
{t(`fields.${labelKey}`)}
</Label>
<Popover <Popover
open={open} open={open}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
setSearch((prev) => setSearch((prev) =>
nextOpen nextOpen ? { ...prev, open: kind, query: value } : emptySearch,
? {
...prev,
open: kind,
query: value,
}
: emptySearch,
); );
}} }}
modal={false} modal={false}
> >
<div className="flex gap-2"> <div className="flex gap-1.5">
<Input <Input
id={`report-${kind}`} id={`report-${kind}`}
className="h-8"
value={value} value={value}
onChange={(e) => { onChange={(e) => {
const next = e.target.value; const next = e.target.value;
@@ -1185,9 +521,18 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}} }}
placeholder={t(`placeholders.${labelKey}`)} placeholder={t(`placeholders.${labelKey}`)}
/> />
<PopoverTrigger render={<Button type="button" variant="outline" className="shrink-0" aria-label={t("searchPicker.open")} />}> <PopoverTrigger
<Search data-icon="inline-start" /> render={
{t("searchPicker.select")} <Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 px-2"
aria-label={t("searchPicker.open")}
/>
}
>
<Search className="size-3.5" />
</PopoverTrigger> </PopoverTrigger>
</div> </div>
<PopoverContent <PopoverContent
@@ -1202,11 +547,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))} onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))}
/> />
<div className="mt-2 max-h-64 overflow-auto"> <div className="mt-2 max-h-64 overflow-auto">
{search.loading ? ( {search.loading ? <AdminLoadingInline className="py-2" /> : null}
<AdminLoadingInline className="py-2" /> {!search.loading && kind === "draw"
) : null} ? search.draws.map((item) => (
{!search.loading && kind === "draw" ? (
search.draws.map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
@@ -1217,12 +560,15 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}} }}
> >
<span className="font-medium">{item.draw_no}</span> <span className="font-medium">{item.draw_no}</span>
<DrawStatusBadge status={item.status} label={drawStatusLabel(item.status, t)} /> <DrawStatusBadge
status={item.status}
label={drawStatusLabel(item.status, t)}
/>
</button> </button>
)) ))
) : null} : null}
{!search.loading && kind === "player" ? ( {!search.loading && kind === "player"
search.players.map((item) => ( ? search.players.map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
@@ -1236,13 +582,15 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
setSearch(emptySearch); setSearch(emptySearch);
}} }}
> >
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname, item.site_player_id)}</span> <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> <span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
</button> </button>
)) ))
) : null} : null}
{!search.loading && kind === "operator" ? ( {!search.loading && kind === "operator"
search.operators.map((item) => ( ? search.operators.map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
@@ -1256,11 +604,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
setSearch(emptySearch); setSearch(emptySearch);
}} }}
> >
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname)}</span> <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> <span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
</button> </button>
)) ))
) : null} : null}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -1294,15 +644,21 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
} }
if (field === "play") { if (field === "play") {
return ( return (
<div key={field} className="grid gap-1.5"> <div key={field} className="min-w-[10rem] flex-1">
<Label htmlFor="report-play">{t("fields.play")}</Label> <Label className="sr-only" htmlFor="report-play">
{t("fields.play")}
</Label>
<Select <Select
modal={false} modal={false}
value={filters.play || "__none__"} value={filters.play || "__none__"}
onValueChange={(value) => updateFilter("play", value === "__none__" ? "" : String(value))} onValueChange={(value) => updateFilter("play", value === "__none__" ? "" : String(value))}
> >
<SelectTrigger id="report-play" className="h-8 w-full"> <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> </SelectTrigger>
<SelectContent align="start" sideOffset={6}> <SelectContent align="start" sideOffset={6}>
<SelectItem value="__none__">{t("filterAll")}</SelectItem> <SelectItem value="__none__">{t("filterAll")}</SelectItem>
@@ -1316,12 +672,14 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
</div> </div>
); );
} }
return ( return (
<div key={field} className="grid gap-1.5"> <div key={field} className="min-w-[8rem] flex-1">
<Label htmlFor={`report-${field}`}>{t(`fields.${field}`)}</Label> <Label className="sr-only" htmlFor={`report-${field}`}>
{t(`fields.${field}`)}
</Label>
<Input <Input
id={`report-${field}`} id={`report-${field}`}
className="h-8"
value={filters.number} value={filters.number}
onChange={(e) => updateFilter("number", e.target.value)} onChange={(e) => updateFilter("number", e.target.value)}
placeholder={t(`placeholders.${field}`)} placeholder={t(`placeholders.${field}`)}
@@ -1330,321 +688,141 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
); );
}; };
const renderTable = () => { const stats = headlineStats(result, selectedReport.key);
if (!selectedReport.connected) { const shortcutHref = CATEGORY_SHORTCUT_HREF[activeCategory];
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} />
);
}
if (result.key === "draw_profit") { if (visibleReports.length === 0) {
const summary = result.raw; return <AdminNoResourceState message={t("empty")} />;
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;
};
return ( return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4"> <div className="flex w-full flex-col gap-4">
{filteredReports.length === 0 ? ( <AdminSubnav aria-label={t("categories.all")}>
<AdminNoResourceState message={t("empty")} /> {categoriesWithReports.map((category) => (
) : ( <AdminSubnavLink
<> key={category}
<Card className="admin-list-card"> href={`/admin/reports/${category}`}
<CardHeader className="admin-list-header pb-3"> active={activeCategory === category}
<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))}> {t(`categories.${category}`)}
<Icon className="size-3.5" aria-hidden /> </AdminSubnavLink>
</span> ))}
<span className="truncate">{t(`items.${report.key}.title`)}</span> </AdminSubnav>
</button>
); <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> </div>
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
</div> <div className="rounded-lg border border-border/60 p-3">
</CardHeader> <div className="flex flex-wrap items-end gap-2">
<CardContent className="space-y-3 pt-0">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{selectedReport.fields.map(renderField)} {selectedReport.fields.map(renderField)}
</div> <div className="flex flex-wrap items-center gap-1.5 sm:ml-auto">
<div className="flex justify-end gap-2 border-t border-border/60 pt-3"> <Button type="button" variant="ghost" size="sm" className="h-8" onClick={resetFilters}>
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
{t("reset")} {t("reset")}
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
disabled={!canViewReports || !selectedReport.connected || loading} className="h-8"
disabled={!canQuerySelected || !selectedReport.connected || loading}
onClick={() => { onClick={() => {
setPage(1); setPage(1);
void queryReport(); void queryReport();
}} }}
> >
<Database data-icon="inline-start" /> <Database className="size-3.5" data-icon="inline-start" />
{loading ? t("querying") : t("query")} {loading ? t("querying") : t("query")}
</Button> </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>
<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">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8"
disabled={!canExportReports || exporting !== null} disabled={!canExportReports || exporting !== null}
onClick={() => void exportReport("csv")} onClick={() => void exportReport("csv")}
> >
<FileDown data-icon="inline-start" /> <FileDown className="size-3.5" data-icon="inline-start" />
{t("formats.csv")} {t("formats.csv")}
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline"
size="sm" size="sm"
className="h-8"
disabled={!canExportReports || exporting !== null} disabled={!canExportReports || exporting !== null}
onClick={() => void exportReport("excel")} onClick={() => void exportReport("excel")}
> >
<FileSpreadsheet data-icon="inline-start" /> <FileSpreadsheet className="size-3.5" data-icon="inline-start" />
{t("formats.excel")} {t("formats.excel")}
</Button> </Button>
</div> </div>
</CardHeader> </div>
<CardContent className="space-y-3 pt-3"> </div>
<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>
{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 ? ( {result?.meta ? (
<div className="border-t border-border/60 px-3 py-2">
<AdminListPaginationFooter <AdminListPaginationFooter
selectId="reports-preview-per-page" selectId="reports-preview-per-page"
total={result.meta.total} total={result.meta.total}
@@ -1658,11 +836,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}} }}
onPageChange={setPage} onPageChange={setPage}
/> />
</div>
) : null} ) : null}
</CardContent> </div>
</Card>
</>
)}
</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 ( return (
<RulesPageShell> <RulesPageShell>
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}> <AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
<ConfigDocPage <ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="pt-2">
title={t("nav.rulesOddsTitle")}
description={t("nav.rulesOddsDescriptionShort")}
contentClassName="pt-2"
>
<OddsConfigDocScreen embedded mergedLayout workspace={workspace} /> <OddsConfigDocScreen embedded mergedLayout workspace={workspace} />
</ConfigDocPage> </ConfigDocPage>
</AdminPermissionGate> </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 = { type CurrencyFormState = {
code: string; code: string;
name: string; name: string;
decimal_places: string;
is_enabled: boolean; is_enabled: boolean;
is_bettable: boolean; is_bettable: boolean;
}; };
@@ -54,7 +53,6 @@ type CurrencyFormState = {
const EMPTY_FORM: CurrencyFormState = { const EMPTY_FORM: CurrencyFormState = {
code: "", code: "",
name: "", name: "",
decimal_places: "2",
is_enabled: true, is_enabled: true,
is_bettable: false, is_bettable: false,
}; };
@@ -63,7 +61,6 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
return { return {
code: row.code, code: row.code,
name: row.name, name: row.name,
decimal_places: String(row.decimal_places),
is_enabled: row.is_enabled, is_enabled: row.is_enabled,
is_bettable: row.is_enabled && row.is_bettable, is_bettable: row.is_enabled && row.is_bettable,
}; };
@@ -138,7 +135,6 @@ export function CurrencySettingsPanel() {
async function handleSubmit(): Promise<void> { async function handleSubmit(): Promise<void> {
const payload = { const payload = {
name: form.name.trim(), name: form.name.trim(),
decimal_places: Number.parseInt(form.decimal_places || "0", 10),
is_enabled: form.is_enabled, is_enabled: form.is_enabled,
is_bettable: form.is_enabled && form.is_bettable, 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); setSaving(true);
try { try {
if (mode === "create") { if (mode === "create") {
@@ -229,7 +220,6 @@ export function CurrencySettingsPanel() {
<TableRow> <TableRow>
<TableHead className="whitespace-nowrap">{t("currencies.table.code", { ns: "config" })}</TableHead> <TableHead className="whitespace-nowrap">{t("currencies.table.code", { ns: "config" })}</TableHead>
<TableHead>{t("currencies.table.name", { 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.enabled", { ns: "config" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { 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> <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> <TableBody>
{loading ? ( {loading ? (
<TableRow> <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" })} {t("currencies.loading", { ns: "config" })}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<AdminTableNoResourceRow colSpan={6} cellClassName="text-center" /> <AdminTableNoResourceRow colSpan={5} cellClassName="text-center" />
) : ( ) : (
items.map((row) => ( items.map((row) => (
<TableRow key={row.code}> <TableRow key={row.code}>
<TableCell className="font-mono">{row.code}</TableCell> <TableCell className="font-mono">{row.code}</TableCell>
<TableCell>{row.name}</TableCell> <TableCell>{row.name}</TableCell>
<TableCell>{row.decimal_places}</TableCell>
<TableCell> <TableCell>
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}> <AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled {row.is_enabled
@@ -329,21 +318,6 @@ export function CurrencySettingsPanel() {
/> />
</div> </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="flex items-center justify-between rounded-xl border border-border/70 p-3">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium">{t("currencies.form.enabled", { ns: "config" })}</p> <p className="text-sm font-medium">{t("currencies.form.enabled", { ns: "config" })}</p>

View File

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

View File

@@ -6,43 +6,28 @@ import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions"; 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 { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { DRAW_KEYS } from "@/modules/settings/settings-keys"; import { DRAW_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings"; import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd"; import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
interface DrawDraft { interface DrawDraft {
defaultCurrency: string;
drawIntervalMinutes: string;
drawBettingWindowSeconds: string;
drawCloseBeforeDrawSeconds: string;
drawBufferDrawsAhead: string;
requireManualReview: boolean; requireManualReview: boolean;
cooldownMinutes: string; cooldownMinutes: string;
} }
const INITIAL: DrawDraft = { const INITIAL: DrawDraft = {
defaultCurrency: "NPR",
drawIntervalMinutes: "5",
drawBettingWindowSeconds: "270",
drawCloseBeforeDrawSeconds: "30",
drawBufferDrawsAhead: "8",
requireManualReview: false, requireManualReview: false,
cooldownMinutes: "15", cooldownMinutes: "15",
}; };
function fromKv(kv: Record<string, unknown>): DrawDraft { function fromKv(kv: Record<string, unknown>): DrawDraft {
return { 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), requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15), 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[] { function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
const items: AdminSettingBatchItem[] = []; const items: AdminSettingBatchItem[] = [];
const push = (key: string, value: unknown, changed: boolean) => {
if (changed) {
items.push({ key, value });
}
};
push( if (draft.requireManualReview !== saved.requireManualReview) {
DRAW_KEYS.DEFAULT_CURRENCY, items.push({ key: DRAW_KEYS.REQUIRE_MANUAL_REVIEW, value: draft.requireManualReview });
draft.defaultCurrency.trim().toUpperCase() || "NPR", }
draft.defaultCurrency !== saved.defaultCurrency, if (draft.cooldownMinutes !== saved.cooldownMinutes) {
); items.push({
push( key: DRAW_KEYS.COOLDOWN_MINUTES,
DRAW_KEYS.DRAW_INTERVAL_MINUTES, value: Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5), });
draft.drawIntervalMinutes !== saved.drawIntervalMinutes, }
);
push(
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
draft.drawBettingWindowSeconds !== saved.drawBettingWindowSeconds,
);
push(
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
draft.drawCloseBeforeDrawSeconds !== saved.drawCloseBeforeDrawSeconds,
);
push(
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
draft.drawBufferDrawsAhead !== saved.drawBufferDrawsAhead,
);
push(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview, draft.requireManualReview !== saved.requireManualReview);
push(
DRAW_KEYS.COOLDOWN_MINUTES,
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
draft.cooldownMinutes !== saved.cooldownMinutes,
);
return items; return items;
} }
@@ -114,102 +72,23 @@ export function DrawSettingsPanel() {
description={t("system.sections.drawDescription", { ns: "config" })} description={t("system.sections.drawDescription", { ns: "config" })}
> >
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm"> <div className="overflow-hidden rounded-xl border border-border/70 bg-card 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"> <SettingsToggleRow
<Label className="font-medium cursor-pointer" onClick={() => updateField("requireManualReview", !draft.requireManualReview)}>{t("system.fields.manualReview", { ns: "config" })}</Label> label={t("system.fields.manualReview", { ns: "config" })}
<Switch hint={t("system.hints.manualReview", { ns: "config" })}
checked={draft.requireManualReview} checked={draft.requireManualReview}
disabled={!canManage || loading || saving} disabled={!canManage || loading || saving}
aria-label={t("system.fields.manualReview", { ns: "config" })}
onCheckedChange={(value) => updateField("requireManualReview", value)} onCheckedChange={(value) => updateField("requireManualReview", value)}
/> />
</div> </div>
</div>
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3"> <div className="max-w-sm space-y-2">
<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"> <Label htmlFor="cooldown-minutes" className="text-muted-foreground">
{t("system.fields.cooldownMinutes", { ns: "config" })} {t("system.fields.cooldownMinutes", { ns: "config" })}
</Label> </Label>
<p className="text-sm text-muted-foreground">
{t("system.hints.cooldownMinutes", { ns: "config" })}
</p>
<Input <Input
id="cooldown-minutes" id="cooldown-minutes"
type="number" type="number"
@@ -222,7 +101,6 @@ export function DrawSettingsPanel() {
className="h-10 bg-background/50 transition-colors focus:bg-background" className="h-10 bg-background/50 transition-colors focus:bg-background"
/> />
</div> </div>
</div>
<SettingsSectionActions <SettingsSectionActions
dirty={dirty} dirty={dirty}

View File

@@ -6,11 +6,10 @@ import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions"; 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 { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys"; import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings"; 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 { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_PAYOUT_MANAGE } from "@/lib/admin-prd"; import { PRD_PAYOUT_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
@@ -82,50 +81,36 @@ export function SettlementSettingsPanel() {
description={t("system.sections.settlementDescription", { ns: "config" })} description={t("system.sections.settlementDescription", { ns: "config" })}
> >
<div className="space-y-5"> <div className="space-y-5">
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm"> <div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm divide-y divide-border/50">
<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"> <SettingsToggleRow
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoSettlement", !draft.autoSettlement)}>{t("system.fields.autoSettlement", { ns: "config" })}</Label> label={t("system.fields.autoSettlement", { ns: "config" })}
<Switch hint={t("system.hints.autoSettlement", { ns: "config" })}
checked={draft.autoSettlement} checked={draft.autoSettlement}
disabled={loading || saving} disabled={loading || saving}
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
onCheckedChange={(value) => updateField("autoSettlement", value)} onCheckedChange={(value) => updateField("autoSettlement", value)}
/> />
</div> <SettingsToggleRow
label={t("system.fields.autoApprove", { ns: "config" })}
<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"> hint={t("system.hints.autoApprove", { ns: "config" })}
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoApprove", !draft.autoApprove)}>{t("system.fields.autoApprove", { ns: "config" })}</Label>
<Switch
checked={draft.autoApprove} checked={draft.autoApprove}
disabled={loading || saving} disabled={loading || saving}
aria-label={t("system.fields.autoApprove", { ns: "config" })}
onCheckedChange={(value) => updateField("autoApprove", value)} onCheckedChange={(value) => updateField("autoApprove", value)}
/> />
</div> <SettingsToggleRow
label={t("system.fields.autoPayout", { ns: "config" })}
<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"> hint={t("system.hints.autoPayout", { ns: "config" })}
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoPayout", !draft.autoPayout)}>{t("system.fields.autoPayout", { ns: "config" })}</Label>
<Switch
checked={draft.autoPayout} checked={draft.autoPayout}
disabled={loading || saving} disabled={loading || saving}
aria-label={t("system.fields.autoPayout", { ns: "config" })}
onCheckedChange={(value) => updateField("autoPayout", value)} onCheckedChange={(value) => updateField("autoPayout", value)}
/> />
</div> <SettingsToggleRow
label={t("system.fields.applyRebateToPayout", { ns: "config" })}
<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"> hint={t("system.hints.applyRebateToPayout", { ns: "config" })}
<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} checked={draft.applyRebateToPayout}
disabled={loading || saving} disabled={loading || saving}
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
onCheckedChange={(value) => updateField("applyRebateToPayout", value)} onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
/> />
</div> </div>
</div>
<SettingsSectionActions <SettingsSectionActions
dirty={dirty} dirty={dirty}

View File

@@ -4,11 +4,6 @@ export const FRONTEND_GROUP = "frontend";
export const WALLET_GROUP = "wallet"; export const WALLET_GROUP = "wallet";
export const DRAW_KEYS = { 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", REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes", COOLDOWN_MINUTES: "draw.cooldown_minutes",
} as const; } as const;

View File

@@ -13,6 +13,8 @@ function SystemSettingsContent() {
return ( return (
<div className="flex w-full max-w-none flex-col gap-6"> <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 /> <DrawSettingsPanel />
<SettlementSettingsPanel /> <SettlementSettingsPanel />

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { ArrowRight, Eye } from "lucide-react"; import { ArrowRight, Banknote, Check, Eye } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement"; import type { SettlementBillRow } from "@/api/admin-agent-settlement";
@@ -16,9 +16,8 @@ import {
formatSignedSettlementMoney, formatSignedSettlementMoney,
} from "@/modules/settlement/settlement-signed-money"; } from "@/modules/settlement/settlement-signed-money";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display";
describeBillPaymentDirection, import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
} from "@/modules/settlement/settlement-bill-display";
import { import {
formatPlatformPartyLabel, formatPlatformPartyLabel,
SettlementDashCell, SettlementDashCell,
@@ -44,7 +43,11 @@ type SettlementBillsTableProps = {
currencyCode: string; currencyCode: string;
billTypeFilter?: BillTypeFilter; billTypeFilter?: BillTypeFilter;
emptyMessage?: string; emptyMessage?: string;
canOperate?: boolean;
boundAgentId?: number | null;
onOpenDetail: (billId: number) => void; onOpenDetail: (billId: number) => void;
onConfirmBill?: (row: SettlementBillRow) => void;
onPayBill?: (row: SettlementBillRow) => void;
}; };
function billRowTone(row: SettlementBillRow): string { function billRowTone(row: SettlementBillRow): string {
@@ -120,7 +123,11 @@ export function SettlementBillsTable({
currencyCode, currencyCode,
billTypeFilter = "all", billTypeFilter = "all",
emptyMessage, emptyMessage,
canOperate = false,
boundAgentId = null,
onOpenDetail, onOpenDetail,
onConfirmBill,
onPayBill,
}: SettlementBillsTableProps): React.ReactElement { }: SettlementBillsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]); const { t } = useTranslation(["settlementCenter", "agents", "common"]);
@@ -173,8 +180,44 @@ export function SettlementBillsTable({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rows.map((row) => { {rows.map((row) => {
const isPlayerBill = row.bill_type === "player";
const direction = describeBillPaymentDirection(row, t); 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 ( return (
<TableRow key={row.id} className={billRowTone(row)}> <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)]" className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<AdminRowActionsMenu <AdminRowActionsMenu actions={rowActions} />
actions={[
{
key: "detail",
label: t("actions.detail", { defaultValue: "详情" }),
icon: Eye,
onClick: () => onOpenDetail(row.id),
},
]}
/>
</TableCell> </TableCell>
</TableRow> </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 { function parsePositiveInt(raw: string | null): number | null {
if (raw === null || raw === "") { if (raw === null || raw === "") {
@@ -11,16 +23,30 @@ function parsePositiveInt(raw: string | null): number | null {
} }
export function settlementCenterListHref(adminSiteId?: number | null): string { export function settlementCenterListHref(adminSiteId?: number | null): string {
if (adminSiteId != null && adminSiteId > 0) { return settlementCenterScopeHref(adminSiteId, "all");
return `/admin/settlement-center?site=${adminSiteId}`;
} }
return "/admin/settlement-center";
/** 结算中心列表深链,可带账单筛选 scope待确认 / 待收付等)。 */
export function settlementCenterScopeHref(
adminSiteId?: number | null,
billScope: SettlementBillScopeFilter = "all",
): string {
const params = new URLSearchParams();
if (adminSiteId != null && adminSiteId > 0) {
params.set("site", String(adminSiteId));
}
if (billScope !== "all") {
params.set("scope", billScope);
}
const qs = params.toString();
return qs ? `/admin/settlement-center?${qs}` : "/admin/settlement-center";
} }
export function settlementPeriodViewHref( export function settlementPeriodViewHref(
periodId: number, periodId: number,
view: SettlementPeriodView = "bills", view: SettlementPeriodView = "bills",
adminSiteId?: number | null, adminSiteId?: number | null,
billScope?: SettlementBillScopeFilter | null,
): string { ): string {
const params = new URLSearchParams({ const params = new URLSearchParams({
period: String(periodId), period: String(periodId),
@@ -29,24 +55,57 @@ export function settlementPeriodViewHref(
if (adminSiteId != null && adminSiteId > 0) { if (adminSiteId != null && adminSiteId > 0) {
params.set("site", String(adminSiteId)); params.set("site", String(adminSiteId));
} }
if (billScope != null && billScope !== "all") {
params.set("scope", billScope);
}
return `/admin/settlement-center?${params.toString()}`; 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( export function parseSettlementCenterView(
siteRaw: string | null, siteRaw: string | null,
periodRaw: string | null, periodRaw: string | null,
viewRaw: string | null, viewRaw: string | null,
): { siteId: number | null; periodId: number | null; view: SettlementPeriodView } { scopeRaw?: string | null,
const normalizedView = viewRaw === "reports" ? "bills" : viewRaw; ): {
siteId: number | null;
periodId: number | null;
view: SettlementPeriodView;
billScope: SettlementBillScopeFilter;
} {
const normalizedView =
viewRaw === "reports" || viewRaw === "operations" ? "bills" : viewRaw;
const view = const view =
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView) normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
? (normalizedView as SettlementPeriodView) ? (normalizedView as SettlementPeriodView)
: "bills"; : "bills";
const billScope =
scopeRaw !== null &&
scopeRaw !== "" &&
VALID_BILL_SCOPES.includes(scopeRaw as SettlementBillScopeFilter)
? (scopeRaw as SettlementBillScopeFilter)
: "all";
return { return {
siteId: parsePositiveInt(siteRaw), siteId: parsePositiveInt(siteRaw),
periodId: parsePositiveInt(periodRaw), periodId: parsePositiveInt(periodRaw),
view, view,
billScope,
}; };
} }

View File

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

View File

@@ -2,7 +2,7 @@
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
import Link from "next/link"; 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 { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; 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 { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import { import {
parseSettlementCenterView, parseSettlementCenterView,
preferredBillScopeForPeriod,
settlementCenterListHref, settlementCenterListHref,
settlementPeriodViewHref, settlementPeriodViewHref,
type SettlementPeriodView, type SettlementPeriodView,
@@ -45,11 +46,24 @@ export function SettlementCenterShell(): React.ReactElement {
const profile = useAdminProfile(); const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null; 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("site"),
searchParams.get("period"), searchParams.get("period"),
searchParams.get("view"), 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 = const canOperateBills =
profile?.is_super_admin === true || profile?.is_super_admin === true ||
@@ -66,6 +80,8 @@ export function SettlementCenterShell(): React.ReactElement {
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]); const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
const [periodsReady, setPeriodsReady] = useState(false); const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null); 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 [refreshKey, setRefreshKey] = useState(0);
const [periodLookupDone, setPeriodLookupDone] = useState(false); const [periodLookupDone, setPeriodLookupDone] = useState(false);
@@ -261,8 +277,15 @@ export function SettlementCenterShell(): React.ReactElement {
const activePeriod = const activePeriod =
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null; activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => { const openPeriodView = (periodId: number, view: SettlementPeriodView = "bills"): void => {
router.push(settlementPeriodViewHref(periodId, view, siteId)); 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; const isListMode = activePeriodId === null;
@@ -279,14 +302,30 @@ export function SettlementCenterShell(): React.ReactElement {
return; return;
} }
if (activePeriodId !== null && siteFromUrl !== siteId) { 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(() => { useEffect(() => {
setPeriodLookupDone(false); setPeriodLookupDone(false);
}, [activePeriodId, siteId]); }, [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(() => { useEffect(() => {
if (!periodsReady || activePeriodId === null || siteId === null) { if (!periodsReady || activePeriodId === null || siteId === null) {
return; return;
@@ -306,7 +345,9 @@ export function SettlementCenterShell(): React.ReactElement {
const match = (data.items ?? []).find((row) => row.id === activePeriodId); const match = (data.items ?? []).find((row) => row.id === activePeriodId);
if (match?.admin_site_id && match.admin_site_id !== siteId) { if (match?.admin_site_id && match.admin_site_id !== siteId) {
setAdminSiteId(match.admin_site_id); 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; return;
} }
@@ -381,21 +422,51 @@ export function SettlementCenterShell(): React.ReactElement {
<SettlementCenterPeriodDetail <SettlementCenterPeriodDetail
period={activePeriod} period={activePeriod}
view={activeView} view={activeView}
billScope={activeBillScope}
adminSiteId={siteId} adminSiteId={siteId}
currencyCode={currency} currencyCode={currency}
canOperateBills={canOperateBills} canOperateBills={canOperateBills}
canManagePeriods={canManagePeriods}
boundAgentId={boundAgent?.id ?? null} boundAgentId={boundAgent?.id ?? null}
refreshKey={refreshKey} 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 <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"> <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> </DialogHeader>
{detailBillId !== null ? ( {detailBillId !== null ? (
<div className="min-h-0 overflow-y-auto px-6 py-5"> <div className="min-h-0 overflow-y-auto px-6 py-5">
@@ -405,6 +476,7 @@ export function SettlementCenterShell(): React.ReactElement {
canManage={canOperateBills} canManage={canOperateBills}
boundAgent={boundAgent} boundAgent={boundAgent}
canFinanceAdjustments={canFinanceAdjustments} canFinanceAdjustments={canFinanceAdjustments}
focusSection={detailBillFocus}
onUpdated={() => { onUpdated={() => {
void loadPeriods(); void loadPeriods();
setRefreshKey((n) => n + 1); setRefreshKey((n) => n + 1);

View File

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

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) => { periods.map((row) => {
const canCloseRow = canManage && row.status === "open"; 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 ( return (
<TableRow key={row.id}> <TableRow key={row.id}>
@@ -144,7 +150,7 @@ export function SettlementPeriodsTable({
actions={[ actions={[
{ {
key: "detail", key: "detail",
label: t("periodTable.viewDetail", { defaultValue: "查看详情" }), label: detailLabel,
icon: Eye, icon: Eye,
onClick: () => onViewDetail(row.id), 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"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Copy, RotateCcw, Wrench } from "lucide-react"; import { RotateCcw, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -12,7 +12,6 @@ import { toast } from "sonner";
import { import {
getAdminPlayerWallets, getAdminPlayerWallets,
getAdminTransferOrders, getAdminTransferOrders,
getAdminWalletTransactions,
reverseTransferOrder, reverseTransferOrder,
manuallyProcessTransferOrder, manuallyProcessTransferOrder,
completeTransferInCredit, completeTransferInCredit,
@@ -58,204 +57,27 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels"; 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 { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { formatAdminMinorUnits } from "@/lib/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 { LotteryApiBizError } from "@/types/api/errors";
import type { import type {
AdminPlayerWalletsData, AdminPlayerWalletsData,
AdminTransferOrderItem, AdminTransferOrderItem,
AdminTransferOrderListData, AdminTransferOrderListData,
AdminWalletTxnListData,
} from "@/types/api/admin-wallet"; } from "@/types/api/admin-wallet";
/** 长单号/流水号:单行截断;点击复制全文,悬停可看全文 */ export { WalletTxnsPanel } from "@/modules/wallet/wallet-txns-panel";
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;
}
type TransferOrderRowActionsProps = { type TransferOrderRowActionsProps = {
row: AdminTransferOrderItem; row: AdminTransferOrderItem;
@@ -317,24 +139,27 @@ export function TransferOrdersPanel(): React.ReactElement {
useAdminCurrencyCatalog(); useAdminCurrencyCatalog();
const formatTs = useAdminDateTimeFormatter(); const formatTs = useAdminDateTimeFormatter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim(); const urlFilterKey = searchParams.toString();
const transferNoFromUrl = (searchParams.get("transfer_no") ?? "").trim();
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
const [data, setData] = useState<AdminTransferOrderListData | null>(null); const [data, setData] = useState<AdminTransferOrderListData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10); const [perPage, setPerPage] = useState(10);
const initialTransferFilters: TransferFilters = { const [draft, setDraft] = useState<TransferFilters>(() =>
...emptyTransferFilters, transferFiltersFromSearchParams(searchParams),
playerId: playerIdFromUrl, );
transferNo: transferNoFromUrl, const [applied, setApplied] = useState<TransferFilters>(() =>
externalRefNo: externalRefNoFromUrl, transferFiltersFromSearchParams(searchParams),
}; );
const [draft, setDraft] = useState<TransferFilters>(initialTransferFilters);
const [applied, setApplied] = useState<TransferFilters>(initialTransferFilters);
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set()); const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
useEffect(() => {
const next = transferFiltersFromSearchParams(searchParams);
setDraft(next);
setApplied(next);
setPage(1);
}, [urlFilterKey, searchParams]);
const doAction = async ( const doAction = async (
transferNo: string, transferNo: string,
fn: () => Promise<unknown>, fn: () => Promise<unknown>,
@@ -399,7 +224,7 @@ export function TransferOrdersPanel(): React.ReactElement {
external_ref_no: applied.externalRefNo.trim() || undefined, external_ref_no: applied.externalRefNo.trim() || undefined,
created_from: applied.createdFrom.trim() || undefined, created_from: applied.createdFrom.trim() || undefined,
created_to: applied.createdTo.trim() || undefined, created_to: applied.createdTo.trim() || undefined,
status: applied.statusCsv.trim() || undefined, status: applied.abnormalOnly ? undefined : applied.statusCsv.trim() || undefined,
}); });
setData(d); setData(d);
} catch (e) { } catch (e) {
@@ -432,6 +257,11 @@ export function TransferOrdersPanel(): React.ReactElement {
<CardTitle>{t("transferOrders")}</CardTitle> <CardTitle>{t("transferOrders")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label> <Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
@@ -571,10 +401,10 @@ export function TransferOrdersPanel(): React.ReactElement {
data.items.map((row) => ( data.items.map((row) => (
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal"> <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>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal"> <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> </TableCell>
<AdminAgentIdentityCells row={row} /> <AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} /> <AdminPlayerIdentityCells row={row} />
@@ -585,7 +415,7 @@ export function TransferOrdersPanel(): React.ReactElement {
</AdminTableMoney> </AdminTableMoney>
</TableCell> </TableCell>
<TableCell> <TableCell>
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge> <AdminStatusBadge status={row.status}>{walletStatusLabel(row.status, t)}</AdminStatusBadge>
</TableCell> </TableCell>
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground"> <TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
{row.fail_reason?.trim() ? row.fail_reason : "—"} {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 { export function PlayerWalletPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]); const { t } = useTranslation(["wallet", "common"]);
const tRef = useTranslationRef(["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 = { export type AgentProfilePayload = {
relative_share_rate?: number;
total_share_rate?: number; total_share_rate?: number;
credit_limit?: number; credit_limit?: number;
rebate_limit?: number; rebate_limit?: number;

View File

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