feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
@@ -155,10 +155,10 @@ const translations = {
|
||||
"lineProvision.siteRequired": "Select an integration site",
|
||||
"lineProvision.noUnboundSite": "No sites without a level-1 agent",
|
||||
"lineProvision.openIntegrationSites": "Go to integration sites",
|
||||
"lineProvision.passwordHint": "At least 8 characters",
|
||||
"lineProvision.passwordHint": "At least 6 characters",
|
||||
"playersPanel.siteCode": "Line site",
|
||||
"playersPanel.passwordHint": "At least 8 characters",
|
||||
"playersPanel.passwordMinLength": "Initial password must be at least 8 characters",
|
||||
"playersPanel.passwordHint": "At least 6 characters",
|
||||
"playersPanel.passwordMinLength": "Initial password must be at least 6 characters",
|
||||
"playersPanel.creditLimitInvalid": "Credit limit must be an integer ≥ 0",
|
||||
"playersPanel.creditLimitExceeded": "Credit limit cannot exceed this agent’s available grant",
|
||||
"playersPanel.rebateRateInvalid": "Rebate rate must be between 0 and 100%",
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { AgentsDirectoryConsole } from "@/modules/agents/agents-directory-console";
|
||||
import {
|
||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("agents", "listTitle");
|
||||
|
||||
export default function AgentsListPage() {
|
||||
return (
|
||||
<ModuleScaffold embedded>
|
||||
<AdminPermissionGate
|
||||
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
|
||||
>
|
||||
<AgentsDirectoryConsole />
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
export default function AgentsListRedirectPage() {
|
||||
redirect("/admin/agents?view=list");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { AgentsConsole } from "@/modules/agents/agents-console";
|
||||
import { AgentsManagementScreen } from "@/modules/agents/agents-management-screen";
|
||||
import {
|
||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
@@ -16,8 +16,8 @@ export default function AgentsPage() {
|
||||
<AdminPermissionGate
|
||||
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
|
||||
>
|
||||
<AgentsConsole />
|
||||
<AgentsManagementScreen />
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,4 @@ export default function AdminAuditLogsPage() {
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { DrawSubnav } from "@/modules/draws/draw-subnav";
|
||||
import { DrawDetailShell } from "@/modules/draws/draw-detail-shell";
|
||||
|
||||
export default async function AdminDrawSegmentLayout(props: {
|
||||
children: React.ReactNode;
|
||||
@@ -9,8 +9,7 @@ export default async function AdminDrawSegmentLayout(props: {
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<DrawSubnav drawId={drawId} />
|
||||
{props.children}
|
||||
<DrawDetailShell drawId={drawId}>{props.children}</DrawDetailShell>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { PRD_REPORTS_PAGE_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import { ReportsConsole } from "@/modules/reports/reports-console";
|
||||
import type { ReportCategory } from "@/modules/reports/reports-definitions";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("reports", "title");
|
||||
|
||||
const VALID_CATEGORIES = new Set<ReportCategory>(["profit", "wallet", "risk", "audit"]);
|
||||
|
||||
export default async function AdminReportsCategoryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ category: string }>;
|
||||
}) {
|
||||
const { category } = await params;
|
||||
if (!["profit", "wallet", "risk", "audit"].includes(category)) {
|
||||
if (!VALID_CATEGORIES.has(category as ReportCategory)) {
|
||||
notFound();
|
||||
}
|
||||
redirect("/admin/reports");
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPermissionGate requiredAny={PRD_REPORTS_PAGE_ACCESS_ANY}>
|
||||
<Suspense fallback={<AdminLoadingState minHeight="12rem" />}>
|
||||
<ReportsConsole initialCategory={category as ReportCategory} />
|
||||
</Suspense>
|
||||
</AdminPermissionGate>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { ReportsConsole } from "@/modules/reports/reports-console";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
return (
|
||||
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
|
||||
<Suspense fallback={<AdminLoadingState minHeight="12rem" />}>
|
||||
<ReportsConsole />
|
||||
</Suspense>
|
||||
</AdminPermissionGate>
|
||||
);
|
||||
}
|
||||
redirect("/admin/reports/profit");
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { WalletScopeHint } from "@/modules/wallet/wallet-scope-hint";
|
||||
import { WalletSubnav } from "@/modules/wallet/wallet-subnav";
|
||||
|
||||
export default function AdminWalletLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
|
||||
<div className="sticky top-14 z-20 space-y-2 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="sticky top-14 z-20 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<WalletSubnav />
|
||||
<WalletScopeHint />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -168,6 +168,24 @@
|
||||
@apply overflow-x-auto rounded-2xl border border-border/80 bg-card shadow-sm;
|
||||
}
|
||||
|
||||
/* Flat table inside an existing card — same white surface, no nested panel or blue header tint */
|
||||
.admin-table-inset {
|
||||
@apply overflow-x-auto rounded-xl border border-border/80;
|
||||
}
|
||||
|
||||
.admin-table-inset [data-slot="table-header"] {
|
||||
@apply bg-card;
|
||||
}
|
||||
|
||||
.admin-table-inset [data-slot="table-head"][class*="sticky"],
|
||||
.admin-table-inset [data-slot="table-cell"][class*="sticky"] {
|
||||
background-color: var(--card);
|
||||
}
|
||||
|
||||
.admin-table-inset [data-slot="table-row"]:hover > [data-slot="table-cell"][class*="sticky"] {
|
||||
@apply bg-muted/20;
|
||||
}
|
||||
|
||||
/* Sticky columns need an opaque background so scrolled cells/headers do not show through */
|
||||
[data-slot="table-head"][class*="sticky"] {
|
||||
@apply bg-muted;
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ADMIN_PERMISSION_PACKAGES } from "@/lib/admin-permission-packages";
|
||||
import {
|
||||
applyProfileLevel,
|
||||
permissionProfileForGroup,
|
||||
resolveProfileLevel,
|
||||
type PermissionAccessLevel,
|
||||
type PermissionGroupProfile,
|
||||
} from "@/lib/admin-permission-profiles";
|
||||
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
||||
|
||||
type PackageSelectorProps = {
|
||||
@@ -22,10 +19,7 @@ type PackageSelectorProps = {
|
||||
selectedSlugs: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
resolveGroupLabel: (key: string, fallback: string) => string;
|
||||
resolvePackageLabel: (key: string, fallback: string) => string;
|
||||
selectableSlugs?: string[] | null;
|
||||
helperText?: string;
|
||||
summaryText?: string;
|
||||
isSuperAdmin?: boolean;
|
||||
emptyText: string;
|
||||
heightClassName?: string;
|
||||
};
|
||||
@@ -33,24 +27,8 @@ type PackageSelectorProps = {
|
||||
type RenderGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
packages: Array<{ key: string; label: string; slugs: string[] }>;
|
||||
};
|
||||
|
||||
const PACKAGE_LEVEL_ORDER: Record<string, number> = {
|
||||
view: 10,
|
||||
node_view: 10,
|
||||
role_view: 11,
|
||||
user_view: 12,
|
||||
review: 20,
|
||||
export: 20,
|
||||
manage: 30,
|
||||
node_manage: 30,
|
||||
role_manage: 31,
|
||||
user_manage: 32,
|
||||
config: 30,
|
||||
control: 30,
|
||||
reopen: 32,
|
||||
special: 40,
|
||||
profile: PermissionGroupProfile;
|
||||
levels: Array<{ key: PermissionAccessLevel; label: string; slugs: string[] }>;
|
||||
};
|
||||
|
||||
export function AdminPermissionPackageSelector({
|
||||
@@ -58,197 +36,101 @@ export function AdminPermissionPackageSelector({
|
||||
selectedSlugs,
|
||||
onChange,
|
||||
resolveGroupLabel,
|
||||
resolvePackageLabel,
|
||||
selectableSlugs = null,
|
||||
helperText,
|
||||
summaryText,
|
||||
isSuperAdmin = false,
|
||||
emptyText,
|
||||
heightClassName = "h-[52vh]",
|
||||
}: PackageSelectorProps): React.ReactElement {
|
||||
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]);
|
||||
const { t } = useTranslation("adminUsers");
|
||||
const catalogSlugSet = useMemo(
|
||||
() => new Set((catalog?.permissions ?? []).map((permission) => permission.slug)),
|
||||
[catalog],
|
||||
);
|
||||
const allowedSet = useMemo(
|
||||
() => (selectableSlugs ? new Set(selectableSlugs) : null),
|
||||
[selectableSlugs],
|
||||
);
|
||||
|
||||
const groups = useMemo<RenderGroup[]>(() => {
|
||||
const defs = catalog?.permission_menu_groups ?? [];
|
||||
const out: RenderGroup[] = [];
|
||||
|
||||
for (const group of defs) {
|
||||
const bundles = ADMIN_PERMISSION_PACKAGES[group.key] ?? [];
|
||||
if (bundles.length === 0) {
|
||||
const profile = permissionProfileForGroup(group.key);
|
||||
if (profile === undefined) {
|
||||
continue;
|
||||
}
|
||||
const renderedPackages = bundles
|
||||
.map((bundle) => {
|
||||
const slugs = bundle.slugs.filter((slug) => {
|
||||
if (!catalogSlugSet.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
if (allowedSet && !allowedSet.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
key: bundle.key,
|
||||
label: resolvePackageLabel(bundle.key, bundle.label),
|
||||
slugs,
|
||||
};
|
||||
})
|
||||
.filter((bundle) => bundle.slugs.length > 0);
|
||||
if (renderedPackages.length === 0) {
|
||||
if (profile.platformOnly && !isSuperAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const levels = profile.levels
|
||||
.map((level) => ({
|
||||
key: level.key,
|
||||
label: t(level.labelKey),
|
||||
slugs: level.slugs.filter((slug) => catalogSlugSet.has(slug)),
|
||||
}))
|
||||
.filter((level) => level.key === "none" || level.slugs.length > 0);
|
||||
|
||||
if (levels.length <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({
|
||||
key: group.key,
|
||||
label: resolveGroupLabel(group.key, group.label),
|
||||
packages: renderedPackages,
|
||||
profile,
|
||||
levels,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [allowedSet, catalog, catalogSlugSet, resolveGroupLabel, resolvePackageLabel]);
|
||||
}, [catalog, catalogSlugSet, isSuperAdmin, resolveGroupLabel, t]);
|
||||
|
||||
const bundleCount = useMemo(
|
||||
() => groups.reduce((sum, group) => sum + group.packages.length, 0),
|
||||
[groups],
|
||||
);
|
||||
|
||||
if (groups.length === 0 || bundleCount === 0) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed p-4">
|
||||
<AdminNoResourceState compact className="py-4" />
|
||||
<div className="rounded-lg border border-dashed p-4">
|
||||
<AdminNoResourceState compact className="py-4" message={emptyText} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleBundle = (group: RenderGroup, bundleKey: string, slugs: string[], checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
const currentLevel = PACKAGE_LEVEL_ORDER[bundleKey] ?? 10;
|
||||
|
||||
if (checked) {
|
||||
const implied = group.packages
|
||||
.filter((item) => (PACKAGE_LEVEL_ORDER[item.key] ?? 10) <= currentLevel)
|
||||
.flatMap((item) => item.slugs);
|
||||
for (const slug of implied.length > 0 ? implied : slugs) {
|
||||
next.add(slug);
|
||||
}
|
||||
for (const item of group.packages) {
|
||||
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
|
||||
for (const slug of item.slugs) {
|
||||
next.delete(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const slug of slugs) {
|
||||
next.delete(slug);
|
||||
}
|
||||
if (bundleKey === "manage" || bundleKey === "reopen") {
|
||||
for (const item of group.packages) {
|
||||
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
|
||||
for (const slug of item.slugs) {
|
||||
next.delete(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
const toggleGroup = (group: RenderGroup, checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (checked) {
|
||||
const viewBundle =
|
||||
group.packages.find((item) => item.key === "view") ?? group.packages[0];
|
||||
for (const slug of viewBundle?.slugs ?? []) {
|
||||
next.add(slug);
|
||||
}
|
||||
} else {
|
||||
for (const slug of group.packages.flatMap((item) => item.slugs)) {
|
||||
next.delete(slug);
|
||||
}
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
const setGroupLevel = (group: RenderGroup, levelKey: PermissionAccessLevel) => {
|
||||
onChange(applyProfileLevel(group.profile, levelKey, selectedSlugs));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{helperText || summaryText ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>{helperText}</span>
|
||||
<span>{summaryText}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn("relative w-full overflow-auto rounded-xl border border-border/60 bg-card", heightClassName)}>
|
||||
<table className="w-full min-w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-md border-b border-border/50">
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }} className="w-[180px] h-11 px-4 font-semibold text-foreground">模块</th>
|
||||
<th style={{ textAlign: "left" }} className="h-11 px-4 font-semibold text-foreground">具体权限</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{groups.map((group) => {
|
||||
const groupAllSlugs = group.packages.flatMap((p) => p.slugs);
|
||||
const groupSelectedCount = groupAllSlugs.filter((slug) => selectedSet.has(slug)).length;
|
||||
const groupChecked = groupSelectedCount === groupAllSlugs.length && groupAllSlugs.length > 0;
|
||||
<div className={cn("overflow-y-auto rounded-lg border bg-card", heightClassName)}>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{groups.map((group) => {
|
||||
const activeLevel = resolveProfileLevel(group.profile, selectedSlugs);
|
||||
|
||||
return (
|
||||
<tr key={group.key} className="hover:bg-muted/10 transition-colors">
|
||||
<td style={{ textAlign: "left" }} className="align-top py-4 pl-4">
|
||||
<label
|
||||
style={{ display: "flex", justifyContent: "flex-start" }}
|
||||
className="cursor-pointer items-center gap-2 font-medium w-full"
|
||||
return (
|
||||
<li
|
||||
key={group.key}
|
||||
className="flex flex-col gap-2.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span className="min-w-[7rem] shrink-0 text-sm font-medium text-foreground">{group.label}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{group.levels.map((level) => {
|
||||
const active = activeLevel === level.key;
|
||||
return (
|
||||
<button
|
||||
key={`${group.key}.${level.key}`}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border/70 bg-background text-foreground hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => setGroupLevel(group, level.key)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={groupChecked}
|
||||
onCheckedChange={(value) =>
|
||||
toggleGroup(group, value === true)
|
||||
}
|
||||
/>
|
||||
<span>{group.label}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ textAlign: "left" }} className="py-4 pl-4">
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-start" }}
|
||||
className="flex-wrap gap-3 w-full"
|
||||
>
|
||||
{group.packages.map((bundle) => {
|
||||
const checked = bundle.slugs.every((slug) => selectedSet.has(slug));
|
||||
return (
|
||||
<label
|
||||
key={`${group.key}.${bundle.key}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted/50",
|
||||
checked && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) =>
|
||||
toggleBundle(group, bundle.key, bundle.slugs, value === true)
|
||||
}
|
||||
/>
|
||||
<span>{bundle.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{level.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function LoginForm() {
|
||||
const passwordIssue = validateAdminPassword(password);
|
||||
if (passwordIssue === "too_short") {
|
||||
toast.error(
|
||||
t("passwordMinLength", { defaultValue: "密码至少需要 8 个字符" }),
|
||||
t("passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"noRoles": "No roles available yet. Wait for the list to finish loading and try again.",
|
||||
"password": "Password",
|
||||
"passwordOptional": "Password (optional)",
|
||||
"passwordPlaceholderCreate": "At least 8 characters",
|
||||
"passwordPlaceholderCreate": "at least 6 characters",
|
||||
"passwordPlaceholderEdit": "Leave empty to keep unchanged",
|
||||
"rolesDescription": "After creation, adjust per-site role bindings in Assign Roles.",
|
||||
"rolesRequired": "Roles (at least one)",
|
||||
@@ -56,9 +56,9 @@
|
||||
"listTitle": "Admin user list",
|
||||
"loadFailed": "Failed to load admin list",
|
||||
"modelGuide": "Accounts bind roles only. Maintain functional permissions in Role Management.",
|
||||
"newPasswordMin": "New password must be at least 8 characters",
|
||||
"newPasswordMin": "New password must be at least 6 characters",
|
||||
"nicknameRequired": "Enter a nickname",
|
||||
"passwordMin": "Password must be at least 8 characters",
|
||||
"passwordMin": "Password must be at least 6 characters",
|
||||
"permissionDialog": {
|
||||
"rolePermissionCount": "Contains {{count}} functional permissions",
|
||||
"rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management. Save roles per site.",
|
||||
@@ -179,9 +179,18 @@
|
||||
"roleListTitle": "Role Management",
|
||||
"roleLoadFailed": "Failed to load role list",
|
||||
"rolePermissionDialog": {
|
||||
"packageHint": "Checking a module row on the left grants View only. Select Manage separately for data entry, close, draw, and other admin actions.",
|
||||
"title": "Role Permissions"
|
||||
"title": "Role permissions",
|
||||
"titleWithName": "{{name}}"
|
||||
},
|
||||
"permissionAccess": {
|
||||
"none": "No access",
|
||||
"view": "View only",
|
||||
"review": "Can review",
|
||||
"export": "Can export",
|
||||
"manage": "Can manage",
|
||||
"special": "Includes privileged actions"
|
||||
},
|
||||
|
||||
"rolePermissionSaveFailed": "Failed to save role permissions",
|
||||
"rolePermissionSaveSuccess": "Role permissions updated",
|
||||
"roleRequired": "Select at least one role",
|
||||
@@ -190,6 +199,7 @@
|
||||
"actions": "Actions",
|
||||
"name": "Role",
|
||||
"permissions": "Permission count",
|
||||
"enabledAreas": "Enabled modules",
|
||||
"slug": "Role Code",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"noUnboundSite": "No sites without a level-1 agent",
|
||||
"openIntegrationSites": "Go to integration sites",
|
||||
"password": "Initial password",
|
||||
"passwordHint": "At least 8 characters",
|
||||
"passwordHint": "at least 6 characters",
|
||||
"secretsOnce": "Integration secrets are shown once — save them now",
|
||||
"siteCode": "Integration site",
|
||||
"siteCodePlaceholder": "Select site",
|
||||
@@ -82,7 +82,8 @@
|
||||
"downlineEmptyShort": "No direct downline yet.",
|
||||
"downlineEmptyTitle": "No direct downline yet",
|
||||
"editAccount": "Account & status",
|
||||
"editAgent": "Edit agent",
|
||||
"editAgent": "Edit",
|
||||
"parentMeta": "Parent {{name}}",
|
||||
"editCurrent": "Edit this agent",
|
||||
"expand": "Expand",
|
||||
"kicker": "Credit share · Agent tree",
|
||||
@@ -112,6 +113,8 @@
|
||||
"tabDownline": "Downline",
|
||||
"tabOverview": "Overview",
|
||||
"tabPlayers": "Players",
|
||||
"tabProfileShort": "Share & credit",
|
||||
"openInTree": "Open in tree",
|
||||
"createDirectPlayer": "Create direct player",
|
||||
"createDownline": "Create child agent",
|
||||
"editDownline": "Edit agent",
|
||||
@@ -125,6 +128,11 @@
|
||||
},
|
||||
"listFlatHint": "All operating agents in a flat list. Use row actions to add a child under a specific agent.",
|
||||
"listSearch": "Search name / code / login",
|
||||
"viewModes": {
|
||||
"label": "View",
|
||||
"tree": "Tree",
|
||||
"list": "List"
|
||||
},
|
||||
"listTitle": "Agents",
|
||||
"loadFailed": "Failed to load agent tree",
|
||||
"modelGuide": "Agent layer controls data scope and delegation ceiling. Account permissions are assigned through roles.",
|
||||
@@ -134,9 +142,9 @@
|
||||
"noAccess": "You do not have permission to manage agents. Contact an administrator.",
|
||||
"pageGuide": "Manage the agent tree, agent roles, agent accounts, and delegation ceilings here. Platform accounts and platform roles stay in platform governance pages.",
|
||||
"parentAgent": "Parent",
|
||||
"passwordMinLength": "Password must be at least 8 characters",
|
||||
"passwordOptionalHint": "Leave empty to keep unchanged, or enter an 8-character password",
|
||||
"passwordPlaceholder": "Enter an 8-character password",
|
||||
"passwordMinLength": "Password must be at least 6 characters",
|
||||
"passwordOptionalHint": "Leave empty to keep unchanged, or enter a 6-character password",
|
||||
"passwordPlaceholder": "Enter a 6-character password",
|
||||
"passwordRequired": "Password is required",
|
||||
"path": "Path",
|
||||
"playersPanel": {
|
||||
@@ -157,8 +165,8 @@
|
||||
"initialPassword": "Initial password",
|
||||
"loginRequired": "Enter login username and initial password",
|
||||
"loginUsername": "Login username",
|
||||
"passwordHint": "At least 8 characters",
|
||||
"passwordMinLength": "Initial password must be at least 8 characters",
|
||||
"passwordHint": "at least 6 characters",
|
||||
"passwordMinLength": "Initial password must be at least 6 characters",
|
||||
"playerRef": "Player ref",
|
||||
"rebateInherited": "Inherit agent default rebate",
|
||||
"rebateRate": "Rebate rate (%)",
|
||||
@@ -168,7 +176,11 @@
|
||||
"riskTagsPlaceholder": "Comma-separated",
|
||||
"scopedTo": "Direct players: {{agent}}",
|
||||
"siteCode": "Line site",
|
||||
"usernameNickname": "Username / nickname"
|
||||
"usernameNickname": "Username / nickname",
|
||||
"searchPh": "Search player account / ID",
|
||||
"manageSettlement": "Process bill",
|
||||
"noPendingBillsGoCenter": "No pending bills for this player. Opened settlement center.",
|
||||
"billingLoadFailed": "Failed to load bills"
|
||||
},
|
||||
"profile": {
|
||||
"availableCredit": "Available to grant {{amount}}",
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
{
|
||||
"title": "Audit Logs",
|
||||
"moduleCode": "Module code",
|
||||
"actionCode": "Action code",
|
||||
"operatorType": "Operator type",
|
||||
"operatorIdPlaceholder": "Enter operator ID",
|
||||
"exactMatch": "Exact match",
|
||||
"operatorTypePlaceholder": "For example admin / system",
|
||||
"operator": "Operator",
|
||||
"module": "Module",
|
||||
"action": "Action",
|
||||
"target": "Target",
|
||||
"title": "Activity Log",
|
||||
"filterModule": "Business area",
|
||||
"filterModuleAll": "All areas",
|
||||
"filterOperatorType": "Actor type",
|
||||
"filterOperatorTypeAll": "All",
|
||||
"operatorIdPlaceholder": "Optional operator ID",
|
||||
"operator": "Actor",
|
||||
"summary": "What happened",
|
||||
"target": "Related to",
|
||||
"time": "Time",
|
||||
"empty": "No data",
|
||||
"empty": "No records",
|
||||
"moduleOptions": {
|
||||
"agent": "Agents",
|
||||
"system": "System admin",
|
||||
"settings": "Settings",
|
||||
"integration": "Integration sites",
|
||||
"player_manage": "Player management",
|
||||
"player_service": "Player service",
|
||||
"risk_cap": "Risk caps",
|
||||
"odds": "Odds",
|
||||
"play_config": "Play config",
|
||||
"settlement": "Settlement",
|
||||
"report_jobs": "Report export",
|
||||
"reconcile_jobs": "Reconcile jobs",
|
||||
"draw": "Draws",
|
||||
"wallet": "Wallet",
|
||||
"reconcile": "Reconcile",
|
||||
"jackpot": "Jackpot",
|
||||
"dashboard": "Dashboard"
|
||||
},
|
||||
"operatorTypes": {
|
||||
"admin": "Admin",
|
||||
"admin": "Admin user",
|
||||
"player": "Player",
|
||||
"system": "System"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"captchaLoadFailed": "Failed to load captcha. Check the API or network.",
|
||||
"apiBaseMissingToast": "API proxy is not enabled: check LOTTERY_API_UPSTREAM points to Laravel",
|
||||
"captchaRequired": "Refresh the captcha first",
|
||||
"passwordMinLength": "Password must be at least 6 characters",
|
||||
"welcome": "Welcome, {{name}}",
|
||||
"networkFailed": "Network request failed",
|
||||
"loginFailed": "Login failed"
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
},
|
||||
"validation": {
|
||||
"required": "{{field}} is required",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordMinLength": "Password must be at least 6 characters"
|
||||
},
|
||||
"aria": {
|
||||
"expand": "Expand",
|
||||
@@ -156,7 +157,7 @@
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"dashboard": "Dashboard",
|
||||
"admin_users": "Admin Users",
|
||||
"admin_users": "Platform accounts",
|
||||
"admin_roles": "Role Management",
|
||||
"players": "Players",
|
||||
"currencies": "Currencies",
|
||||
@@ -171,21 +172,20 @@
|
||||
"settlement": "Settlement",
|
||||
"jackpot": "Jackpot",
|
||||
"reconcile": "Reconcile",
|
||||
"tickets": "Ticket list",
|
||||
"tickets": "Tickets",
|
||||
"audit": "Audit Logs",
|
||||
"settings": "Settings",
|
||||
"account": "Account settings",
|
||||
"integration": "Integration",
|
||||
"agents": "Agent lines",
|
||||
"agent_list": "Agent list",
|
||||
"settlement_center": "Settlement center",
|
||||
"agents": "Agent management",
|
||||
"settlement_center": "Credit settlement",
|
||||
"config": "Operations config"
|
||||
},
|
||||
"sidebar": {
|
||||
"workspace": "Workspace",
|
||||
"group": {
|
||||
"overview": "Overview",
|
||||
"agent": "Agent organization",
|
||||
"agent": "Agents",
|
||||
"operations": "Operations",
|
||||
"finance": "Finance & reports",
|
||||
"rules": "Rules & parameters",
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
},
|
||||
"form": {
|
||||
"adminNicknameRequired": "Site admin nickname is required",
|
||||
"adminPasswordRequired": "Initial site admin password must be at least 8 characters",
|
||||
"adminPasswordRequired": "Initial site admin password must be at least 6 characters",
|
||||
"adminUsernameRequired": "Site admin username is required",
|
||||
"codeRequired": "site_code is required",
|
||||
"required": "Site name is required"
|
||||
@@ -147,7 +147,7 @@
|
||||
"placeholders": {
|
||||
"adminEmail": "Enter email",
|
||||
"adminNickname": "Enter account nickname",
|
||||
"adminPassword": "At least 8 characters",
|
||||
"adminPassword": "at least 6 characters",
|
||||
"adminUsername": "Enter admin username",
|
||||
"code": "Enter site identifier, for example partner-a",
|
||||
"connectivityPlayerId": "Enter player ID, for example 10001",
|
||||
@@ -527,10 +527,12 @@
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pageTitle": "System settings",
|
||||
|
||||
"confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.",
|
||||
"confirmSaveCurrencyFormatTitle": "Save currency display format?",
|
||||
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
|
||||
"confirmSaveDrawDescription": "This updates draw review, schedule timing, and cooldown in this block only.",
|
||||
"confirmSaveDrawDescription": "This updates manual draw review and cooldown duration only.",
|
||||
"confirmSaveDrawTitle": "Save draw parameters?",
|
||||
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed.",
|
||||
"confirmSaveFrontendTitle": "Save front-end display settings?",
|
||||
@@ -591,12 +593,33 @@
|
||||
"saveSettlementSuccess": "Settlement automation saved",
|
||||
"saveSuccess": "System settings saved",
|
||||
"sections": {
|
||||
"deployment": "Deployment (read-only)",
|
||||
"deploymentDescription": "Default currency and draw schedule are set in server .env; this block shows effective values.",
|
||||
"currencyFormat": "Currency display format",
|
||||
"currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).",
|
||||
"draw": "Draw schedule and review",
|
||||
"drawDescription": "Controls draw timing, close window, manual review, and cooldown. Only changed fields in this block are submitted.",
|
||||
"draw": "Draw review",
|
||||
"drawDescription": "Manual review after RNG and post-publish cooldown.",
|
||||
"settlement": "Settlement automation",
|
||||
"settlementDescription": "Controls whether tick auto-runs settlement, approval, and payout. Only changed fields in this block are submitted."
|
||||
"settlementDescription": "In order: auto settlement → auto approve → auto payout. When off, handle manually in the relevant module."
|
||||
},
|
||||
"deployment": {
|
||||
"footer": "Restart PHP or refresh config cache after changes. Contact ops if unsure.",
|
||||
"fields": {
|
||||
"default_currency": "Default currency",
|
||||
"draw_timezone": "Draw timezone",
|
||||
"draw_interval_minutes": "Draw interval (minutes)",
|
||||
"draw_betting_window_seconds": "Betting window (seconds)",
|
||||
"draw_close_before_draw_seconds": "Close before draw (seconds)",
|
||||
"draw_buffer_draws_ahead": "Pre-generated future draws"
|
||||
},
|
||||
"hints": {
|
||||
"default_currency": "Default for new players when currency is unspecified",
|
||||
"draw_timezone": "Timezone for draw schedule and display",
|
||||
"draw_interval_minutes": "Minutes between consecutive draw times",
|
||||
"draw_betting_window_seconds": "How long betting stays open each draw",
|
||||
"draw_close_before_draw_seconds": "Seconds before draw time to stop accepting bets",
|
||||
"draw_buffer_draws_ahead": "How many future draws the scheduler pre-creates"
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"disabled": "Disabled",
|
||||
@@ -630,19 +653,17 @@
|
||||
"count": "{{count}} items",
|
||||
"current": "Current",
|
||||
"delete": "Delete",
|
||||
"deleteConfirmDescription": "Version ID {{id}} (version_no {{version}}) will be permanently deleted. Active versions cannot be deleted.",
|
||||
"deleteConfirmDescription": "Version v{{version}} will be deleted. This cannot be undone.",
|
||||
"deleteConfirmTitle": "Delete this version?",
|
||||
"effectiveAt": "Effective at: {{value}}",
|
||||
"empty": "No version records yet.",
|
||||
"loading": "Loading…",
|
||||
"moreActions": "More actions for v{{version}}",
|
||||
"noneSelected": "No version selected",
|
||||
"noneSelected": "Select version",
|
||||
"note": "Note: {{value}}",
|
||||
"rollback": "Rollback",
|
||||
"selected": "Selected",
|
||||
"sheetDescription": "Choose a version to view on this page. Drafts are editable, while active and archived versions are read-only.",
|
||||
"sheetTitle": "Switch configuration version",
|
||||
"switch": "Switch version",
|
||||
"sheetTitle": "Versions",
|
||||
"view": "View"
|
||||
},
|
||||
"versionToolbar": {
|
||||
|
||||
@@ -70,8 +70,8 @@
|
||||
"batchSummaryPending": "{{count}} pending",
|
||||
"batchSummaryPublished": "{{count}} published",
|
||||
"noResultBatchesYet": "No result batches yet.",
|
||||
"reviewQueueHint": "After results are generated, continue in Review & publish.",
|
||||
"goToReviewTab": "Review & publish",
|
||||
"reviewQueueHint": "",
|
||||
"goToReviewTab": "Publish",
|
||||
"businessDate": "Business date",
|
||||
"sequenceNo": "Sequence no.",
|
||||
"plannedDraw": "Planned draw",
|
||||
@@ -96,12 +96,12 @@
|
||||
"published": "Published",
|
||||
"viewFinance": "View draw finance",
|
||||
"drawActions": "Draw actions",
|
||||
"drawActionsDesc": "Manual close / cancel / RNG / reopen / settlement all call backend APIs directly.",
|
||||
"drawActionsDesc": "",
|
||||
"manualClose": "Manual close",
|
||||
"cancelDraw": "Cancel draw",
|
||||
"cancelBeforeDraw": "Cancel before draw",
|
||||
"rngDraw": "RNG draw",
|
||||
"rngAutoGenerate": "RNG auto generate",
|
||||
"rngAutoGenerate": "Auto draw",
|
||||
"reopen": "Reopen",
|
||||
"cooldownReopen": "Reopen in cooldown",
|
||||
"runSettlement": "Run settlement",
|
||||
@@ -114,8 +114,8 @@
|
||||
"actualBet": "Actual bet deducted",
|
||||
"currentPayout": "Current payout total",
|
||||
"grossProfit": "Approx. gross profit",
|
||||
"settlementBatchList": "Settlement batch list (filter by draw)",
|
||||
"relatedSettlementBatches": "Related settlement batches",
|
||||
"settlementBatchList": "Settlement records",
|
||||
"relatedSettlementBatches": "Settlement batches",
|
||||
"noSettlementBatches": "No settlement batch records.",
|
||||
"ticketCount": "Tickets",
|
||||
"winCount": "Wins",
|
||||
@@ -124,7 +124,7 @@
|
||||
"resultsTitle": "Results",
|
||||
"reviewAndPublish": "Review / publish",
|
||||
"viewReviewQueue": "View review queue",
|
||||
"noPublishedBatch": "No published batches.",
|
||||
"noPublishedBatch": "None",
|
||||
"version": "Version v{{version}}",
|
||||
"sourceType": "Source {{source}}",
|
||||
"manualEntry": "Manual",
|
||||
@@ -135,28 +135,28 @@
|
||||
"tail3": "Last 3",
|
||||
"tail2": "Last 2",
|
||||
"headTail": "Head/Tail",
|
||||
"manualResultEntry": "Manual result entry",
|
||||
"currentStatusAndDraft": "Current status {{status}}. Saving creates a pending batch and does not publish it.",
|
||||
"currentStatusLabel": "Current status",
|
||||
"currentStatusDraftHint": "Saving creates a pending batch and does not publish it.",
|
||||
"manualResultEntry": "Enter numbers",
|
||||
"currentStatusAndDraft": "",
|
||||
"currentStatusLabel": "Status",
|
||||
"currentStatusDraftHint": "",
|
||||
"hallPreviewStatusLabel": "Hall preview",
|
||||
"enter23Numbers": "Please enter all 23 groups of 4 digits",
|
||||
"draftSaved": "Draft v{{version}} saved, waiting to be published",
|
||||
"draftSaved": "Saved v{{version}}",
|
||||
"saveFailed": "Failed to save",
|
||||
"fillRandomNumbers": "Fill randomly",
|
||||
"clear": "Clear",
|
||||
"saveDraft": "Save draft",
|
||||
"saving": "Saving…",
|
||||
"pendingBatches": "Pending batches",
|
||||
"noPendingBatches": "There are no batches pending review.",
|
||||
"batchId": "Batch ID",
|
||||
"numberCount": "Number count",
|
||||
"reviewAndPublishAction": "Review and publish",
|
||||
"discardPendingBatch": "Delete draft",
|
||||
"pendingBatches": "Pending publish",
|
||||
"noPendingBatches": "None",
|
||||
"batchId": "Batch",
|
||||
"numberCount": "Numbers",
|
||||
"reviewAndPublishAction": "Publish",
|
||||
"discardPendingBatch": "Delete",
|
||||
"discardingPendingBatch": "Deleting…",
|
||||
"discardPendingBatchSuccess": "Pending batch removed. You can re-enter numbers or run RNG.",
|
||||
"discardPendingBatchFailed": "Delete failed",
|
||||
"publishReadOnlyHint": "This page is read-only for verification. To change numbers, delete this batch and save a new draft under manual entry.",
|
||||
"publishReadOnlyHint": "",
|
||||
"noPublishPermission": "No publish permission",
|
||||
"batchNotFound": "Batch not found",
|
||||
"batchNotFoundDesc": "Return to the review list and confirm the batch ID.",
|
||||
@@ -164,8 +164,8 @@
|
||||
"publishTitle": "Publish",
|
||||
"cannotPublish": "Cannot publish",
|
||||
"cannotPublishDesc": "Current batch status is '{{status}}'.",
|
||||
"checkBeforePublish": "Check the numbers before publishing",
|
||||
"checkBeforePublishDesc": "Publish only after confirming the numbers.",
|
||||
"checkBeforePublish": "",
|
||||
"checkBeforePublishDesc": "",
|
||||
"publishedView": "View published result",
|
||||
"confirmPublish": "Confirm publish",
|
||||
"submitting": "Submitting…",
|
||||
@@ -173,10 +173,10 @@
|
||||
"publishFailed": "Publish failed",
|
||||
"sourceTypeFull": "Source: {{source}} · Items: {{count}}/23 · RNG hash: {{hash}}",
|
||||
"subnav": {
|
||||
"status": "Draw status",
|
||||
"status": "Overview",
|
||||
"results": "Results",
|
||||
"finance": "Draw finance",
|
||||
"review": "Review & publish",
|
||||
"finance": "Finance",
|
||||
"review": "Publish",
|
||||
"riskOccupancy": "Risk occupancy",
|
||||
"riskLockLogs": "Lock logs",
|
||||
"riskHot": "Hot numbers",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"title": "Reconcile",
|
||||
"createTitle": "Run reconcile scan",
|
||||
"createHint": "Scans transfer orders in the selected period, compares lottery wallet ledgers, and checks main-site idempotent records when the wallet API is configured.",
|
||||
"workflowHint": "Run dated reconcile scans here. For live unresolved exceptions, use the link above (opens Wallet → Transfer orders with abnormal-only filter).",
|
||||
"shortcutAbnormalTransfers": "Live abnormal transfers",
|
||||
"viewOnlyHint": "View-only: historical reconcile jobs.",
|
||||
"createTitle": "Run scan",
|
||||
"createHint": "",
|
||||
"reconcileType": "Reconcile type",
|
||||
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
|
||||
"dateRange": "Reconcile date range",
|
||||
@@ -12,13 +15,18 @@
|
||||
"periodRequired": "Enter both reconcile start and end dates",
|
||||
"periodOrderInvalid": "End time must be later than or equal to start time",
|
||||
"confirmCreateTitle": "Start reconcile scan?",
|
||||
"confirmCreateDescription": "Scan transfer orders in the selected date range{{playerHint}} and generate discrepancy items.",
|
||||
"confirmCreateDescription": "Scan transfer orders in the selected period{{playerHint}}.",
|
||||
"confirmCreatePlayer": " for the selected player",
|
||||
"confirmCreateAllPlayers": " for all players",
|
||||
"createSuccess": "Scan finished: {{count}} issue(s) found",
|
||||
"createSuccessEmpty": "Scan finished: no issues found",
|
||||
"createFailed": "Scan failed",
|
||||
"noCreatePermission": "Current account cannot start reconcile scans.",
|
||||
"filterOpen": "Open only",
|
||||
"filterAll": "All items",
|
||||
"filterCount": "{{shown}} on page / {{total}} total",
|
||||
"filterOpenEmpty": "No open items on this page. Try All items or another page.",
|
||||
"playerEmpty": "No matching players",
|
||||
"jobsTitle": "Reconcile jobs",
|
||||
"refresh": "Refresh",
|
||||
"jobNo": "Job no.",
|
||||
@@ -31,13 +39,22 @@
|
||||
"finishedAt": "Finished at",
|
||||
"createdAt": "Created at",
|
||||
"operate": "Action",
|
||||
"viewDetails": "View discrepancy details",
|
||||
"viewDetails": "View details",
|
||||
"hideDetails": "Hide",
|
||||
"closeDetails": "Close",
|
||||
"detailsTitle": "Discrepancy details",
|
||||
"detailsEmpty": "No items",
|
||||
"itemStatusColumn": "Status",
|
||||
"itemIssueColumn": "Issue type",
|
||||
"itemHandlingColumn": "Handling",
|
||||
"itemScanFindingColumn": "Scan finding",
|
||||
"itemCurrentStatusColumn": "Current status",
|
||||
"itemScanFindingResolvedHint": "Recorded at scan; now handled",
|
||||
"transferNo": "Transfer no.",
|
||||
"walletTxnNo": "Lottery wallet txn",
|
||||
"mainSiteRef": "Main-site ref",
|
||||
"mainSiteCheck": "Main-site check",
|
||||
"differenceAmount": "Difference (minor)",
|
||||
"differenceAmount": "Difference",
|
||||
"itemResult": "Check result",
|
||||
"processingStatus": "Processing status",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"title": "Reports",
|
||||
"subtitle": "Centralized operational, finance, risk, and audit reports with unified export filters.",
|
||||
"subtitle": "",
|
||||
"profitScopeHint": "Ticket-level totals; not credit settlement periods",
|
||||
"shortcuts": {
|
||||
"wallet": "Transfer orders",
|
||||
"risk": "Risk center",
|
||||
"audit": "Audit logs"
|
||||
},
|
||||
"pageGuide": "P&L and risk by draw, player, and play type; export permission enables async jobs.",
|
||||
"exportPanel": "Export setup",
|
||||
"chooseReport": "Choose a report to export",
|
||||
@@ -86,8 +92,12 @@
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"subtitle": "Results appear below. Export as CSV or Excel.",
|
||||
"empty": "No data. Adjust filters and try again.",
|
||||
"subtitle": "",
|
||||
"empty": "No data",
|
||||
"sections": {
|
||||
"settlementBatches": "Settlement batches",
|
||||
"lockLogs": "Lock logs"
|
||||
},
|
||||
"exportableRows": "rows exportable",
|
||||
"summaryScopeHint": "Except for the total record count, the stat cards above summarize the current preview page. Use full CSV/Excel export for full-range numbers.",
|
||||
"scope": {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"unsettledTickets": "Unsettled tickets",
|
||||
"openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.",
|
||||
"viewDetail": "View details",
|
||||
"processBills": "Process bills",
|
||||
"close": "Close",
|
||||
"closeNow": "Close now",
|
||||
"hasOpen": "Period {{range}} is open. Close it before opening a new one.",
|
||||
@@ -66,6 +67,7 @@
|
||||
"periods": "Periods",
|
||||
"bills": "Bills",
|
||||
"operations": "Payments & adjustments",
|
||||
"aria": "Period views",
|
||||
"ledger": "Account ledger",
|
||||
"creditLedger": "Credit ledger",
|
||||
"playerBills": "Player bills",
|
||||
@@ -213,7 +215,8 @@
|
||||
"actions": {
|
||||
"detail": "Detail",
|
||||
"viewBill": "View bill",
|
||||
"billDetail": "Bill detail"
|
||||
"billDetail": "Bill detail",
|
||||
"billDetailWithId": "Bill #{{id}}"
|
||||
},
|
||||
"billDisplay": {
|
||||
"settlementFlow": "Who pays whom",
|
||||
@@ -250,6 +253,7 @@
|
||||
"unpaidAwaitingPayment": "Record offline payment",
|
||||
"fullySettled": "Fully settled this period",
|
||||
"confirmHint": "Confirm the bill before recording payment.",
|
||||
"advancedActions": "Adjust / bad debt",
|
||||
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
|
||||
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
|
||||
"rebateAllocationsHint": "How rebate is allocated across agent tiers.",
|
||||
@@ -282,6 +286,12 @@
|
||||
},
|
||||
"billsPanel": {
|
||||
"intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.",
|
||||
"confirmOneTitle": "Confirm bill #{{id}}?",
|
||||
"confirmOneDesc": "After confirm, record offline payment.",
|
||||
"confirmOneBtn": "Confirm",
|
||||
"confirmedOne": "Confirmed",
|
||||
"confirmFailed": "Confirm failed",
|
||||
"payBtn": "Pay",
|
||||
"hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down.",
|
||||
"quickFilter": {
|
||||
"title": "Which settlement layer do you want to review",
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"subnavLabel": "Wallet sub pages",
|
||||
"subnavTransactions": "Main-site wallet txns",
|
||||
"subnavTransferOrders": "Main-site transfers",
|
||||
"scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line period settlement, see",
|
||||
"scopeHintSettlementLink": "Settlement center",
|
||||
"scopeHintSettlement": "Settlement center",
|
||||
|
||||
"ledgerChannel": "Ledger",
|
||||
"ledgerCredit": "Credit ledger",
|
||||
"ledgerWallet": "Wallet txn",
|
||||
@@ -43,6 +41,9 @@
|
||||
"options": "Options",
|
||||
"abnormalOnly": "Abnormal only",
|
||||
"abnormalOnlyPending": "Abnormal only (pending reconcile)",
|
||||
"abnormalFilterActive": "Showing transfer orders flagged by reconciliation (not wallet transactions). Uncheck “Abnormal only” below to see all.",
|
||||
"txnAbnormalFilterActive": "Showing only pending-reconcile transactions. Status filter is ignored while this is on (same as abnormal=1).",
|
||||
"txnDeepLinkActive": "Filters were applied from the link. Reset clears URL parameters.",
|
||||
"search": "Search",
|
||||
"resetFilters": "Reset filters",
|
||||
"refreshCurrentPage": "Refresh current page",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"noRoles": "अहिले भूमिका डाटा छैन। सूची लोड भएपछि फेरि प्रयास गर्नुहोस्।",
|
||||
"password": "पासवर्ड",
|
||||
"passwordOptional": "पासवर्ड (वैकल्पिक)",
|
||||
"passwordPlaceholderCreate": "कम्तीमा 8 वर्ण",
|
||||
"passwordPlaceholderCreate": "कम्तीमा 6 वर्ण",
|
||||
"passwordPlaceholderEdit": "परिवर्तन नगर्न खाली छोड्नुहोस्",
|
||||
"rolesDescription": "सिर्जना भएपछि \"भूमिका तोक्नुहोस्\" मा गएर भूमिका बाइन्डिङ थप समायोजन गर्न सकिन्छ।",
|
||||
"rolesRequired": "भूमिका (पूर्वनिर्धारित साइट, कम्तीमा एक)",
|
||||
@@ -46,9 +46,9 @@
|
||||
"listTitle": "प्रशासक सूची",
|
||||
"loadFailed": "प्रशासक सूची लोड असफल भयो",
|
||||
"modelGuide": "खाता तहमा भूमिका मात्र बाँधिन्छ; कार्य अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
|
||||
"newPasswordMin": "नयाँ पासवर्ड कम्तीमा 8 वर्ण हुनुपर्छ",
|
||||
"newPasswordMin": "नयाँ पासवर्ड कम्तीमा 6 वर्ण हुनुपर्छ",
|
||||
"nicknameRequired": "उपनाम लेख्नुहोस्",
|
||||
"passwordMin": "पासवर्ड कम्तीमा 8 वर्ण हुनुपर्छ",
|
||||
"passwordMin": "पासवर्ड कम्तीमा 6 वर्ण हुनुपर्छ",
|
||||
"permissionDialog": {
|
||||
"rolePermissionCount": "{{count}} वटा कार्य अनुमति समावेश",
|
||||
"rolesDescription": "यहाँ प्रशासकलाई भूमिका मात्र जोडिन्छ। विस्तृत अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
|
||||
@@ -168,8 +168,18 @@
|
||||
"roleListTitle": "भूमिका व्यवस्थापन",
|
||||
"roleLoadFailed": "भूमिका सूची लोड असफल भयो",
|
||||
"rolePermissionDialog": {
|
||||
"title": "भूमिका अनुमति"
|
||||
"title": "भूमिका अनुमति",
|
||||
"titleWithName": "{{name}}"
|
||||
},
|
||||
"permissionAccess": {
|
||||
"none": "चाहिँदैन",
|
||||
"view": "हेर्न मात्र",
|
||||
"review": "समीक्षा",
|
||||
"export": "निर्यात",
|
||||
"manage": "व्यवस्थापन",
|
||||
"special": "विशेष अधिकार"
|
||||
},
|
||||
|
||||
"rolePermissionSaveFailed": "भूमिका अनुमति सुरक्षित गर्न असफल",
|
||||
"rolePermissionSaveSuccess": "भूमिका अनुमति अद्यावधिक भयो",
|
||||
"roleRequired": "कम्तीमा एउटा भूमिका छान्नुहोस्",
|
||||
@@ -178,7 +188,8 @@
|
||||
"actions": "कार्य",
|
||||
"name": "भूमिका",
|
||||
"permissions": "अनुमति संख्या",
|
||||
"slug": "角色 कोड",
|
||||
"enabledAreas": "खुला मोड्युल",
|
||||
"slug": "भूमिका कोड",
|
||||
"status": "स्थिति",
|
||||
"type": "प्रकार",
|
||||
"users": "सम्बन्धित प्रयोगकर्ता"
|
||||
|
||||
@@ -100,6 +100,11 @@
|
||||
},
|
||||
"listFlatHint": "सबै सञ्चालन एजेन्ट सूचीमा; अधीनस्थ थप्न पङ्क्ति मेनु प्रयोग गर्नुहोस्।",
|
||||
"listSearch": "नाम / कोड / लगइन खोज्नुहोस्",
|
||||
"viewModes": {
|
||||
"label": "दृश्य",
|
||||
"tree": "रूख",
|
||||
"list": "सूची"
|
||||
},
|
||||
"listTitle": "एजेन्ट सूची",
|
||||
"loadFailed": "Failed to load agent tree",
|
||||
"modelGuide": "एजेन्ट तहले डाटा स्कोप र delegation ceiling नियन्त्रण गर्छ; खाताको अनुमति भूमिका मार्फत बाँडिन्छ।",
|
||||
@@ -109,9 +114,9 @@
|
||||
"noAccess": "एजेन्ट सञ्चालन अनुमति छैन। प्रशासकलाई सम्पर्क गर्नुहोस्।",
|
||||
"pageGuide": "यहाँ एजेन्ट ट्री, एजेन्ट भूमिका, एजेन्ट खाता र delegation ceiling व्यवस्थापन गरिन्छ। प्लेटफर्म खाता र प्लेटफर्म भूमिका अलग पृष्ठमा राखिन्छ।",
|
||||
"parentAgent": "माथिल्लो",
|
||||
"passwordMinLength": "Password must be at least 8 characters",
|
||||
"passwordOptionalHint": "परिवर्तन नगर्ने भए खाली छोड्नुहोस्, परिवर्तन गर्न ८-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
||||
"passwordPlaceholder": "८-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
||||
"passwordMinLength": "Password must be at least 6 characters",
|
||||
"passwordOptionalHint": "परिवर्तन नगर्ने भए खाली छोड्नुहोस्, परिवर्तन गर्न कम्तीमा ६-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
||||
"passwordPlaceholder": "कम्तीमा ६-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
||||
"passwordRequired": "Password is required",
|
||||
"path": "Path",
|
||||
"playersPanel": {
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
{
|
||||
"title": "अडिट लग",
|
||||
"moduleCode": "मोड्युल कोड",
|
||||
"actionCode": "कार्य कोड",
|
||||
"operatorType": "अपरेटर प्रकार",
|
||||
"operatorIdPlaceholder": "अपरेटर ID प्रविष्ट गर्नुहोस्",
|
||||
"exactMatch": "ठ्याक्कै मिलान",
|
||||
"operatorTypePlaceholder": "जस्तै admin / system",
|
||||
"title": "सञ्चालन रेकर्ड",
|
||||
"filterModule": "व्यवसाय प्रकार",
|
||||
"filterModuleAll": "सबै",
|
||||
"filterOperatorType": "अपरेटर प्रकार",
|
||||
"filterOperatorTypeAll": "सबै",
|
||||
"operatorIdPlaceholder": "अपरेटर ID थाहा भए मात्र",
|
||||
"operator": "अपरेटर",
|
||||
"module": "मोड्युल",
|
||||
"action": "कार्य",
|
||||
"target": "लक्ष्य",
|
||||
"summary": "के भयो",
|
||||
"target": "सम्बन्धित",
|
||||
"time": "समय",
|
||||
"empty": "डाटा छैन",
|
||||
"empty": "रेकर्ड छैन",
|
||||
"moduleOptions": {
|
||||
"agent": "एजेन्ट",
|
||||
"system": "प्रणाली",
|
||||
"settings": "सेटिङ",
|
||||
"integration": "इन्टिग्रेसन",
|
||||
"player_manage": "खेलाडी व्यवस्थापन",
|
||||
"player_service": "खेलाडी सेवा",
|
||||
"risk_cap": "जोखिम सीमा",
|
||||
"odds": "बाधा",
|
||||
"play_config": "खेल कन्फिग",
|
||||
"settlement": "बन्दोबस्त",
|
||||
"report_jobs": "रिपोर्ट",
|
||||
"reconcile_jobs": "मिलान",
|
||||
"draw": "ड्र",
|
||||
"wallet": "वालेट",
|
||||
"reconcile": "मिलान",
|
||||
"jackpot": "ज्याकपट",
|
||||
"dashboard": "ड्यासबोर्ड"
|
||||
},
|
||||
"operatorTypes": {
|
||||
"admin": "प्रशासक",
|
||||
"player": "खेलाडी",
|
||||
"system": "प्रणाली"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"captchaLoadFailed": "क्याप्चा लोड गर्न सकिएन। API वा नेटवर्क जाँच गर्नुहोस्।",
|
||||
"apiBaseMissingToast": "API proxy सक्षम छैन: LOTTERY_API_UPSTREAM Laravel तर्फ छ कि छैन जाँच गर्नुहोस्",
|
||||
"captchaRequired": "पहिले क्याप्चा रिफ्रेस गर्नुहोस्",
|
||||
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||
"welcome": "स्वागत छ, {{name}}",
|
||||
"networkFailed": "नेटवर्क अनुरोध असफल भयो",
|
||||
"loginFailed": "लगइन असफल भयो"
|
||||
|
||||
@@ -165,9 +165,8 @@
|
||||
"nav": {
|
||||
"account": "खाता सेटिङ",
|
||||
"admin_roles": "भूमिका व्यवस्थापन",
|
||||
"admin_users": "प्रशासक सूची",
|
||||
"agent_list": "एजेन्ट सूची",
|
||||
"agents": "एजेन्ट लाइन",
|
||||
"admin_users": "प्लेटफर्म खाता",
|
||||
"agents": "एजेन्ट व्यवस्थापन",
|
||||
"audit": "अडिट लग",
|
||||
"config": "सञ्चालन कन्फिगरेसन",
|
||||
"currencies": "मुद्रा व्यवस्थापन",
|
||||
@@ -186,8 +185,8 @@
|
||||
"rules_plays": "खेल नियम",
|
||||
"settings": "सेटिङ",
|
||||
"settlement": "सेटलमेन्ट",
|
||||
"settlement_center": "सेटलमेन्ट केन्द्र",
|
||||
"tickets": "टिकट सूची",
|
||||
"settlement_center": "क्रेडिट सेटलमेन्ट",
|
||||
"tickets": "टिकट",
|
||||
"wallet": "वालेट"
|
||||
},
|
||||
"pagination": {
|
||||
@@ -217,9 +216,9 @@
|
||||
"securitySettingsDesc": "लगइन पासवर्ड परिवर्तन गर्नुहोस्। नपरिवर्तन गर्दा खाली छोड्नुहोस्।",
|
||||
"sidebar": {
|
||||
"group": {
|
||||
"agent": "एजेन्ट संगठन",
|
||||
"agent": "एजेन्ट",
|
||||
"finance": "वित्त र रिपोर्ट",
|
||||
"operations": "दैनिक सञ्चालन",
|
||||
"operations": "सञ्चालन",
|
||||
"overview": "सारांश",
|
||||
"platform": "प्लेटफर्म",
|
||||
"rules": "नियम र प्यारामिटर"
|
||||
@@ -257,6 +256,7 @@
|
||||
},
|
||||
"validation": {
|
||||
"passwordMismatch": "पासवर्ड मिलेन",
|
||||
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||
"required": "{{field}} अनिवार्य छ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"pageTitle": "प्रणाली सेटिङ",
|
||||
"confirmSaveCurrencyFormatDescription": "यसले दशमलव स्थान र विभाजक अद्यावधिक गर्छ।",
|
||||
"confirmSaveCurrencyFormatTitle": "मुद्रा प्रदर्शन ढाँचा बचत गर्ने?",
|
||||
"confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।",
|
||||
@@ -582,12 +583,33 @@
|
||||
"saveSettlementSuccess": "सेटलमेन्ट स्वचालन बचत भयो",
|
||||
"saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो",
|
||||
"sections": {
|
||||
"deployment": "डिप्लोयमेन्ट (पढ्न मात्र)",
|
||||
"deploymentDescription": "पूर्वनिर्धारित मुद्रा र ड्र तालिका सर्भर .env मा सेट हुन्छ; यहाँ प्रभावी मान देखिन्छ।",
|
||||
"currencyFormat": "मुद्रा प्रदर्शन ढाँचा",
|
||||
"currencyFormatDescription": "साइटभरि रकमका दशमलव र विभाजक (मुद्रा मास्टर डाटाबाट अलग)।",
|
||||
"draw": "ड्र तालिका र समीक्षा",
|
||||
"drawDescription": "ड्र समय, बन्द सञ्झ्याल, म्यानुअल समीक्षा र कूलडाउन नियन्त्रण। यो ब्लकमा परिवर्तित फिल्ड मात्र पेश हुन्छ।",
|
||||
"draw": "ड्रअ समीक्षा",
|
||||
"drawDescription": "RNG पछि म्यानुअल समीक्षा र प्रकाशनपछि कूलडाउन।",
|
||||
"settlement": "सेटलमेन्ट स्वचालन",
|
||||
"settlementDescription": "टिक स्वतः सेटलमेन्ट, अनुमोदन र भुक्तानी चलाउने नियन्त्रण। यो ब्लकमा परिवर्तित फिल्ड मात्र पेश हुन्छ।"
|
||||
"settlementDescription": "क्रम: स्वतः सेटलमेन्ट → स्वतः अनुमोदन → स्वतः भुक्तानी। बन्द हुँदा सम्बन्धित मोड्युलमा म्यानुअल गर्नुहोस्।"
|
||||
},
|
||||
"deployment": {
|
||||
"footer": "परिवर्तनपछि PHP पुनः सुरु वा config cache refresh गर्नुहोस्।",
|
||||
"fields": {
|
||||
"default_currency": "पूर्वनिर्धारित मुद्रा",
|
||||
"draw_timezone": "ड्रअ समय क्षेत्र",
|
||||
"draw_interval_minutes": "ड्रअ अन्तराल (मिनेट)",
|
||||
"draw_betting_window_seconds": "बेटिङ विन्डो (सेकेन्ड)",
|
||||
"draw_close_before_draw_seconds": "ड्रअ अघि बन्द (सेकेन्ड)",
|
||||
"draw_buffer_draws_ahead": "अग्रिम सिर्जना गरिने ड्रअ"
|
||||
},
|
||||
"hints": {
|
||||
"default_currency": "नयाँ खेलाडीको पूर्वनिर्धारित मुद्रा",
|
||||
"draw_timezone": "ड्रअ तालिका र प्रदर्शनको समय क्षेत्र",
|
||||
"draw_interval_minutes": "लगातार ड्रअ बीचको मिनेट",
|
||||
"draw_betting_window_seconds": "प्रति ड्रअ बेटिङ खुला रहने समय",
|
||||
"draw_close_before_draw_seconds": "ड्रअ अघि बेट बन्द हुने सेकेन्ड",
|
||||
"draw_buffer_draws_ahead": "अग्रिम सिर्जना गरिने भविष्य ड्रअ संख्या"
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"disabled": "बन्द",
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
"loadFailed": "加载管理员列表失败",
|
||||
"roleLoadFailed": "加载角色列表失败",
|
||||
"nicknameRequired": "请填写昵称",
|
||||
"newPasswordMin": "新密码至少 8 位",
|
||||
"newPasswordMin": "新密码至少 6 位",
|
||||
"roleRequired": "请至少选择一个角色",
|
||||
"usernameRequired": "请填写登录账号",
|
||||
"passwordMin": "密码至少 8 位",
|
||||
"passwordMin": "密码至少 6 位",
|
||||
"createSuccess": "已创建管理员 {{name}}",
|
||||
"updateSuccess": "已更新 {{name}}",
|
||||
"saveAccountFailed": "保存账号失败",
|
||||
@@ -69,6 +69,7 @@
|
||||
"status": "状态",
|
||||
"users": "关联用户",
|
||||
"permissions": "权限数",
|
||||
"enabledAreas": "开放模块",
|
||||
"actions": "操作"
|
||||
},
|
||||
"roleActions": {
|
||||
@@ -101,8 +102,17 @@
|
||||
},
|
||||
"rolePermissionDialog": {
|
||||
"title": "角色权限",
|
||||
"packageHint": "勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。"
|
||||
"titleWithName": "{{name}}"
|
||||
},
|
||||
"permissionAccess": {
|
||||
"none": "不需要",
|
||||
"view": "仅查看",
|
||||
"review": "可审核",
|
||||
"export": "可导出",
|
||||
"manage": "可管理",
|
||||
"special": "含特权操作"
|
||||
},
|
||||
|
||||
"roleDialog": {
|
||||
"createTitle": "新增角色",
|
||||
"editTitle": "编辑角色",
|
||||
@@ -128,7 +138,7 @@
|
||||
"emailPlaceholder": "留空则不填",
|
||||
"password": "密码",
|
||||
"passwordOptional": "密码(可选)",
|
||||
"passwordPlaceholderCreate": "至少 8 位",
|
||||
"passwordPlaceholderCreate": "至少 6 位",
|
||||
"passwordPlaceholderEdit": "不修改请留空",
|
||||
"site": "绑定站点",
|
||||
"sitePlaceholder": "选择该账号可访问的数据站点",
|
||||
@@ -159,7 +169,7 @@
|
||||
"permissionGroups": {
|
||||
"all": "全部权限",
|
||||
"dashboard": "仪表盘",
|
||||
"admin_users": "管理列表",
|
||||
"admin_users": "平台账号",
|
||||
"admin_roles": "角色管理",
|
||||
"agents": "代理管理",
|
||||
"players": "玩家列表",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"overviewDownlineCount": "{{count}} 个",
|
||||
"downlineEmptyTitle": "暂无直属下级",
|
||||
"tabProfile": "占成与授信",
|
||||
"tabProfileShort": "占成授信",
|
||||
"tabProfileReadOnly": "占成与授信(只读)",
|
||||
"profileReadOnlyHint": "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
||||
"selfAgentOverviewHint": "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
|
||||
@@ -42,7 +43,8 @@
|
||||
"downlineCount": "下级数"
|
||||
},
|
||||
"editAccount": "账号与状态",
|
||||
"editAgent": "编辑代理",
|
||||
"editAgent": "编辑",
|
||||
"parentMeta": "上级 {{name}}",
|
||||
"editCurrent": "编辑本代理",
|
||||
"saveProfile": "保存占成与授信",
|
||||
"profileTabHint": "占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
|
||||
@@ -56,7 +58,13 @@
|
||||
"sidebarShareRate": "占成 {{rate}}%",
|
||||
"sidebarAvailableCredit": "可下发 {{amount}}",
|
||||
"expand": "展开",
|
||||
"collapse": "收起"
|
||||
"collapse": "收起",
|
||||
"openInTree": "在树中打开"
|
||||
},
|
||||
"viewModes": {
|
||||
"label": "视图",
|
||||
"tree": "树形",
|
||||
"list": "列表"
|
||||
},
|
||||
"listTitle": "代理列表",
|
||||
"listSearch": "搜索代理名称 / 编码 / 登录名",
|
||||
@@ -132,7 +140,7 @@
|
||||
"usernameRequired": "请填写登录名",
|
||||
"usernameInvalidCharset": "登录名只能使用字母、数字、点(.)、下划线和连字符",
|
||||
"passwordRequired": "请填写密码",
|
||||
"passwordMinLength": "密码至少 8 位",
|
||||
"passwordMinLength": "密码至少 6 位",
|
||||
"bindAccountPasswordRequired": "该代理尚未绑定登录账号,请填写初始密码以创建账号",
|
||||
"bindAccountHint": "该代理尚无登录账号,保存时将自动创建并绑定。",
|
||||
"modelGuide": "代理层负责数据范围(Scope)与授权上限(Ceiling),账号权限请通过角色分配。",
|
||||
@@ -328,7 +336,7 @@
|
||||
"name": "一级代理名称",
|
||||
"username": "后台登录账号",
|
||||
"password": "初始密码",
|
||||
"passwordHint": "至少 8 位",
|
||||
"passwordHint": "至少 6 位",
|
||||
"submit": "创建一级代理",
|
||||
"success": "一级代理已创建",
|
||||
"link": "创建一级代理"
|
||||
@@ -343,8 +351,8 @@
|
||||
"loginRequired": "请填写登录账号与初始密码",
|
||||
"loginUsername": "登录账号",
|
||||
"initialPassword": "初始密码",
|
||||
"passwordHint": "至少 8 位",
|
||||
"passwordMinLength": "初始密码至少 8 位",
|
||||
"passwordHint": "至少 6 位",
|
||||
"passwordMinLength": "初始密码至少 6 位",
|
||||
"externalIdOptional": "外部 ID(可选)",
|
||||
"externalIdHint": "留空则系统自动生成",
|
||||
"creditLimit": "授信额度",
|
||||
@@ -363,7 +371,11 @@
|
||||
"playerRef": "玩家标识",
|
||||
"usernameNickname": "用户名 / 昵称",
|
||||
"creditLimitAvailable": "授信 / 可用",
|
||||
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端 /login 登录"
|
||||
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端 /login 登录",
|
||||
"searchPh": "搜索玩家账号 / 标识",
|
||||
"manageSettlement": "处理账单",
|
||||
"noPendingBillsGoCenter": "该玩家暂无待处理账单,已打开结算中心。",
|
||||
"billingLoadFailed": "加载账单失败"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "下放权限上限",
|
||||
@@ -406,6 +418,6 @@
|
||||
"deleteSuccess": "已删除账号 {{name}}"
|
||||
},
|
||||
"usernamePlaceholder": "请输入登录名",
|
||||
"passwordPlaceholder": "请输入8位数密码",
|
||||
"passwordOptionalHint": "留空则不修改,修改请输入8位数密码"
|
||||
"passwordPlaceholder": "请输入至少6位密码",
|
||||
"passwordOptionalHint": "留空则不修改,修改请输入至少6位密码"
|
||||
}
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
{
|
||||
"title": "审计日志",
|
||||
"moduleCode": "模块",
|
||||
"actionCode": "动作",
|
||||
"operatorType": "操作者类型",
|
||||
"operatorIdPlaceholder": "请输入操作人 ID",
|
||||
"exactMatch": "请输入完整名称",
|
||||
"operatorTypePlaceholder": "如管理员、系统",
|
||||
"operator": "操作者",
|
||||
"module": "模块",
|
||||
"action": "动作",
|
||||
"target": "目标",
|
||||
"title": "操作记录",
|
||||
"filterModule": "业务类型",
|
||||
"filterModuleAll": "全部业务",
|
||||
"filterOperatorType": "操作人类型",
|
||||
"filterOperatorTypeAll": "全部",
|
||||
"operatorIdPlaceholder": "知道操作人编号时可填写",
|
||||
"operator": "操作人",
|
||||
"summary": "操作说明",
|
||||
"target": "涉及对象",
|
||||
"time": "时间",
|
||||
"empty": "无数据",
|
||||
"empty": "暂无记录",
|
||||
"moduleOptions": {
|
||||
"agent": "代理管理",
|
||||
"system": "系统管理",
|
||||
"settings": "系统设置",
|
||||
"integration": "接入站点",
|
||||
"player_manage": "玩家管理",
|
||||
"player_service": "玩家服务",
|
||||
"risk_cap": "限额规则",
|
||||
"odds": "赔率配置",
|
||||
"play_config": "玩法配置",
|
||||
"settlement": "派彩结算",
|
||||
"report_jobs": "报表导出",
|
||||
"reconcile_jobs": "对账任务",
|
||||
"draw": "开奖期号",
|
||||
"wallet": "钱包资金",
|
||||
"reconcile": "对账核对",
|
||||
"jackpot": "奖池",
|
||||
"dashboard": "工作台"
|
||||
},
|
||||
"operatorTypes": {
|
||||
"admin": "管理员",
|
||||
"admin": "后台管理员",
|
||||
"player": "玩家",
|
||||
"system": "系统"
|
||||
"system": "系统自动"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"captchaLoadFailed": "无法获取验证码,请检查接口或网络",
|
||||
"apiBaseMissingToast": "API 代理未启用:请检查 LOTTERY_API_UPSTREAM 是否指向 Laravel",
|
||||
"captchaRequired": "请先刷新验证码",
|
||||
"passwordMinLength": "密码至少需要 6 个字符",
|
||||
"welcome": "欢迎,{{name}}",
|
||||
"networkFailed": "网络请求失败",
|
||||
"loginFailed": "登录失败"
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
},
|
||||
"validation": {
|
||||
"required": "请填写{{field}}",
|
||||
"passwordMismatch": "两次输入的密码不一致"
|
||||
"passwordMismatch": "两次输入的密码不一致",
|
||||
"passwordMinLength": "密码至少需要 6 个字符"
|
||||
},
|
||||
"aria": {
|
||||
"expand": "展开",
|
||||
@@ -156,7 +157,7 @@
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"dashboard": "仪表盘",
|
||||
"admin_users": "管理列表",
|
||||
"admin_users": "平台账号",
|
||||
"admin_roles": "角色管理",
|
||||
"players": "玩家列表",
|
||||
"currencies": "币种管理",
|
||||
@@ -171,25 +172,24 @@
|
||||
"settlement": "结算",
|
||||
"jackpot": "奖池",
|
||||
"reconcile": "对账",
|
||||
"tickets": "注单列表",
|
||||
"tickets": "注单",
|
||||
"audit": "审计日志",
|
||||
"settings": "系统设置",
|
||||
"account": "账号设置",
|
||||
"integration": "接入配置",
|
||||
"agents": "代理线路",
|
||||
"agent_list": "代理列表",
|
||||
"settlement_center": "结算中心",
|
||||
"agents": "代理管理",
|
||||
"settlement_center": "信用结算",
|
||||
"config": "运营配置"
|
||||
},
|
||||
"sidebar": {
|
||||
"workspace": "工作台",
|
||||
"group": {
|
||||
"overview": "总览",
|
||||
"agent": "代理组织",
|
||||
"operations": "日常运营",
|
||||
"agent": "代理",
|
||||
"operations": "运营",
|
||||
"finance": "资金与报表",
|
||||
"rules": "规则与参数",
|
||||
"platform": "平台管理"
|
||||
"platform": "平台"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"rulesPlaysTitle": "投注规则",
|
||||
"rulesOddsTitle": "赔率与基础回水",
|
||||
"rulesOddsDescription": "赔率矩阵与基础回水在同一页维护,共用赔率版本线。",
|
||||
"rulesOddsDescriptionShort": "左侧选玩法,右侧修改赔率与基础回水;代理/玩家回水需在此基础上叠加,修改后记得保存草稿并发布。",
|
||||
"rulesOddsDescriptionShort": "",
|
||||
"riskCapTitle": "限额版本"
|
||||
},
|
||||
"hub": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"codeRequired": "请填写 site_code",
|
||||
"adminUsernameRequired": "请填写站点后台登录名",
|
||||
"adminNicknameRequired": "请填写站点后台账号昵称",
|
||||
"adminPasswordRequired": "请填写至少 8 位的站点后台初始密码"
|
||||
"adminPasswordRequired": "请填写至少 6 位的站点后台初始密码"
|
||||
},
|
||||
"columns": {
|
||||
"code": "site_code",
|
||||
@@ -135,7 +135,7 @@
|
||||
"name": "请输入站点名称",
|
||||
"adminUsername": "请输入后台登录名",
|
||||
"adminNickname": "请输入账号昵称",
|
||||
"adminPassword": "至少 8 位",
|
||||
"adminPassword": "至少 6 位",
|
||||
"adminEmail": "请输入邮箱",
|
||||
"currency": "请输入币种代码,如 NPR",
|
||||
"walletApiUrl": "请输入钱包接口地址",
|
||||
@@ -153,11 +153,9 @@
|
||||
"archived": "已归档"
|
||||
},
|
||||
"versionSwitcher": {
|
||||
"sheetTitle": "切换配置版本",
|
||||
"sheetDescription": "选择一条版本在本页查看;草稿可编辑,生效中与已归档为只读。",
|
||||
"sheetTitle": "版本",
|
||||
"loading": "加载中…",
|
||||
"noneSelected": "未选择版本",
|
||||
"switch": "切换版本",
|
||||
"noneSelected": "选择版本",
|
||||
"empty": "暂无版本记录。",
|
||||
"count": "{{count}} 条",
|
||||
"effectiveAt": "生效时间:{{value}}",
|
||||
@@ -169,7 +167,7 @@
|
||||
"rollback": "回滚",
|
||||
"delete": "删除",
|
||||
"deleteConfirmTitle": "确认删除版本?",
|
||||
"deleteConfirmDescription": "将永久删除版本 ID {{id}}(version_no {{version}})。生效中的版本不可删除。"
|
||||
"deleteConfirmDescription": "将删除版本 v{{version}},此操作不可恢复。"
|
||||
},
|
||||
"versionToolbar": {
|
||||
"draftEditing": "正在编辑草稿,保存并发布后生效"
|
||||
@@ -221,6 +219,7 @@
|
||||
"confirmSaveDescription": "将更新转入/转出单笔限额,立即影响玩家钱包转账。"
|
||||
},
|
||||
"system": {
|
||||
"pageTitle": "系统设置",
|
||||
"title": "开奖与结算运行参数",
|
||||
"runtimeTitle": "全局运行参数",
|
||||
"runtimeIntro1": "这里放不属于玩法版本、赔率版本、风控版本的全局系统参数。它们会直接影响钱包转账、任务开关或系统运行策略。",
|
||||
@@ -234,12 +233,33 @@
|
||||
"saveSettlementSuccess": "结算自动化参数已保存",
|
||||
"saveFrontendSuccess": "前端展示配置已保存",
|
||||
"sections": {
|
||||
"draw": "开奖节奏与审核",
|
||||
"drawDescription": "控制期号节奏、封盘与开奖后人工审核、冷静期。仅保存本区块内修改过的项。",
|
||||
"deployment": "部署参数(只读)",
|
||||
"deploymentDescription": "默认币种、期号节奏等由运维在服务器 .env 配置,此处展示当前生效值。",
|
||||
"draw": "开奖审核",
|
||||
"drawDescription": "控制开奖后是否必须人工审核,以及结果发布后的冷静期。",
|
||||
"currencyFormat": "金额显示格式",
|
||||
"currencyFormatDescription": "全站金额展示的小数位与分隔符,与币种主数据无关。",
|
||||
"settlement": "结算自动化",
|
||||
"settlementDescription": "控制 tick 是否自动结算、审核与派彩。修改后只提交本区块变更项。"
|
||||
"settlementDescription": "按顺序:自动结算 → 自动审核批次 → 自动派彩。关闭后需到对应模块手工处理。"
|
||||
},
|
||||
"deployment": {
|
||||
"footer": "修改后需重启 PHP 应用或刷新配置缓存方生效;如有疑问请联系运维。",
|
||||
"fields": {
|
||||
"default_currency": "默认币种",
|
||||
"draw_timezone": "期号时区",
|
||||
"draw_interval_minutes": "开奖间隔(分钟)",
|
||||
"draw_betting_window_seconds": "下注窗口(秒)",
|
||||
"draw_close_before_draw_seconds": "封盘提前(秒)",
|
||||
"draw_buffer_draws_ahead": "预生成未来期数"
|
||||
},
|
||||
"hints": {
|
||||
"default_currency": "新玩家建档、未指定币种时的默认值",
|
||||
"draw_timezone": "期号计划与展示使用的时区",
|
||||
"draw_interval_minutes": "相邻两期开奖时间的间隔",
|
||||
"draw_betting_window_seconds": "每期允许下注的时长",
|
||||
"draw_close_before_draw_seconds": "开奖前停止收单的提前秒数",
|
||||
"draw_buffer_draws_ahead": "调度提前生成的未开奖期数"
|
||||
}
|
||||
},
|
||||
"saveFailed": "系统设置保存失败",
|
||||
"unsavedChanges": "有未保存的更改",
|
||||
@@ -291,7 +311,7 @@
|
||||
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
|
||||
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
|
||||
"confirmSaveDrawTitle": "确认保存开奖参数?",
|
||||
"confirmSaveDrawDescription": "将更新开奖审核、期号节奏与冷静期等本区块字段。",
|
||||
"confirmSaveDrawDescription": "将更新开奖人工审核开关与冷静期时长。",
|
||||
"confirmSaveCurrencyFormatTitle": "确认保存金额显示格式?",
|
||||
"confirmSaveCurrencyFormatDescription": "将更新小数位与千分位/小数分隔符。",
|
||||
"confirmSaveSettlementTitle": "确认保存结算自动化?",
|
||||
@@ -367,15 +387,15 @@
|
||||
"publishFailed": "发布失败",
|
||||
"publishDialog": {
|
||||
"title": "确认发布玩法配置?",
|
||||
"description": "新配置将影响后续下注;已下注注单仍按各自快照结算。",
|
||||
"description": "发布后对新注单生效。",
|
||||
"confirm": "确认发布"
|
||||
},
|
||||
"batchSwitchConfirmTitle": "确认批量{{action}}?",
|
||||
"batchSwitchConfirmDescription": "将{{action}}「{{group}}」下 {{count}} 个玩法,并写入当前草稿。",
|
||||
"batchSwitchConfirmDescription": "将{{action}}「{{group}}」下 {{count}} 个玩法。",
|
||||
"batchSwitchEnable": "开启",
|
||||
"batchSwitchDisable": "关闭",
|
||||
"toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}?",
|
||||
"toggleConfirmDescription": "将写入当前草稿;保存并发布后才会影响玩家端。",
|
||||
"toggleConfirmDescription": "将写入当前草稿。",
|
||||
"batchPartialEnabled": "{{enabledCount}}/{{total}} 已开启(未全开,打开开关将全部开启)",
|
||||
"toggleEnable": "开启",
|
||||
"toggleDisable": "关闭",
|
||||
@@ -513,8 +533,8 @@
|
||||
"loadingDetails": "正在加载详情…",
|
||||
"multiplier": "倍数 x{{value}} · {{currency}}",
|
||||
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
|
||||
"rebateRate": "基础回水比例 (%)",
|
||||
"rebateRateHint": "这里维护的是平台基础回水,会把 rebate_rate 写入该玩法下所有奖级范围;代理/玩家回水需在此基础上叠加。",
|
||||
"rebateRate": "基础回水 (%)",
|
||||
"rebateRateHint": "",
|
||||
"placeholders": {
|
||||
"multiplier": "请输入赔率倍数",
|
||||
"rebateRate": "请输入基础回水比例"
|
||||
@@ -532,7 +552,7 @@
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "确认发布赔率版本?",
|
||||
"description": "新赔率会立即影响后续新注单;已成功下注的历史注单仍按各自保存的赔率快照结算。",
|
||||
"description": "发布后对新注单生效。",
|
||||
"confirm": "确认发布",
|
||||
"columns": {
|
||||
"prizeScope": "奖级范围",
|
||||
@@ -608,14 +628,13 @@
|
||||
"readOnlyHint": "当前为只读,请先创建草稿。",
|
||||
"readOnly": "只读",
|
||||
"defaultCap": {
|
||||
"title": "默认封顶",
|
||||
"description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。",
|
||||
"fieldLabel": "封顶金额(主币单位)"
|
||||
"title": "默认限额",
|
||||
"fieldLabel": "限额金额"
|
||||
},
|
||||
"specialCaps": {
|
||||
"title": "特殊封顶",
|
||||
"description": "不选期号时表示全局号码限额;选择期号后表示该期单独限额。"
|
||||
"title": "号码限额"
|
||||
},
|
||||
|
||||
"scope": {
|
||||
"global": "全局号码",
|
||||
"drawId": "期号 ID:{{id}}"
|
||||
@@ -641,6 +660,7 @@
|
||||
"noDetailRows": "暂无明细行。",
|
||||
"table": {
|
||||
"scope": "作用范围",
|
||||
"draw": "期号",
|
||||
"number": "号码",
|
||||
"capAmount": "封顶金额",
|
||||
"used": "已占用",
|
||||
@@ -668,7 +688,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"update": "更新",
|
||||
"addSpecialCap": "+ 新增特殊封顶",
|
||||
"addNumberCap": "添加号码限额",
|
||||
"addSpecialCap": "新增",
|
||||
"filterPresets": "筛选预设…",
|
||||
"exportCsv": "导出 CSV",
|
||||
"close": "关闭"
|
||||
|
||||
@@ -67,11 +67,11 @@
|
||||
"scheduleTitle": "时间安排",
|
||||
"resultBatchesTitle": "开奖批次",
|
||||
"batchSummaryTotal": "共 {{count}} 批",
|
||||
"batchSummaryPending": "待审 {{count}}",
|
||||
"batchSummaryPending": "待发布 {{count}}",
|
||||
"batchSummaryPublished": "已发 {{count}}",
|
||||
"noResultBatchesYet": "尚无开奖批次。",
|
||||
"reviewQueueHint": "结果生成后,可前往审核与发布处理。",
|
||||
"goToReviewTab": "去审核与发布",
|
||||
"reviewQueueHint": "",
|
||||
"goToReviewTab": "开奖发布",
|
||||
"businessDate": "业务日",
|
||||
"sequenceNo": "流水序号",
|
||||
"plannedDraw": "计划开奖",
|
||||
@@ -96,12 +96,12 @@
|
||||
"published": "已发布",
|
||||
"viewFinance": "查看期号收支",
|
||||
"drawActions": "期号操作",
|
||||
"drawActionsDesc": "手动封盘 / 取消 / RNG / 重开 / 触发结算均直接调用后台接口。",
|
||||
"drawActionsDesc": "",
|
||||
"manualClose": "手动封盘",
|
||||
"cancelDraw": "取消期号",
|
||||
"cancelBeforeDraw": "未开奖前取消",
|
||||
"rngDraw": "RNG开奖",
|
||||
"rngAutoGenerate": "RNG 自动生成",
|
||||
"rngAutoGenerate": "自动开奖",
|
||||
"reopen": "重开",
|
||||
"cooldownReopen": "冷静期重开",
|
||||
"runSettlement": "触发结算",
|
||||
@@ -114,8 +114,8 @@
|
||||
"actualBet": "当期实扣投注",
|
||||
"currentPayout": "当期派彩合计",
|
||||
"grossProfit": "近似毛损益",
|
||||
"settlementBatchList": "结算批次列表(按期号筛选)",
|
||||
"relatedSettlementBatches": "本关联期结算批次",
|
||||
"settlementBatchList": "结算记录",
|
||||
"relatedSettlementBatches": "结算批次",
|
||||
"noSettlementBatches": "暂无结算批次记录。",
|
||||
"ticketCount": "票数",
|
||||
"winCount": "中奖数",
|
||||
@@ -124,7 +124,7 @@
|
||||
"resultsTitle": "开奖结果",
|
||||
"reviewAndPublish": "去审核 / 发布",
|
||||
"viewReviewQueue": "查看审核队列",
|
||||
"noPublishedBatch": "暂无已发布批次。",
|
||||
"noPublishedBatch": "暂无",
|
||||
"version": "版本 v{{version}}",
|
||||
"sourceType": "生成方式 {{source}}",
|
||||
"manualEntry": "人工录入",
|
||||
@@ -135,28 +135,28 @@
|
||||
"tail3": "尾3",
|
||||
"tail2": "尾2",
|
||||
"headTail": "头/尾",
|
||||
"manualResultEntry": "人工录入开奖结果",
|
||||
"currentStatusAndDraft": "当前状态 {{status}} · 保存后生成待确认批次,不会直接发布",
|
||||
"currentStatusLabel": "当前状态",
|
||||
"currentStatusDraftHint": "保存后生成待确认批次,不会直接发布",
|
||||
"manualResultEntry": "录入号码",
|
||||
"currentStatusAndDraft": "",
|
||||
"currentStatusLabel": "状态",
|
||||
"currentStatusDraftHint": "",
|
||||
"hallPreviewStatusLabel": "大厅预览",
|
||||
"enter23Numbers": "请完整输入 23 组 4 位数字",
|
||||
"draftSaved": "已保存草稿 v{{version}},等待确认发布",
|
||||
"draftSaved": "已保存 v{{version}}",
|
||||
"saveFailed": "保存失败",
|
||||
"fillRandomNumbers": "随机填满",
|
||||
"clear": "清空",
|
||||
"saveDraft": "保存草稿",
|
||||
"saving": "保存中…",
|
||||
"pendingBatches": "待确认批次",
|
||||
"noPendingBatches": "当前没有待审核批次。",
|
||||
"batchId": "批次 ID",
|
||||
"pendingBatches": "待发布",
|
||||
"noPendingBatches": "暂无",
|
||||
"batchId": "批次",
|
||||
"numberCount": "号码条数",
|
||||
"reviewAndPublishAction": "核对并发布",
|
||||
"discardPendingBatch": "删除草稿",
|
||||
"reviewAndPublishAction": "发布",
|
||||
"discardPendingBatch": "删除",
|
||||
"discardingPendingBatch": "删除中…",
|
||||
"discardPendingBatchSuccess": "已删除待确认批次,可重新录入或 RNG",
|
||||
"discardPendingBatchFailed": "删除失败",
|
||||
"publishReadOnlyHint": "发布页仅用于核对;若要改号请先删除本批次,回到上方「人工录入」重新保存。",
|
||||
"publishReadOnlyHint": "",
|
||||
"noPublishPermission": "无发布权限",
|
||||
"batchNotFound": "未找到批次",
|
||||
"batchNotFoundDesc": "请返回审核列表确认 batch id。",
|
||||
@@ -173,10 +173,10 @@
|
||||
"publishFailed": "发布失败",
|
||||
"sourceTypeFull": "生成方式:{{source}} · 号码条数:{{count}}/23 · RNG 摘要:{{hash}}",
|
||||
"subnav": {
|
||||
"status": "期号状态",
|
||||
"status": "概览",
|
||||
"results": "开奖结果",
|
||||
"finance": "期号收支",
|
||||
"review": "审核与发布",
|
||||
"finance": "收支",
|
||||
"review": "开奖发布",
|
||||
"riskOccupancy": "风控占用",
|
||||
"riskLockLogs": "占用流水",
|
||||
"riskHot": "热门号码",
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
{
|
||||
"title": "奖池",
|
||||
"configTitle": "奖池配置",
|
||||
"pageDescription": "维护各币种奖池参数,下方可查询蓄水与派彩流水。",
|
||||
"poolsSectionDescription": "蓄水比例、爆池阈值、开关与手动爆池。",
|
||||
"rulesTitle": "规则说明",
|
||||
"rulesJoin": "只有提交成功且满足最低参与下注额的注项,才会按蓄水比例进入奖池。",
|
||||
"rulesBurst": "奖池会在达到爆池阈值、达到强制爆池间隔,或命中指定组合触发玩法时释放。",
|
||||
"rulesManual": "手动爆池仅限超管兜底使用,可填写后台期号数字 ID 或期号编码。",
|
||||
"recordsSectionTitle": "蓄水与派彩流水",
|
||||
"recordsSectionDescription": "按条件筛选派彩记录与蓄水明细,只读查询。",
|
||||
"configTitle": "奖池",
|
||||
"pageTabs": "奖池",
|
||||
"tabConfig": "配置",
|
||||
"tabRecords": "流水",
|
||||
"loadFailed": "加载失败",
|
||||
"saveSuccess": "已保存",
|
||||
"saveFailed": "保存失败",
|
||||
@@ -19,7 +14,6 @@
|
||||
"displayBalance": "展示余额 {{amount}}",
|
||||
"currentAmount": "当前池余额(主币单位)",
|
||||
"balanceAdjustmentTitle": "余额调整",
|
||||
"balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。",
|
||||
"adjustmentDirection": "方向",
|
||||
"adjustmentIncrease": "增加",
|
||||
"adjustmentDecrease": "减少",
|
||||
@@ -35,31 +29,25 @@
|
||||
"confirmAdjustmentTitle": "确认提交奖池余额调整?",
|
||||
"confirmAdjustmentDescription": "将写入调整流水并更新当前池余额,请确认金额与原因无误。",
|
||||
"recentAdjustments": "最近调整记录",
|
||||
"contributionRate": "蓄水比例 (%)",
|
||||
"contributionRatePlaceholder": "如 2 表示 2%",
|
||||
"triggerThreshold": "爆池阈值(主币单位)",
|
||||
"triggerThresholdPlaceholder": "请输入触发阈值",
|
||||
"payoutRate": "爆池派彩比例 (%)",
|
||||
"payoutRatePlaceholder": "如 5 表示 5%",
|
||||
"forceTriggerGap": "强制爆池间隔(已结算期数)",
|
||||
"forceTriggerGapPlaceholder": "请输入强制触发间隔期数",
|
||||
"minBetAmount": "最低参与下注额(主币单位)",
|
||||
"minBetAmountPlaceholder": "请输入最低下注金额",
|
||||
"comboTriggerPlays": "组合触发玩法(逗号分隔)",
|
||||
"comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox",
|
||||
"contributionRate": "蓄水比例 %",
|
||||
"triggerThreshold": "爆池阈值",
|
||||
"payoutRate": "派彩比例 %",
|
||||
"forceTriggerGap": "强制爆池间隔(期)",
|
||||
"minBetAmount": "最低下注额",
|
||||
"comboTriggerPlays": "组合触发玩法",
|
||||
"comboTriggerPlaysPlaceholder": "straight,ibox",
|
||||
"status": "开关",
|
||||
"disabled": "关闭",
|
||||
"enabled": "开启",
|
||||
"saving": "保存中…",
|
||||
"save": "保存",
|
||||
"confirmSavePoolTitle": "确认保存奖池配置?",
|
||||
"confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含池余额);余额请使用「余额调整」。",
|
||||
"manualBurstDrawId": "手动爆池期号(ID 或编码)",
|
||||
"manualBurstHint": "仅超级管理员可在紧急情况下触发;可填写后台期号数字 ID 或期号编码。须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。",
|
||||
"confirmSavePoolDescription": "将更新奖池参数(不含余额)。",
|
||||
"manualBurstDrawId": "爆池期号",
|
||||
"manualBurstConfirmTitle": "确认手动爆池?",
|
||||
"manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot,并扣减奖池余额。此操作不可自动撤销。",
|
||||
"manualBurstConfirmDescription": "确认对期号 {{drawId}} 执行手动爆池?",
|
||||
"processing": "处理中…",
|
||||
"manualBurst": "手动触发爆池(仅超管)",
|
||||
"manualBurst": "手动爆池",
|
||||
"manualBurstConfirm": "确认爆池",
|
||||
"cancel": "取消",
|
||||
"filter": "筛选",
|
||||
@@ -72,7 +60,7 @@
|
||||
"title": "奖池记录",
|
||||
"description": "派彩记录与奖池蓄水流水"
|
||||
},
|
||||
"poolsSectionTitle": "各币种奖池参数",
|
||||
|
||||
"payoutLoadFailed": "派彩记录加载失败",
|
||||
"contributionLoadFailed": "蓄水记录加载失败",
|
||||
"trigger": "触发",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"title": "对账",
|
||||
"createTitle": "发起对账扫描",
|
||||
"createHint": "将扫描所选日期内的转账单,比对彩票钱包流水,并在已配置主站 API 时核对主站幂等记录。",
|
||||
"workflowHint": "本页按日期扫描并留存对账任务。处理当前仍未解决的异常转账单,请点右上角入口(打开钱包 → 转账单,并自动勾选「仅异常单」)。",
|
||||
"shortcutAbnormalTransfers": "当前异常转账单",
|
||||
"viewOnlyHint": "当前账号仅可查看历史对账任务。",
|
||||
"createTitle": "发起扫描",
|
||||
"createHint": "",
|
||||
"reconcileType": "对账类型",
|
||||
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
|
||||
"dateRange": "对账日期范围",
|
||||
@@ -12,13 +15,18 @@
|
||||
"periodRequired": "请填写对账日期范围(开始与结束)",
|
||||
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
|
||||
"confirmCreateTitle": "确认发起对账扫描?",
|
||||
"confirmCreateDescription": "将扫描所选日期范围{{playerHint}}内的转账单,并自动生成差异明细。",
|
||||
"confirmCreateDescription": "扫描所选日期{{playerHint}}内的转账单。",
|
||||
"confirmCreatePlayer": "内指定玩家",
|
||||
"confirmCreateAllPlayers": "内全部玩家",
|
||||
"createSuccess": "扫描完成,发现 {{count}} 条异常",
|
||||
"createSuccessEmpty": "扫描完成,未发现异常",
|
||||
"createFailed": "扫描失败",
|
||||
"noCreatePermission": "当前账号无发起对账扫描权限。",
|
||||
"filterOpen": "仅待处理",
|
||||
"filterAll": "全部明细",
|
||||
"filterCount": "本页 {{shown}} / 共 {{total}} 条",
|
||||
"filterOpenEmpty": "本页无待处理明细,可切换「全部明细」或翻页。",
|
||||
"playerEmpty": "无匹配玩家",
|
||||
"jobsTitle": "对账任务",
|
||||
"refresh": "刷新",
|
||||
"jobNo": "任务号",
|
||||
@@ -31,16 +39,25 @@
|
||||
"finishedAt": "完成时间",
|
||||
"createdAt": "创建时间",
|
||||
"operate": "操作",
|
||||
"viewDetails": "查看差异明细",
|
||||
"viewDetails": "查看明细",
|
||||
"hideDetails": "收起",
|
||||
"closeDetails": "关闭明细",
|
||||
"detailsTitle": "差异明细",
|
||||
"detailsEmpty": "暂无明细",
|
||||
"itemStatusColumn": "状态",
|
||||
"itemIssueColumn": "异常类型",
|
||||
"itemHandlingColumn": "处理进度",
|
||||
"itemScanFindingColumn": "扫描发现",
|
||||
"itemCurrentStatusColumn": "当前状态",
|
||||
"itemScanFindingResolvedHint": "扫描时记录,现已处理",
|
||||
"transferNo": "转账单号",
|
||||
"walletTxnNo": "彩票钱包流水号",
|
||||
"mainSiteRef": "主站流水号",
|
||||
"mainSiteCheck": "主站核对",
|
||||
"differenceAmount": "差额(分)",
|
||||
"differenceAmount": "差额",
|
||||
"itemResult": "检查结果",
|
||||
"processingStatus": "处理状态",
|
||||
"actions": "处理",
|
||||
"actions": "操作",
|
||||
"openTransferOrder": "查看转账单",
|
||||
"openWalletTxn": "查看钱包流水",
|
||||
"detectedAt": "发现时间",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"title": "报表中心",
|
||||
"subtitle": "集中查看运营、资金、风控与审计报表,统一按维度筛选后导出。",
|
||||
"subtitle": "",
|
||||
"profitScopeHint": "注单口径,非信用账期结算",
|
||||
"shortcuts": {
|
||||
"wallet": "转账单列表",
|
||||
"risk": "风控中心",
|
||||
"audit": "审计日志"
|
||||
},
|
||||
"pageGuide": "",
|
||||
"exportPanel": "导出设置",
|
||||
"chooseReport": "选择要导出的报表",
|
||||
@@ -86,8 +92,12 @@
|
||||
},
|
||||
"preview": {
|
||||
"title": "数据预览",
|
||||
"subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。",
|
||||
"empty": "暂无数据,请调整筛选条件后重试。",
|
||||
"subtitle": "",
|
||||
"empty": "暂无数据",
|
||||
"sections": {
|
||||
"settlementBatches": "结算批次",
|
||||
"lockLogs": "占用日志"
|
||||
},
|
||||
"exportableRows": "行可导出",
|
||||
"summaryScopeHint": "上方统计卡除“记录数”外,默认按当前预览页汇总;需要全量口径请使用“导出 CSV/Excel(全量)”。",
|
||||
"scope": {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"unsettledTickets": "未结算注单",
|
||||
"openReportHint": "进行中账期:占成/输赢来自账期内流水;账单数在关账后更新。",
|
||||
"viewDetail": "查看详情",
|
||||
"processBills": "处理账单",
|
||||
"close": "关账",
|
||||
"closeNow": "立即关账",
|
||||
"hasOpen": "已有进行中账期 {{range}},须先关账才能开新期。",
|
||||
@@ -51,6 +52,7 @@
|
||||
"periods": "账期",
|
||||
"bills": "账单",
|
||||
"operations": "收付与调账",
|
||||
"aria": "账期视图",
|
||||
"ledger": "账务流水",
|
||||
"creditLedger": "信用流水",
|
||||
"playerBills": "玩家账单",
|
||||
@@ -198,7 +200,8 @@
|
||||
"actions": {
|
||||
"detail": "详情",
|
||||
"viewBill": "查看账单",
|
||||
"billDetail": "账单详情"
|
||||
"billDetail": "账单详情",
|
||||
"billDetailWithId": "账单 #{{id}}"
|
||||
},
|
||||
"billDisplay": {
|
||||
"settlementFlow": "谁付谁",
|
||||
@@ -235,6 +238,7 @@
|
||||
"unpaidAwaitingPayment": "请登记线下收付",
|
||||
"fullySettled": "本期已结清",
|
||||
"confirmHint": "确认后才可以登记收款或付款。",
|
||||
"advancedActions": "调账 / 坏账",
|
||||
"recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}})",
|
||||
"recordPayoutTo": "登记付款({{payer}} 付给 {{payee}})",
|
||||
"rebateAllocationsHint": "各层级代理对回水的承担明细。",
|
||||
@@ -321,7 +325,13 @@
|
||||
"hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。",
|
||||
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
|
||||
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。",
|
||||
"intro": "关账后生成的占成账单。可按类型或状态筛选,详情内确认或登记收付。"
|
||||
"intro": "关账后生成的占成账单。可按类型或状态筛选,详情内确认或登记收付。",
|
||||
"confirmOneTitle": "确认账单 #{{id}}?",
|
||||
"confirmOneDesc": "确认后进入待收付,可登记线下收付。",
|
||||
"confirmOneBtn": "确认",
|
||||
"confirmedOne": "已确认",
|
||||
"confirmFailed": "确认失败",
|
||||
"payBtn": "收付"
|
||||
},
|
||||
"panels": {
|
||||
"workbench": {
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
"subnavLabel": "钱包子页",
|
||||
"subnavTransactions": "主站钱包流水",
|
||||
"subnavTransferOrders": "主站转账单",
|
||||
"scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的账期结账请查看",
|
||||
"scopeHintSettlementLink": "结算中心",
|
||||
"scopeHintSettlement": "结算中心",
|
||||
|
||||
"ledgerChannel": "账本",
|
||||
"ledgerCredit": "信用流水",
|
||||
"ledgerWallet": "钱包流水",
|
||||
@@ -43,6 +41,9 @@
|
||||
"options": "选项",
|
||||
"abnormalOnly": "仅异常单",
|
||||
"abnormalOnlyPending": "仅异常(待对账)",
|
||||
"abnormalFilterActive": "当前仅显示对账标记的异常转账单(非钱包流水)。可在下方取消勾选「仅异常单」查看全部。",
|
||||
"txnAbnormalFilterActive": "当前仅显示状态为「待对账」的流水。勾选后状态下拉无效,与接口 abnormal=1 一致。",
|
||||
"txnDeepLinkActive": "已按链接带入筛选条件并自动查询。重置可清空 URL 参数。",
|
||||
"search": "搜索",
|
||||
"resetFilters": "重置筛选",
|
||||
"refreshCurrentPage": "刷新当前页",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/** 后台/彩票端登录账号:字母、数字、点、下划线、连字符。 */
|
||||
export const ADMIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
export const ADMIN_PASSWORD_MIN_LENGTH = 8;
|
||||
export const NATIVE_PLAYER_PASSWORD_MIN_LENGTH = 6;
|
||||
/** 全项目统一:后台账号、代理账号、彩票端玩家密码最短长度 */
|
||||
export const PASSWORD_MIN_LENGTH = 6;
|
||||
export const ADMIN_PASSWORD_MIN_LENGTH = PASSWORD_MIN_LENGTH;
|
||||
export const NATIVE_PLAYER_PASSWORD_MIN_LENGTH = PASSWORD_MIN_LENGTH;
|
||||
|
||||
export type AccountValidationIssue = "empty" | "invalid_charset";
|
||||
export type PasswordValidationIssue = "empty" | "too_short";
|
||||
|
||||
@@ -23,16 +23,19 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
||||
settings: "settings",
|
||||
integration: "integration",
|
||||
agents: "agents",
|
||||
agent_list: "agent_list",
|
||||
config: "config",
|
||||
};
|
||||
|
||||
const LEGACY_NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
||||
agent_list: "agents",
|
||||
};
|
||||
|
||||
export function adminNavLabel(
|
||||
segment: string,
|
||||
t: TFunction,
|
||||
apiLabel?: string | null,
|
||||
): string {
|
||||
const key = NAV_SEGMENT_I18N_KEYS[segment];
|
||||
const key = NAV_SEGMENT_I18N_KEYS[segment] ?? LEGACY_NAV_SEGMENT_I18N_KEYS[segment];
|
||||
if (key) {
|
||||
return t(`nav.${key}`, { ns: "common" });
|
||||
}
|
||||
|
||||
@@ -12,11 +12,14 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
||||
"/admin/settlement-batches": { ns: "settlement", key: "batchList" },
|
||||
"/admin/reconcile": { ns: "reconcile", key: "title" },
|
||||
"/admin/reports": { ns: "reports", key: "title" },
|
||||
"/admin/reports/profit": { ns: "reports", key: "title" },
|
||||
"/admin/reports/wallet": { ns: "reports", key: "title" },
|
||||
"/admin/reports/risk": { ns: "reports", key: "title" },
|
||||
"/admin/reports/audit": { ns: "reports", key: "title" },
|
||||
"/admin/audit-logs": { ns: "audit", key: "title" },
|
||||
"/admin/admin-users": { ns: "adminUsers", key: "title" },
|
||||
"/admin/admin-roles": { ns: "adminRoles", key: "title" },
|
||||
"/admin/agents": { ns: "agents", key: "title" },
|
||||
"/admin/agents/list": { ns: "agents", key: "listTitle" },
|
||||
"/admin/agents/provision": { ns: "agents", key: "subnav.provision" },
|
||||
"/admin/agents/sites": { ns: "config", key: "integrationSites.title" },
|
||||
"/admin/settlement-center": { ns: "settlementCenter", key: "title" },
|
||||
|
||||
313
src/lib/admin-permission-profiles.ts
Normal file
313
src/lib/admin-permission-profiles.ts
Normal 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();
|
||||
}
|
||||
@@ -103,6 +103,15 @@ export const PRD_WALLET_TRANSFER_ACCESS_ANY = [
|
||||
PRD_WALLET_ADJUST_MANAGE,
|
||||
] as const;
|
||||
|
||||
/** 报表中心页(含资金/风控/审计类导出报表入口) */
|
||||
export const PRD_REPORTS_PAGE_ACCESS_ANY = [
|
||||
PRD_REPORT_VIEW,
|
||||
PRD_REPORT_EXPORT,
|
||||
...PRD_WALLET_TRANSFER_ACCESS_ANY,
|
||||
...PRD_RISK_ACCESS_ANY,
|
||||
PRD_AUDIT_VIEW,
|
||||
] as const;
|
||||
|
||||
/** 单玩家钱包查询 */
|
||||
export const PRD_WALLET_PLAYER_ACCESS_ANY = [
|
||||
PRD_USERS_VIEW_FINANCE,
|
||||
|
||||
@@ -32,7 +32,6 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
{
|
||||
dashboard: LayoutDashboard,
|
||||
agents: Network,
|
||||
agent_list: Users,
|
||||
players: Users,
|
||||
draws: CalendarClock,
|
||||
rules_plays: ClipboardList,
|
||||
@@ -56,6 +55,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
|
||||
/** 旧版 localStorage / 接口缓存中的 segment,避免首屏侧栏崩溃 */
|
||||
const legacyAdminNavIconBySegment: Record<string, LucideIcon> = {
|
||||
agent_list: Users,
|
||||
config: SlidersHorizontal,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export type AdminNavGroup =
|
||||
export type AdminNavSegment =
|
||||
| "dashboard"
|
||||
| "agents"
|
||||
| "agent_list"
|
||||
| "players"
|
||||
| "draws"
|
||||
| "rules_plays"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { putAdminUser } from "@/api/admin-users";
|
||||
import { validateAdminPassword } from "@/lib/admin-input-validation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -62,6 +63,10 @@ export function AccountSettingsConsole() {
|
||||
toast.error(t("validation.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
if (validateAdminPassword(password) === "too_short") {
|
||||
toast.error(t("validation.passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }));
|
||||
return;
|
||||
}
|
||||
if (!adminProfile) {
|
||||
toast.error(t("actions.updateFailed"));
|
||||
return;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
|
||||
import { permissionProfileForGroup, resolveProfileLevel } from "@/lib/admin-permission-profiles";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -57,8 +58,26 @@ function permissionGroupLabel(key: string, fallback: string, t: (key: string) =>
|
||||
return translated === `permissionGroups.${key}` ? fallback : translated;
|
||||
}
|
||||
|
||||
function permissionPackageLabel(key: string, fallback: string, t: (key: string, options?: { defaultValue?: string }) => string): string {
|
||||
return t(`permissionLevels.${key}`, { defaultValue: fallback });
|
||||
function countEnabledAreas(
|
||||
permissionSlugs: string[],
|
||||
catalog: AdminPermissionCatalogData | null,
|
||||
isSuperAdmin: boolean,
|
||||
): number {
|
||||
if (!catalog) {
|
||||
return permissionSlugs.length;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const group of catalog.permission_menu_groups ?? []) {
|
||||
const profile = permissionProfileForGroup(group.key);
|
||||
if (profile === undefined || (profile.platformOnly && !isSuperAdmin)) {
|
||||
continue;
|
||||
}
|
||||
if (resolveProfileLevel(profile, permissionSlugs) !== "none") {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function AdminRolesConsole(): React.ReactElement {
|
||||
@@ -67,6 +86,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
const exportLabels = useExportLabels("adminRoles");
|
||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||
@@ -270,11 +290,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("roleListHint", {
|
||||
defaultValue: "可新增自定义角色并配置权限;内置角色(超级管理员、站点管理员、代理)不可删除。",
|
||||
})}
|
||||
</p>
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
<div className="rounded-md border">
|
||||
<Table id="admin-roles-table">
|
||||
@@ -282,19 +298,19 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<TableRow>
|
||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("roleTable.name")}</TableHead>
|
||||
<TableHead>{t("roleTable.slug")}</TableHead>
|
||||
{isSuperAdmin ? <TableHead>{t("roleTable.slug")}</TableHead> : null}
|
||||
<TableHead>{t("roleTable.type")}</TableHead>
|
||||
<TableHead>{t("roleTable.status")}</TableHead>
|
||||
<TableHead>{t("roleTable.users")}</TableHead>
|
||||
<TableHead>{t("roleTable.permissions")}</TableHead>
|
||||
<TableHead>{t("roleTable.enabledAreas")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("roleTable.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && roles.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
<AdminTableLoadingRow colSpan={isSuperAdmin ? 8 : 7} />
|
||||
) : roles.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={8} className="text-muted-foreground" />
|
||||
<AdminTableNoResourceRow colSpan={isSuperAdmin ? 8 : 7} className="text-muted-foreground" />
|
||||
) : (
|
||||
roles.map((role) => {
|
||||
const fixedRole = isPlatformFixedRole(role);
|
||||
@@ -306,7 +322,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<TableCell>
|
||||
<span className="font-medium">{role.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>{role.slug}</TableCell>
|
||||
{isSuperAdmin ? <TableCell className="font-mono text-xs text-muted-foreground">{role.slug}</TableCell> : null}
|
||||
<TableCell>
|
||||
{role.is_system ? (
|
||||
<Badge variant="secondary">{t("roleType.system")}</Badge>
|
||||
@@ -320,7 +336,9 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
||||
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{countEnabledAreas(role.permission_slugs, catalog, isSuperAdmin)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageRoles ? (
|
||||
<AdminRowActionsMenu
|
||||
@@ -370,11 +388,10 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
>
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-[15px] font-semibold tracking-tight text-foreground">
|
||||
{t("rolePermissionDialog.title")}
|
||||
{selectedRole
|
||||
? t("rolePermissionDialog.titleWithName", { name: selectedRole.name })
|
||||
: t("rolePermissionDialog.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
{selectedRole ? selectedRole.name : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
||||
<AdminPermissionPackageSelector
|
||||
@@ -382,11 +399,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
selectedSlugs={draftRolePermissions}
|
||||
onChange={setDraftRolePermissions}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
helperText={t("rolePermissionDialog.packageHint", {
|
||||
defaultValue:
|
||||
"勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。",
|
||||
})}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
emptyText={t("states.noData", { ns: "common" })}
|
||||
heightClassName="h-[min(56vh,520px)]"
|
||||
/>
|
||||
@@ -404,7 +417,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
title: t("confirmSaveRolePermissionsTitle"),
|
||||
description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: () => saveRolePermissions(),
|
||||
})
|
||||
}
|
||||
@@ -421,7 +433,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<DialogTitle>
|
||||
{editingRoleId === null ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { validateAdminPassword } from "@/lib/admin-input-validation";
|
||||
import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -266,7 +267,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
toast.error(t("nicknameRequired"));
|
||||
return;
|
||||
}
|
||||
if (accountMode === "edit" && formPassword !== "" && formPassword.length < 8) {
|
||||
if (accountMode === "edit" && formPassword !== "" && validateAdminPassword(formPassword) === "too_short") {
|
||||
toast.error(t("newPasswordMin"));
|
||||
return;
|
||||
}
|
||||
@@ -287,7 +288,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
toast.error(t("usernameRequired"));
|
||||
return;
|
||||
}
|
||||
if (formPassword.length < 8) {
|
||||
if (validateAdminPassword(formPassword) === "too_short") {
|
||||
toast.error(t("passwordMin"));
|
||||
return;
|
||||
}
|
||||
@@ -404,12 +405,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("modelGuidePlatform", {
|
||||
defaultValue:
|
||||
"这里只管理平台账号与平台角色。代理账号请到「代理经营」中创建和维护;账号层只绑定角色,不直接分配功能权限。",
|
||||
})}
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">
|
||||
|
||||
@@ -16,136 +16,101 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
|
||||
import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields";
|
||||
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { percentValueToUi } from "@/lib/admin-rate-percent";
|
||||
import { isLineRootAgentNode } from "@/lib/agent-profile-caps";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
|
||||
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
|
||||
|
||||
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null {
|
||||
if (
|
||||
totalShareRate == null ||
|
||||
parentShareRate == null ||
|
||||
parentShareRate <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return percentValueToUi((totalShareRate / parentShareRate) * 100);
|
||||
}
|
||||
|
||||
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
|
||||
export type AgentDetailTab = "profile" | "downline" | "players";
|
||||
|
||||
export type AgentLineDetailPanelProps = {
|
||||
node: AgentNodeRow | null;
|
||||
profile: AgentProfileRow | null;
|
||||
profileLoading: boolean;
|
||||
profileError?: string | null;
|
||||
childAgents: AgentNodeRow[];
|
||||
childCountById: Map<number, number>;
|
||||
siteCode: string;
|
||||
siteLabel: string | null;
|
||||
parentName: string | null;
|
||||
detailTab: AgentDetailTab;
|
||||
onDetailTabChange: (tab: AgentDetailTab) => void;
|
||||
canViewProfileTab: boolean;
|
||||
canEditProfileTab: boolean;
|
||||
profileReadOnly: boolean;
|
||||
canSaveProfileTab: boolean;
|
||||
canViewDownlineTab: boolean;
|
||||
canViewPlayersTab: boolean;
|
||||
playersTabHint?: string | null;
|
||||
canManageNode: boolean;
|
||||
canCreateChild: boolean;
|
||||
canCreateChildAgent: boolean;
|
||||
canCreatePlayerAction: boolean;
|
||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||
onEditChild: (node: AgentNodeRow) => void;
|
||||
onDeleteChild: (node: AgentNodeRow) => void;
|
||||
onAddChild: () => void;
|
||||
onAddPlayer: () => void;
|
||||
onEditCurrent: () => void;
|
||||
onSelectChild: (node: AgentNodeRow) => void;
|
||||
profileFields: AgentProfileFieldsProps | null;
|
||||
profileSaving: boolean;
|
||||
onSaveProfile: () => void;
|
||||
playerCreateRequestKey?: number;
|
||||
};
|
||||
|
||||
export function AgentLineDetailPanel({
|
||||
node,
|
||||
profile,
|
||||
profileLoading,
|
||||
profileError = null,
|
||||
childAgents,
|
||||
childCountById,
|
||||
siteCode,
|
||||
siteLabel,
|
||||
parentName,
|
||||
detailTab,
|
||||
onDetailTabChange,
|
||||
canViewProfileTab,
|
||||
canEditProfileTab,
|
||||
profileReadOnly,
|
||||
canSaveProfileTab,
|
||||
canViewDownlineTab,
|
||||
canViewPlayersTab,
|
||||
playersTabHint,
|
||||
canManageNode,
|
||||
canCreateChild,
|
||||
canCreateChildAgent,
|
||||
canCreatePlayerAction,
|
||||
canDeleteChild,
|
||||
onEditChild,
|
||||
onDeleteChild,
|
||||
onAddChild,
|
||||
onAddPlayer,
|
||||
onEditCurrent,
|
||||
onSelectChild,
|
||||
profileFields,
|
||||
profileSaving,
|
||||
onSaveProfile,
|
||||
playerCreateRequestKey = 0,
|
||||
}: AgentLineDetailPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
|
||||
if (node === null) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-muted/20 px-6 py-20 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80 bg-background">
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 py-20 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80">
|
||||
<Network className="size-6 text-muted-foreground/70" aria-hidden />
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-foreground">
|
||||
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理查看占成与授信" })}
|
||||
</p>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
{t("lineUi.selectAgentHint", {
|
||||
defaultValue: "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
|
||||
})}
|
||||
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理" })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
|
||||
{
|
||||
key: "overview",
|
||||
label: t("lineUi.tabOverview", { defaultValue: "概览" }),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
key: "profile",
|
||||
label: profileReadOnly
|
||||
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
|
||||
: t("lineUi.tabProfile", { defaultValue: "占成与授信" }),
|
||||
label: t("lineUi.tabProfileShort", { defaultValue: "占成授信" }),
|
||||
visible: canViewProfileTab,
|
||||
},
|
||||
{
|
||||
key: "downline",
|
||||
label: t("lineUi.tabDownline", { defaultValue: "直属下级" }),
|
||||
count: childAgents.length,
|
||||
visible: canViewDownlineTab,
|
||||
},
|
||||
{
|
||||
@@ -155,165 +120,94 @@ export function AgentLineDetailPanel({
|
||||
},
|
||||
];
|
||||
|
||||
const siteDisplay =
|
||||
siteLabel && siteCode.trim() !== ""
|
||||
? `${siteLabel} (${siteCode})`
|
||||
: siteLabel ?? siteCode;
|
||||
const childActionHint = canCreateChild
|
||||
? null
|
||||
: canCreateChildAgent
|
||||
? t("lineUi.addChildUnavailableHint", {
|
||||
defaultValue: "当前代理未开启“允许创建下级代理”,如需新增请先调整该代理配置。",
|
||||
})
|
||||
: t("lineUi.addChildNoPermissionHint", {
|
||||
defaultValue: "当前账号没有为该节点创建下级代理的权限。",
|
||||
});
|
||||
const playerActionHint =
|
||||
canViewPlayersTab && !canCreatePlayerAction ? playersTabHint ?? null : null;
|
||||
const showPrimaryAction = detailTab === "downline" || detailTab === "players";
|
||||
const primaryActionEnabled =
|
||||
detailTab === "players" ? canCreatePlayerAction : canCreateChild;
|
||||
const primaryActionLabel =
|
||||
detailTab === "players"
|
||||
? t("lineUi.createDirectPlayer", { defaultValue: "创建直属玩家" })
|
||||
: t("createChild", { defaultValue: "添加下级代理" });
|
||||
const primaryActionHint =
|
||||
detailTab === "players" ? playerActionHint : childActionHint;
|
||||
const metaParts = [
|
||||
parentName
|
||||
? t("lineUi.parentMeta", { defaultValue: "上级 {{name}}", name: parentName })
|
||||
: null,
|
||||
node.username?.trim() ? node.username : node.code,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="shrink-0 bg-card shadow-[0_1px_0_rgb(216_230_251_/_35%)]">
|
||||
<header className="border-b border-border/60 px-5 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground">
|
||||
{node.name}
|
||||
</h2>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
|
||||
{node.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "启用" })
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border/60">
|
||||
<header className="px-5 py-4 sm:px-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="truncate text-lg font-semibold tracking-tight">{node.name}</h2>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
|
||||
{node.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "启用" })
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
{metaParts.length > 0 ? (
|
||||
<p className="truncate text-sm text-muted-foreground">{metaParts.join(" · ")}</p>
|
||||
) : null}
|
||||
<AgentHeaderMetrics profile={profile} loading={profileLoading} />
|
||||
</div>
|
||||
{siteDisplay ? (
|
||||
<p className="mt-1 truncate text-sm text-muted-foreground" title={siteDisplay}>
|
||||
{siteDisplay}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{canManageNode ? (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
{t("lineUi.editAgent", { defaultValue: "编辑代理" })}
|
||||
</Button>
|
||||
{showPrimaryAction && primaryActionEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={detailTab === "players" ? onAddPlayer : onAddChild}
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{primaryActionHint ? (
|
||||
<p className="max-w-[26rem] text-right text-xs leading-5 text-muted-foreground">
|
||||
{primaryActionHint}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
||||
<Pencil className="mr-1.5 size-3.5" />
|
||||
{t("lineUi.editAgent", { defaultValue: "编辑" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<AdminSubnav
|
||||
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
|
||||
className="min-h-11 overflow-x-auto border-b border-border/60 px-4 sm:px-5"
|
||||
>
|
||||
{tabs
|
||||
.filter((tab) => tab.visible)
|
||||
.map((tab) => (
|
||||
<AdminSubnavButton
|
||||
key={tab.key}
|
||||
active={detailTab === tab.key}
|
||||
onClick={() => onDetailTabChange(tab.key)}
|
||||
count={tab.count}
|
||||
>
|
||||
{tab.label}
|
||||
</AdminSubnavButton>
|
||||
))}
|
||||
</AdminSubnav>
|
||||
<AdminSubnav
|
||||
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
|
||||
className="min-h-11 overflow-x-auto px-4 sm:px-5"
|
||||
>
|
||||
{tabs
|
||||
.filter((tab) => tab.visible)
|
||||
.map((tab) => (
|
||||
<AdminSubnavButton
|
||||
key={tab.key}
|
||||
active={detailTab === tab.key}
|
||||
onClick={() => onDetailTabChange(tab.key)}
|
||||
count={tab.count}
|
||||
>
|
||||
{tab.label}
|
||||
</AdminSubnavButton>
|
||||
))}
|
||||
</AdminSubnav>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
|
||||
{profileLoading && detailTab !== "overview" ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6 sm:py-6">
|
||||
{profileError ? (
|
||||
<p className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{profileError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{profileLoading && detailTab === "profile" ? (
|
||||
<AdminLoadingInline className="py-16" />
|
||||
) : null}
|
||||
|
||||
{detailTab === "overview" ? (
|
||||
<OverviewTab
|
||||
profile={profile}
|
||||
profileLoading={profileLoading}
|
||||
profileReadOnly={profileReadOnly}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detailTab === "profile" && canViewProfileTab && profileFields && !profileLoading ? (
|
||||
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm">
|
||||
<CardHeader className="border-b border-border/60 pb-4">
|
||||
<CardTitle className="text-base">
|
||||
{profileReadOnly
|
||||
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
|
||||
: t("lineUi.tabProfile", { defaultValue: "占成与授信" })}
|
||||
</CardTitle>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
{profileReadOnly
|
||||
? isLineRootAgentNode(node)
|
||||
? t("lineUi.lineRootProfileReadOnlyHint", {
|
||||
defaultValue:
|
||||
"一级代理的占成、站点授信总额与回水由平台超管配置;您可向直属下级代理与玩家下放额度。",
|
||||
})
|
||||
: t("lineUi.profileReadOnlyHint", {
|
||||
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
||||
})
|
||||
: t("lineUi.profileTabHint", {
|
||||
defaultValue:
|
||||
"占成、授信、回水与风控标签在此维护;登录名、密码与启停状态请用「编辑代理」。",
|
||||
})}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
|
||||
{canManageNode && canEditProfileTab ? (
|
||||
<div className="mt-6 flex justify-end border-t border-border/60 pt-5">
|
||||
<Button
|
||||
type="button"
|
||||
className="min-w-[10rem]"
|
||||
disabled={profileSaving || profileFields.loading}
|
||||
onClick={onSaveProfile}
|
||||
>
|
||||
{profileSaving
|
||||
? t("common:actions.saving", { defaultValue: "保存中…" })
|
||||
: t("lineUi.saveProfile", { defaultValue: "保存占成与授信" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mx-auto max-w-3xl space-y-5">
|
||||
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
|
||||
{canSaveProfileTab ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={profileSaving || profileFields.loading}
|
||||
onClick={onSaveProfile}
|
||||
>
|
||||
{profileSaving
|
||||
? t("common:actions.saving", { defaultValue: "保存中…" })
|
||||
: t("lineUi.saveProfile", { defaultValue: "保存" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailTab === "downline" && canViewDownlineTab && !profileLoading ? (
|
||||
<DownlineTable
|
||||
{detailTab === "downline" && canViewDownlineTab ? (
|
||||
<DownlineSection
|
||||
childAgents={childAgents}
|
||||
childCountById={childCountById}
|
||||
parentTotalShareRate={profile?.total_share_rate}
|
||||
canManageNode={canManageNode}
|
||||
canCreateChild={canCreateChild}
|
||||
canDeleteChild={canDeleteChild}
|
||||
@@ -324,13 +218,12 @@ export function AgentLineDetailPanel({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detailTab === "players" && canViewPlayersTab && !profileLoading ? (
|
||||
{detailTab === "players" && canViewPlayersTab ? (
|
||||
<AgentsPlayersPanel
|
||||
siteCode={siteCode}
|
||||
agentNodeId={node.id}
|
||||
allowCreatePlayer={canCreatePlayerAction}
|
||||
embedded
|
||||
createRequestKey={playerCreateRequestKey}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -338,150 +231,36 @@ export function AgentLineDetailPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
function AgentHeaderMetrics({
|
||||
profile,
|
||||
profileLoading,
|
||||
profileReadOnly,
|
||||
loading,
|
||||
}: {
|
||||
profile: AgentProfileRow | null;
|
||||
profileLoading: boolean;
|
||||
profileReadOnly: boolean;
|
||||
loading: boolean;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const { t } = useTranslation("agents");
|
||||
|
||||
const rebateCap =
|
||||
profile && !profileLoading ? percentValueToUi(profile.rebate_limit ?? 0) : null;
|
||||
const parentRelativeShare = relativeShareRate(
|
||||
profile?.total_share_rate,
|
||||
profile?.parent_caps?.total_share_rate,
|
||||
);
|
||||
if (loading) {
|
||||
return <p className="text-sm text-muted-foreground">…</p>;
|
||||
}
|
||||
|
||||
const yesLabel = t("common:states.yes", { defaultValue: "是" });
|
||||
const noLabel = t("common:states.no", { defaultValue: "否" });
|
||||
const items = [
|
||||
`${t("profile.totalShareRate", { defaultValue: "占成" })} ${profile?.total_share_rate ?? 0}%`,
|
||||
`${t("profile.creditLimit", { defaultValue: "授信" })} ${formatCredit(profile?.credit_limit ?? 0)}`,
|
||||
`${t("lineUi.availableCredit", { defaultValue: "可下发" })} ${formatCredit(profile?.available_credit ?? 0)}`,
|
||||
`${t("profile.rebateLimit", { defaultValue: "回水上限" })} ${percentValueToUi(profile?.rebate_limit ?? 0)}%`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
{profileReadOnly ? (
|
||||
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
|
||||
{t("lineUi.selfAgentOverviewHint", {
|
||||
defaultValue:
|
||||
"以下为上级为您分配的占成与授信;如需调整请联系上级代理或平台。",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-w-0 grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
|
||||
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
|
||||
money={false}
|
||||
subtitle={
|
||||
parentRelativeShare
|
||||
? t("profile.relativeShareRateValue", {
|
||||
defaultValue: "占上级 {{rate}}%",
|
||||
rate: parentRelativeShare,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
accent
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
|
||||
highlight
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
value={profileLoading ? "…" : `${rebateCap ?? "0"}%`}
|
||||
money={false}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
value={
|
||||
profileLoading ? "…" : `${percentValueToUi(profile?.default_player_rebate ?? 0)}%`
|
||||
}
|
||||
money={false}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||
value={
|
||||
profileLoading
|
||||
? "…"
|
||||
: (profile?.risk_tags?.length ?? 0) > 0
|
||||
? profile!.risk_tags!.join(", ")
|
||||
: t("common:states.none", { defaultValue: "无" })
|
||||
}
|
||||
money={false}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
enabled={profile?.can_grant_extra_rebate === true}
|
||||
loading={profileLoading}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
enabled={profile?.can_create_player !== false}
|
||||
loading={profileLoading}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
enabled={profile?.can_create_child_agent === true}
|
||||
loading={profileLoading}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{items.join(" · ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityMetric({
|
||||
label,
|
||||
enabled,
|
||||
loading = false,
|
||||
yesLabel,
|
||||
noLabel,
|
||||
}: {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
loading?: boolean;
|
||||
yesLabel: string;
|
||||
noLabel: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-card px-4 py-4 shadow-sm">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-2xl font-semibold tracking-tight",
|
||||
loading ? "text-muted-foreground" : enabled ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{loading ? "…" : enabled ? yesLabel : noLabel}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DownlineTable({
|
||||
function DownlineSection({
|
||||
childAgents,
|
||||
childCountById,
|
||||
parentTotalShareRate,
|
||||
canManageNode,
|
||||
canCreateChild,
|
||||
canDeleteChild,
|
||||
@@ -492,7 +271,6 @@ function DownlineTable({
|
||||
}: {
|
||||
childAgents: AgentNodeRow[];
|
||||
childCountById: Map<number, number>;
|
||||
parentTotalShareRate?: number;
|
||||
canManageNode: boolean;
|
||||
canCreateChild: boolean;
|
||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||
@@ -502,174 +280,158 @@ function DownlineTable({
|
||||
onAddChild: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const createChildLabel = t("lineUi.createDownline", { defaultValue: "创建下级代理" });
|
||||
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" });
|
||||
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" });
|
||||
|
||||
if (childAgents.length === 0) {
|
||||
return (
|
||||
<AdminNoResourceState message={t("lineUi.downlineEmptyShort", { defaultValue: "暂无直属下级" })}>
|
||||
{canManageNode && canCreateChild ? (
|
||||
<Button type="button" size="sm" onClick={onAddChild}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("createChild", { defaultValue: "添加下级" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</AdminNoResourceState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead>
|
||||
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
|
||||
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
|
||||
<TableHead>{t("lineUi.downlineColumns.email", { defaultValue: "邮箱" })}</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("profile.totalShareRate", { defaultValue: "占成 (%)" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级数" })}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">{t("common:status.label", { defaultValue: "状态" })}</TableHead>
|
||||
{canManageNode ? (
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{childAgents.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={canManageNode ? 10 : 9} cellClassName="py-12 text-center" />
|
||||
) : (
|
||||
childAgents.map((child) => {
|
||||
const summary = child.profile_summary;
|
||||
return (
|
||||
<TableRow
|
||||
key={child.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectChild(child)}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">{child.code}</TableCell>
|
||||
<TableCell className="font-medium">{child.name}</TableCell>
|
||||
<TableCell className="text-xs">{child.username ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-[10rem] truncate text-xs text-muted-foreground">
|
||||
{child.email ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{summary ? (
|
||||
<div className="space-y-0.5">
|
||||
<div>{`${summary.total_share_rate ?? 0}%`}</div>
|
||||
{parentTotalShareRate && parentTotalShareRate > 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("profile.relativeShareRateValue", {
|
||||
defaultValue: "占上级 {{rate}}%",
|
||||
rate: relativeShareRate(
|
||||
summary.total_share_rate,
|
||||
parentTotalShareRate,
|
||||
) ?? "0",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className={adminMoneyCellClassName("text-right text-xs")}>
|
||||
{summary ? <AdminTableMoney>{formatCredit(summary.credit_limit)}</AdminTableMoney> : "—"}
|
||||
</TableCell>
|
||||
<TableCell className={adminMoneyCellClassName("text-right text-xs")}>
|
||||
{summary ? <AdminTableMoney>{formatCredit(summary.allocated_credit)}</AdminTableMoney> : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums text-xs">
|
||||
{childCountById.get(child.id) ?? 0}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
|
||||
{child.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "启用" })
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
{canManageNode ? (
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: editChildLabel,
|
||||
icon: Pencil,
|
||||
onClick: () => onEditChild(child),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: deleteChildLabel,
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: !canDeleteChild(child),
|
||||
onClick: () => onDeleteChild(child),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="space-y-3">
|
||||
{canManageNode && canCreateChild ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" size="sm" onClick={onAddChild}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{t("createChild", { defaultValue: "添加下级" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<DownlineTable
|
||||
childAgents={childAgents}
|
||||
childCountById={childCountById}
|
||||
canManageNode={canManageNode}
|
||||
onEditChild={onEditChild}
|
||||
onDeleteChild={onDeleteChild}
|
||||
onSelectChild={onSelectChild}
|
||||
canDeleteChild={canDeleteChild}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
accent = false,
|
||||
highlight = false,
|
||||
money = true,
|
||||
function DownlineTable({
|
||||
childAgents,
|
||||
childCountById,
|
||||
canManageNode,
|
||||
canDeleteChild,
|
||||
onEditChild,
|
||||
onDeleteChild,
|
||||
onSelectChild,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle?: string;
|
||||
accent?: boolean;
|
||||
highlight?: boolean;
|
||||
/** 金额类指标:自适应字号 + 换行 */
|
||||
money?: boolean;
|
||||
childAgents: AgentNodeRow[];
|
||||
childCountById: Map<number, number>;
|
||||
canManageNode: boolean;
|
||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||
onEditChild: (node: AgentNodeRow) => void;
|
||||
onDeleteChild: (node: AgentNodeRow) => void;
|
||||
onSelectChild: (node: AgentNodeRow) => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 overflow-visible rounded-xl border bg-card px-4 py-4 shadow-sm transition-colors",
|
||||
highlight && "border-primary/25 bg-primary/[0.04]",
|
||||
accent && !highlight && "border-border/70",
|
||||
!accent && !highlight && "border-border/70",
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
{money ? (
|
||||
<AdminMoneyDisplay
|
||||
as="p"
|
||||
value={value}
|
||||
size="lg"
|
||||
className={cn("mt-1.5", highlight ? "text-primary" : "text-foreground")}
|
||||
>
|
||||
{value}
|
||||
</AdminMoneyDisplay>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-2xl font-semibold tabular-nums tracking-tight",
|
||||
highlight ? "text-primary" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
|
||||
<div className="admin-table-inset overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>{t("agentName", { defaultValue: "名称" })}</TableHead>
|
||||
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("profile.totalShareRate", { defaultValue: "占成" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("profile.creditLimit", { defaultValue: "授信" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级" })}
|
||||
</TableHead>
|
||||
<TableHead>{t("common:status.label", { defaultValue: "状态" })}</TableHead>
|
||||
{canManageNode ? (
|
||||
<TableHead className="sticky right-0 z-20 w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{childAgents.map((child) => {
|
||||
const summary = child.profile_summary;
|
||||
return (
|
||||
<TableRow
|
||||
key={child.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelectChild(child)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium">{child.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{child.code}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{child.username ?? "—"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{summary ? `${summary.total_share_rate ?? 0}%` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className={adminMoneyCellClassName("text-right text-sm")}>
|
||||
{summary ? <AdminTableMoney>{formatCredit(summary.credit_limit)}</AdminTableMoney> : "—"}
|
||||
</TableCell>
|
||||
<TableCell className={adminMoneyCellClassName("text-right text-sm")}>
|
||||
{summary ? (
|
||||
<AdminTableMoney>{formatCredit(summary.available_credit)}</AdminTableMoney>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums text-sm">
|
||||
{childCountById.get(child.id) ?? 0}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
|
||||
{child.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "启用" })
|
||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
{canManageNode ? (
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: t("lineUi.editDownline", { defaultValue: "编辑" }),
|
||||
icon: Pencil,
|
||||
onClick: () => onEditChild(child),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("lineUi.deleteDownline", { defaultValue: "删除" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: !canDeleteChild(child),
|
||||
onClick: () => onDeleteChild(child),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
||||
import {
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
validateAdminLoginAccount,
|
||||
validateAdminPassword,
|
||||
} from "@/lib/admin-input-validation";
|
||||
@@ -75,8 +76,9 @@ export function AgentLineProvisionWizard({
|
||||
return;
|
||||
}
|
||||
const normalized = defaultSiteCode.trim().toLowerCase();
|
||||
if (unboundSites.some((row) => row.code.toLowerCase() === normalized)) {
|
||||
setForm((f) => ({ ...f, site_code: normalized }));
|
||||
const matched = unboundSites.find((row) => row.code.toLowerCase() === normalized);
|
||||
if (matched) {
|
||||
setForm((f) => ({ ...f, site_code: matched.code }));
|
||||
}
|
||||
}, [defaultSiteCode, form.site_code, sitesLoading, unboundSites]);
|
||||
|
||||
@@ -109,7 +111,7 @@ export function AgentLineProvisionWizard({
|
||||
}
|
||||
const passwordIssue = validateAdminPassword(form.password);
|
||||
if (passwordIssue === "too_short") {
|
||||
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||||
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 6 位" }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,10 +284,13 @@ export function AgentLineProvisionWizard({
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
required
|
||||
minLength={8}
|
||||
minLength={ADMIN_PASSWORD_MIN_LENGTH}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("agents:lineProvision.passwordHint", { defaultValue: "至少 8 位" })}
|
||||
{t("agents:lineProvision.passwordHint", {
|
||||
defaultValue: "至少 {{min}} 位",
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
||||
@@ -56,6 +57,36 @@ function pruneTreeForSearch(
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSearchExpandIds(
|
||||
nodes: AgentNodeRow[],
|
||||
normalized: string,
|
||||
parentNameMap: Map<number, string>,
|
||||
): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
|
||||
const walk = (list: AgentNodeRow[], ancestors: number[]): void => {
|
||||
for (const node of list) {
|
||||
const children = node.children ?? [];
|
||||
const prunedChildren = pruneTreeForSearch(children, normalized, parentNameMap);
|
||||
const selfMatch = nodeMatchesKeyword(node, normalized, parentNameMap);
|
||||
|
||||
if (selfMatch || prunedChildren.length > 0) {
|
||||
for (const id of ancestors) {
|
||||
ids.add(id);
|
||||
}
|
||||
if (prunedChildren.length > 0) {
|
||||
ids.add(node.id);
|
||||
}
|
||||
walk(prunedChildren, [...ancestors, node.id]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(nodes, []);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export type AgentLineSidebarProps = {
|
||||
siteLabel: string | null;
|
||||
/** API 返回的嵌套树(含 children) */
|
||||
@@ -67,6 +98,8 @@ export type AgentLineSidebarProps = {
|
||||
loading?: boolean;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSelect: (node: AgentNodeRow) => void;
|
||||
errorMessage?: string | null;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
type TreeRowProps = {
|
||||
@@ -98,7 +131,7 @@ function TreeRow({
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-start gap-0.5 rounded-md py-1 pr-2 transition-colors",
|
||||
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-background/80",
|
||||
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-muted/15",
|
||||
)}
|
||||
style={{ paddingLeft: `${6 + indent}px` }}
|
||||
>
|
||||
@@ -163,6 +196,8 @@ export function AgentLineSidebar({
|
||||
loading = false,
|
||||
onKeywordChange,
|
||||
onSelect,
|
||||
errorMessage = null,
|
||||
onRetry,
|
||||
}: AgentLineSidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||
@@ -220,6 +255,19 @@ export function AgentLineSidebar({
|
||||
});
|
||||
}, [selectedId, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedKeyword === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const expandIds = collectSearchExpandIds(tree, normalizedKeyword, parentNameMap);
|
||||
if (expandIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIds((prev) => new Set([...prev, ...expandIds]));
|
||||
}, [normalizedKeyword, parentNameMap, tree]);
|
||||
|
||||
const toggleExpand = useCallback((id: number) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -236,19 +284,8 @@ export function AgentLineSidebar({
|
||||
const hasAnyAgent = displayForest.length > 0;
|
||||
|
||||
return (
|
||||
<aside className="flex min-h-0 h-full w-full flex-col bg-muted/10 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
|
||||
<div className="space-y-3 border-b border-border/60 bg-card px-4 py-4">
|
||||
{siteLabel ? (
|
||||
<p className="truncate text-xs font-medium text-foreground/80" title={siteLabel}>
|
||||
{siteLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("lineUi.agentCount", {
|
||||
defaultValue: "本组 {{count}} 个代理",
|
||||
count: agentCount,
|
||||
})}
|
||||
</p>
|
||||
<aside className="flex min-h-0 h-full w-full flex-col lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
|
||||
<div className="space-y-3 border-b border-border/60 px-4 py-4">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -265,6 +302,15 @@ export function AgentLineSidebar({
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
{loading ? (
|
||||
<AdminLoadingInline className="py-10" />
|
||||
) : !hasAnyAgent && errorMessage ? (
|
||||
<div className="space-y-3 px-2 py-8 text-center">
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
{onRetry ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={onRetry}>
|
||||
{t("common:actions.retry", { defaultValue: "重试" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : !hasAnyAgent ? (
|
||||
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
|
||||
) : (
|
||||
|
||||
@@ -111,14 +111,29 @@ export function AgentProfileFields({
|
||||
max: maxCreditLimit,
|
||||
});
|
||||
|
||||
const showFieldHints = !isCard;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{(parentCaps || availableCredit !== null) && !loading ? (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
|
||||
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{parentCaps ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
isCard ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{parentCaps
|
||||
? t("profile.parentCaps", {
|
||||
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
|
||||
share: parentCaps.total_share_rate,
|
||||
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
||||
})
|
||||
: t("profile.availableCredit", {
|
||||
defaultValue: "可下发额度 {{amount}}",
|
||||
amount: formatAdminCreditMajorDecimal(availableCredit ?? 0, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
|
||||
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
{parentCaps ? (
|
||||
<p className="text-sm font-medium leading-snug">
|
||||
{t("profile.parentCaps", {
|
||||
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
|
||||
@@ -126,27 +141,27 @@ export function AgentProfileFields({
|
||||
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{availableCredit !== null ? (
|
||||
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
|
||||
{t("profile.availableCredit", {
|
||||
defaultValue: "可下发额度 {{amount}}",
|
||||
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
) : null}
|
||||
{availableCredit !== null ? (
|
||||
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
|
||||
{t("profile.availableCredit", {
|
||||
defaultValue: "可下发额度 {{amount}}",
|
||||
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground animate-pulse">
|
||||
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!profileScalarsEditable && !loading ? (
|
||||
{!profileScalarsEditable && !loading && !isCard ? (
|
||||
<p className="rounded-lg border border-amber-200/80 bg-amber-50 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
{t("profile.lineRootScalarsReadOnlyHint", {
|
||||
defaultValue:
|
||||
@@ -166,7 +181,7 @@ export function AgentProfileFields({
|
||||
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
</Label>
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" />
|
||||
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" flat={isCard} />
|
||||
) : (
|
||||
<AdminNumericStepper
|
||||
id={`${idPrefix}-share-rate`}
|
||||
@@ -179,19 +194,20 @@ export function AgentProfileFields({
|
||||
preserveOverMaxOnBlur
|
||||
/>
|
||||
)}
|
||||
{parentCaps ? (
|
||||
{showFieldHints && parentCaps ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.relativeShareCapHint", {
|
||||
defaultValue: "占上级比例最高 100%(上级总占成 {{parent}}%)",
|
||||
parent: parentCaps.total_share_rate,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
) : null}
|
||||
{showFieldHints && !parentCaps ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
|
||||
</p>
|
||||
)}
|
||||
{parentCaps && shareRate && actualShare !== null ? (
|
||||
) : null}
|
||||
{showFieldHints && parentCaps && shareRate && actualShare !== null ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.actualShareRate", {
|
||||
defaultValue: "实际占成 {{rate}}%",
|
||||
@@ -208,7 +224,7 @@ export function AgentProfileFields({
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} />
|
||||
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} flat={isCard} />
|
||||
) : (
|
||||
<AdminNumericStepper
|
||||
id={`${idPrefix}-credit-limit`}
|
||||
@@ -221,7 +237,7 @@ export function AgentProfileFields({
|
||||
preserveOverMaxOnBlur
|
||||
/>
|
||||
)}
|
||||
{parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
|
||||
{showFieldHints && parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.creditParentCapHint", {
|
||||
defaultValue: "最高 {{max}}(上级可再下发 {{available}})",
|
||||
@@ -246,7 +262,7 @@ export function AgentProfileFields({
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{minCreditLimit > 0 ? (
|
||||
{showFieldHints && minCreditLimit > 0 ? (
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("profile.creditAllocatedFloorHint", {
|
||||
defaultValue: "不可低于已下发 {{amount}}",
|
||||
@@ -263,7 +279,7 @@ export function AgentProfileFields({
|
||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
</Label>
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" />
|
||||
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" flat={isCard} />
|
||||
) : (
|
||||
<AdminNumericStepper
|
||||
id={`${idPrefix}-rebate-limit`}
|
||||
@@ -297,7 +313,7 @@ export function AgentProfileFields({
|
||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
</Label>
|
||||
{showReadOnlyDisplay ? (
|
||||
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" />
|
||||
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" flat={isCard} />
|
||||
) : (
|
||||
<AdminNumericStepper
|
||||
id={`${idPrefix}-default-rebate`}
|
||||
@@ -329,11 +345,15 @@ export function AgentProfileFields({
|
||||
id={`${idPrefix}-risk-tags`}
|
||||
value={riskTags.trim() === "" ? "—" : riskTags}
|
||||
className="justify-start font-normal"
|
||||
flat={isCard}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={`${idPrefix}-risk-tags`}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
className={cn(
|
||||
"h-10 transition-colors",
|
||||
isCard ? "bg-card focus:bg-card" : "bg-background/50 focus:bg-background",
|
||||
)}
|
||||
value={riskTags}
|
||||
onChange={(e) => onRiskTagsChange(e.target.value)}
|
||||
placeholder={t("profile.riskTagsPlaceholder", {
|
||||
@@ -345,21 +365,29 @@ export function AgentProfileFields({
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="rounded-xl border border-border/80 bg-muted/20 overflow-hidden shadow-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border/80",
|
||||
isCard ? undefined : "bg-muted/20 shadow-sm",
|
||||
)}
|
||||
>
|
||||
{showReadOnlyDisplay ? (
|
||||
<>
|
||||
<ReadOnlySwitchRow
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
checked={extraRebate}
|
||||
flat={isCard}
|
||||
/>
|
||||
<ReadOnlySwitchRow
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
checked={canCreatePlayer}
|
||||
flat={isCard}
|
||||
/>
|
||||
<ReadOnlySwitchRow
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
checked={canCreateChild}
|
||||
isLast
|
||||
flat={isCard}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -368,11 +396,13 @@ export function AgentProfileFields({
|
||||
checked={extraRebate}
|
||||
onCheckedChange={onExtraRebateChange}
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
flat={isCard}
|
||||
/>
|
||||
<SwitchRow
|
||||
checked={canCreatePlayer}
|
||||
onCheckedChange={onCanCreatePlayerChange}
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
flat={isCard}
|
||||
/>
|
||||
<SwitchRow
|
||||
checked={canCreateChild}
|
||||
@@ -380,6 +410,7 @@ export function AgentProfileFields({
|
||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
isLast
|
||||
flat={isCard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -402,17 +433,20 @@ function ReadOnlyScalar({
|
||||
value,
|
||||
suffix,
|
||||
className,
|
||||
flat = false,
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
flat?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
"flex min-h-10 min-w-0 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 py-2 text-center shadow-xs",
|
||||
"flex min-h-10 min-w-0 items-center justify-center rounded-md border border-border/80 px-3 py-2 text-center",
|
||||
flat ? "bg-card" : "bg-muted/35 shadow-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -428,17 +462,20 @@ function ReadOnlySwitchRow({
|
||||
label,
|
||||
checked,
|
||||
isLast = false,
|
||||
flat = false,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
isLast?: boolean;
|
||||
flat?: boolean;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 bg-background/60 px-4 py-3.5",
|
||||
"flex items-center justify-between gap-4 px-4 py-3.5",
|
||||
!flat && "bg-background/60",
|
||||
!isLast && "border-b border-border/60",
|
||||
)}
|
||||
>
|
||||
@@ -458,18 +495,23 @@ function SwitchRow({
|
||||
label,
|
||||
disabled = false,
|
||||
isLast = false,
|
||||
flat = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
isLast?: boolean;
|
||||
flat?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center justify-between gap-4 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30",
|
||||
!isLast && "border-b border-border/50"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 px-4 py-3.5 transition-colors",
|
||||
flat ? "hover:bg-muted/15" : "bg-background/50 hover:bg-muted/30",
|
||||
!isLast && "border-b border-border/50",
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
className={cn("font-medium", disabled ? "cursor-default text-muted-foreground" : "cursor-pointer")}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
putAgentNode,
|
||||
putAgentNodeProfile,
|
||||
} from "@/api/admin-agents";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
import {
|
||||
AgentLineDetailPanel,
|
||||
type AgentDetailTab,
|
||||
@@ -94,6 +95,8 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
export function AgentsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "common"]);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const profile = useAdminProfile();
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
@@ -123,12 +126,12 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
||||
const [detailTab, setDetailTab] = useState<AgentDetailTab>("overview");
|
||||
const [detailTab, setDetailTab] = useState<AgentDetailTab>("profile");
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
||||
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
||||
const [selectedProfileErr, setSelectedProfileErr] = useState<string | null>(null);
|
||||
const [playerCreateRequestKey, setPlayerCreateRequestKey] = useState(0);
|
||||
|
||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||
const [targetParentId, setTargetParentId] = useState<number | null>(null);
|
||||
@@ -307,6 +310,10 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
return null;
|
||||
}, [adminSiteId, boundSite, siteOptions]);
|
||||
const rootNode = useMemo(
|
||||
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
|
||||
[flatNodes],
|
||||
);
|
||||
const activeSiteCode = useMemo(() => {
|
||||
const fromAgent = boundAgent?.site_code?.trim();
|
||||
if (fromAgent) {
|
||||
@@ -319,12 +326,8 @@ export function AgentsConsole(): React.ReactElement {
|
||||
if (boundSite != null && boundSite.id === adminSiteId) {
|
||||
return boundSite.code.trim();
|
||||
}
|
||||
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
|
||||
}, [adminSiteId, boundAgent?.site_code, boundSite, flatNodes, siteOptions]);
|
||||
const rootNode = useMemo(
|
||||
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
|
||||
[flatNodes],
|
||||
);
|
||||
return rootNode?.site_code?.trim() ?? "";
|
||||
}, [adminSiteId, boundAgent?.site_code, boundSite, rootNode?.site_code, siteOptions]);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() =>
|
||||
@@ -342,8 +345,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const isOwnAgentNode =
|
||||
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
|
||||
|
||||
const canViewProfileTab =
|
||||
canManageProfile && selectedNode !== null && !isOwnAgentNode;
|
||||
const canViewProfileTab = canManageProfile && selectedNode !== null;
|
||||
|
||||
const canEditSelectedProfile =
|
||||
canViewProfileTab && isRootProfileEditableByActor(selectedNode, isSuperAdmin);
|
||||
@@ -473,6 +475,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSelectedProfile(null);
|
||||
resetProfileForm();
|
||||
setSelectedProfileErr(
|
||||
t("profile.loadFailed", { defaultValue: "代理档案加载失败,请稍后重试。" }),
|
||||
);
|
||||
@@ -500,51 +503,46 @@ export function AgentsConsole(): React.ReactElement {
|
||||
.catch(() => setRootProfile(null));
|
||||
}, [rootNode?.id]);
|
||||
|
||||
/** 代理看自己,或站点方看一级代理:占成授信 Tab 只读 */
|
||||
const profileReadOnly = isOwnAgentNode || lineRootProfileLocked;
|
||||
|
||||
const isSiteAdmin = isSiteAdminOperator(profile);
|
||||
|
||||
const canShowDownlineTab = useMemo(
|
||||
() =>
|
||||
selectedNode !== null &&
|
||||
(selectedProfileLoading ||
|
||||
isSiteAdmin ||
|
||||
isSuperAdmin ||
|
||||
selectedProfile?.can_create_child_agent === true),
|
||||
[isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
|
||||
);
|
||||
const canShowDownlineTab = selectedNode !== null;
|
||||
|
||||
const canShowPlayersTab = useMemo(
|
||||
() =>
|
||||
selectedNode !== null &&
|
||||
(selectedProfileLoading ||
|
||||
(hasUsersManagePermission &&
|
||||
(isSiteAdmin ||
|
||||
isSuperAdmin ||
|
||||
selectedProfile?.can_create_player === true))),
|
||||
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
|
||||
(hasUsersManagePermission || isSiteAdmin || isSuperAdmin),
|
||||
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode],
|
||||
);
|
||||
|
||||
const playersTabHint = useMemo(() => {
|
||||
if (selectedNode === null || selectedProfileLoading) {
|
||||
return null;
|
||||
const canCreatePlayerOnSelected = useMemo(() => {
|
||||
if (selectedNode === null || selectedProfileLoading || !hasUsersManagePermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedProfile?.can_create_player !== true) {
|
||||
return t("lineUi.playersUnavailableHint", {
|
||||
defaultValue: "当前代理未开启“允许创建玩家”,如需新增请先调整该代理配置。",
|
||||
});
|
||||
if (boundAgent !== null && selectedNode.id !== boundAgent.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasUsersManagePermission) {
|
||||
return t("lineUi.playersNoPermissionHint", {
|
||||
defaultValue: "当前账号没有该节点的玩家管理权限。",
|
||||
});
|
||||
if (isSuperAdmin || isSiteAdmin) {
|
||||
return selectedProfile?.can_create_player !== false;
|
||||
}
|
||||
if (boundAgent !== null) {
|
||||
return selectedProfile?.can_create_player === true;
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
boundAgent,
|
||||
hasUsersManagePermission,
|
||||
isSiteAdmin,
|
||||
isSuperAdmin,
|
||||
selectedNode,
|
||||
selectedProfile?.can_create_player,
|
||||
selectedProfileLoading,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}, [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading, t]);
|
||||
const [directPlayerCountById, setDirectPlayerCountById] = useState<Map<number, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const prevSelectedNodeIdRef = useRef<number | null>(null);
|
||||
|
||||
const canCreateChildOnSelected = useMemo(
|
||||
() =>
|
||||
@@ -561,11 +559,11 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
|
||||
if (detailTab === "profile" && !canViewProfileTab) {
|
||||
setDetailTab("overview");
|
||||
setDetailTab(canShowPlayersTab ? "players" : "downline");
|
||||
} else if (detailTab === "downline" && !canShowDownlineTab) {
|
||||
setDetailTab(canShowPlayersTab ? "players" : "overview");
|
||||
setDetailTab(canShowPlayersTab ? "players" : "profile");
|
||||
} else if (detailTab === "players" && !canShowPlayersTab) {
|
||||
setDetailTab(canShowDownlineTab ? "downline" : "overview");
|
||||
setDetailTab(canViewProfileTab ? "profile" : "downline");
|
||||
}
|
||||
}, [
|
||||
canShowDownlineTab,
|
||||
@@ -577,14 +575,89 @@ export function AgentsConsole(): React.ReactElement {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailTab("overview");
|
||||
}, [selectedNodeId]);
|
||||
if (selectedNodeId === null) {
|
||||
prevSelectedNodeIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeChanged = prevSelectedNodeIdRef.current !== selectedNodeId;
|
||||
prevSelectedNodeIdRef.current = selectedNodeId;
|
||||
|
||||
if (!nodeChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailTab((current) => {
|
||||
if (current === "profile" && canViewProfileTab) {
|
||||
return current;
|
||||
}
|
||||
if (current === "downline" && canShowDownlineTab) {
|
||||
return current;
|
||||
}
|
||||
if (current === "players" && canShowPlayersTab) {
|
||||
return current;
|
||||
}
|
||||
if (canViewProfileTab) {
|
||||
return "profile";
|
||||
}
|
||||
if (canShowPlayersTab) {
|
||||
return "players";
|
||||
}
|
||||
return "downline";
|
||||
});
|
||||
}, [canShowDownlineTab, canShowPlayersTab, canViewProfileTab, selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOwnAgentNode && detailTab === "profile") {
|
||||
setDetailTab("overview");
|
||||
if (detailTab !== "downline" || activeSiteCode.trim() === "") {
|
||||
return;
|
||||
}
|
||||
}, [detailTab, isOwnAgentNode]);
|
||||
|
||||
let cancelled = false;
|
||||
for (const child of selectedChildAgents) {
|
||||
void getAdminPlayers({
|
||||
site_code: activeSiteCode.trim(),
|
||||
agent_node_id: child.id,
|
||||
page: 1,
|
||||
per_page: 1,
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setDirectPlayerCountById((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(child.id, data.meta.total);
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* 删除按钮降级为仅后端校验 */
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSiteCode, detailTab, selectedChildAgents]);
|
||||
|
||||
const syncSelectedNodeToUrl = useCallback(
|
||||
(nodeId: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("agent_node_id", String(nodeId));
|
||||
params.delete("node");
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const selectAgentNode = useCallback(
|
||||
(node: AgentNodeRow) => {
|
||||
setSelectedNodeId(node.id);
|
||||
syncSelectedNodeToUrl(node.id);
|
||||
},
|
||||
[syncSelectedNodeToUrl],
|
||||
);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!canViewAgents) {
|
||||
@@ -674,8 +747,9 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const canDeleteNode = (node: AgentNodeRow): boolean => {
|
||||
const blockedByChildren = (node.children?.length ?? 0) > 0;
|
||||
const blockedBySelf = profile?.agent?.id === node.id;
|
||||
const blockedByPlayers = (directPlayerCountById.get(node.id) ?? 0) > 0;
|
||||
|
||||
return canManageNode && !blockedByChildren && !blockedBySelf;
|
||||
return canManageNode && !blockedByChildren && !blockedBySelf && !blockedByPlayers;
|
||||
};
|
||||
|
||||
const handleDeleteNode = (node: AgentNodeRow): void => {
|
||||
@@ -732,7 +806,8 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: profileReadOnly || !canEditSelectedProfile,
|
||||
disabled:
|
||||
isOwnAgentNode || lineRootProfileLocked || !canEditSelectedProfile,
|
||||
loading: selectedProfileLoading,
|
||||
parentCaps: profileParentCaps,
|
||||
availableCredit: profileAvailableCredit,
|
||||
@@ -763,7 +838,8 @@ export function AgentsConsole(): React.ReactElement {
|
||||
canEditSelectedProfile,
|
||||
canViewProfileTab,
|
||||
isSuperAdmin,
|
||||
profileReadOnly,
|
||||
isOwnAgentNode,
|
||||
lineRootProfileLocked,
|
||||
selectedNode,
|
||||
profileAvailableCredit,
|
||||
profileCanCreateChild,
|
||||
@@ -780,7 +856,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
selectedProfileLoading,
|
||||
]);
|
||||
|
||||
const showAgentSidebar = loading || visibleAgentRows.length > 0;
|
||||
const showAgentSidebar = loading || visibleAgentRows.length > 0 || err !== null;
|
||||
|
||||
const hasSiteContext =
|
||||
siteOptions.length > 0 ||
|
||||
@@ -832,13 +908,13 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
const passwordIssue = validateAdminPassword(nodePassword);
|
||||
if (passwordIssue === "too_short") {
|
||||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 6 位" }));
|
||||
return;
|
||||
}
|
||||
} else if (nodePassword.trim()) {
|
||||
const passwordIssue = validateAdminPassword(nodePassword);
|
||||
if (passwordIssue === "too_short") {
|
||||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 6 位" }));
|
||||
return;
|
||||
}
|
||||
} else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) {
|
||||
@@ -876,8 +952,9 @@ export function AgentsConsole(): React.ReactElement {
|
||||
|
||||
setNodeSaving(true);
|
||||
try {
|
||||
let createdNodeId: number | null = null;
|
||||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
||||
await postAgentNode({
|
||||
const created = await postAgentNode({
|
||||
parent_id: targetParentId,
|
||||
name: nodeName.trim(),
|
||||
username: nodeUsername.trim(),
|
||||
@@ -885,6 +962,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
status: nodeStatus,
|
||||
...(canManageProfile ? profilePayload(null) : {}),
|
||||
});
|
||||
createdNodeId = created.id;
|
||||
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
||||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||||
await putAgentNode(editingNodeId, {
|
||||
@@ -903,16 +981,12 @@ export function AgentsConsole(): React.ReactElement {
|
||||
|
||||
setNodeDialogOpen(false);
|
||||
await loadTree(adminSiteId);
|
||||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
||||
const refreshed = flattenTree((await getAgentTree(adminSiteId ?? undefined)).tree);
|
||||
const created = refreshed.find(
|
||||
(node) => node.parent_id === targetParentId && node.name === nodeName.trim(),
|
||||
);
|
||||
if (created) {
|
||||
setSelectedNodeId(created.id);
|
||||
}
|
||||
if (nodeDialogMode === "create" && createdNodeId !== null) {
|
||||
setSelectedNodeId(createdNodeId);
|
||||
syncSelectedNodeToUrl(createdNodeId);
|
||||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||||
setSelectedNodeId(editingNodeId);
|
||||
syncSelectedNodeToUrl(editingNodeId);
|
||||
if (canManageProfile) {
|
||||
void getAgentNodeProfile(editingNodeId)
|
||||
.then((p) => {
|
||||
@@ -996,8 +1070,13 @@ export function AgentsConsole(): React.ReactElement {
|
||||
<AgentLineProvisionWizard
|
||||
embedded
|
||||
defaultSiteCode={activeSiteCode}
|
||||
onSuccess={async () => {
|
||||
onSuccess={async (result) => {
|
||||
await loadTree(adminSiteId);
|
||||
const nodeId = result.line_root?.agent_node_id ?? result.agent_node?.id ?? null;
|
||||
if (nodeId !== null) {
|
||||
setSelectedNodeId(nodeId);
|
||||
syncSelectedNodeToUrl(nodeId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1024,24 +1103,20 @@ export function AgentsConsole(): React.ReactElement {
|
||||
onKeywordChange={(value) => {
|
||||
setKeyword(value);
|
||||
}}
|
||||
onSelect={(node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
}}
|
||||
onSelect={selectAgentNode}
|
||||
errorMessage={!loading && visibleAgentRows.length === 0 ? err : null}
|
||||
onRetry={() => void loadTree(adminSiteId)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedProfileErr ? (
|
||||
<p className="border-b border-border/70 px-4 py-2 text-sm text-destructive">{selectedProfileErr}</p>
|
||||
) : null}
|
||||
|
||||
<AgentLineDetailPanel
|
||||
node={selectedNode}
|
||||
profile={selectedProfile}
|
||||
profileLoading={selectedProfileLoading}
|
||||
profileError={selectedProfileErr}
|
||||
childAgents={selectedChildAgents}
|
||||
childCountById={childCountById}
|
||||
siteCode={activeSiteCode}
|
||||
siteLabel={selectedSiteLabel}
|
||||
parentName={
|
||||
selectedNode?.parent_id !== null && selectedNode?.parent_id !== undefined
|
||||
? (parentNameMap.get(selectedNode.parent_id) ?? null)
|
||||
@@ -1050,33 +1125,22 @@ export function AgentsConsole(): React.ReactElement {
|
||||
detailTab={detailTab}
|
||||
onDetailTabChange={setDetailTab}
|
||||
canViewProfileTab={canViewProfileTab}
|
||||
canEditProfileTab={canEditSelectedProfile}
|
||||
profileReadOnly={profileReadOnly}
|
||||
canEditProfileTab={canEditSelectedProfile && !isOwnAgentNode}
|
||||
canSaveProfileTab={canManageProfile && canEditSelectedProfile && !isOwnAgentNode}
|
||||
canViewDownlineTab={canShowDownlineTab}
|
||||
canViewPlayersTab={canShowPlayersTab}
|
||||
playersTabHint={playersTabHint}
|
||||
canManageNode={canManageNode}
|
||||
canCreateChild={canCreateChildOnSelected}
|
||||
canCreateChildAgent={canCreateChildAgent}
|
||||
canCreatePlayerAction={
|
||||
isSuperAdmin ||
|
||||
isSiteAdmin ||
|
||||
(selectedProfile?.can_create_player === true &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]))
|
||||
}
|
||||
canCreatePlayerAction={canCreatePlayerOnSelected}
|
||||
canDeleteChild={canDeleteNode}
|
||||
onEditChild={(node) => openEditForNode(node)}
|
||||
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
||||
onAddPlayer={() => setPlayerCreateRequestKey((value) => value + 1)}
|
||||
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
|
||||
onDeleteChild={(node) => handleDeleteNode(node)}
|
||||
onSelectChild={(child) => {
|
||||
setSelectedNodeId(child.id);
|
||||
}}
|
||||
onSelectChild={selectAgentNode}
|
||||
profileFields={inlineProfileFields}
|
||||
profileSaving={profileSaving}
|
||||
onSaveProfile={() => void saveInlineProfile()}
|
||||
playerCreateRequestKey={playerCreateRequestKey}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -127,38 +127,8 @@ export function AgentsDirectoryConsole(): React.ReactElement {
|
||||
});
|
||||
}, [items, keyword, parentNameMap, status]);
|
||||
|
||||
const totalOperatingAgents = useMemo(
|
||||
() => items.filter((item) => !item.is_root).length,
|
||||
[items],
|
||||
);
|
||||
const enabledOperatingAgents = useMemo(
|
||||
() => items.filter((item) => !item.is_root && item.status === 1).length,
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalOperatingAgents}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.enabledAgents", { defaultValue: "启用中的经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{enabledOperatingAgents}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.visibleList", { defaultValue: "当前平铺列表条数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{filteredItems.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminPageCard
|
||||
title={t("listTitle", { defaultValue: "代理列表" })}
|
||||
actions={
|
||||
@@ -209,10 +179,10 @@ export function AgentsDirectoryConsole(): React.ReactElement {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="admin-table-inset overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead>
|
||||
<TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead>
|
||||
<TableHead className="w-[90px]">{t("depth", { defaultValue: "层级" })}</TableHead>
|
||||
@@ -231,7 +201,7 @@ export function AgentsDirectoryConsole(): React.ReactElement {
|
||||
<TableHead className="w-[130px] text-right">
|
||||
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||
</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<TableHead className="sticky right-0 z-20 w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -289,12 +259,12 @@ export function AgentsDirectoryConsole(): React.ReactElement {
|
||||
<TableCell className="text-right">
|
||||
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<TableCell className="sticky right-0 z-10 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view",
|
||||
label: t("common:actions.viewDetails", { defaultValue: "查看详情" }),
|
||||
label: t("lineUi.openInTree", { defaultValue: "在树中打开" }),
|
||||
icon: Eye,
|
||||
href: `/admin/agents?agent_node_id=${item.id}`,
|
||||
},
|
||||
|
||||
17
src/modules/agents/agents-management-screen.tsx
Normal file
17
src/modules/agents/agents-management-screen.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, Pencil, Plus, ReceiptText, Search, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAgentNodeProfile } from "@/api/admin-agents";
|
||||
import {
|
||||
getSettlementBills,
|
||||
postSettlementBillBadDebtWriteOff,
|
||||
postSettlementBillConfirm,
|
||||
postSettlementBillPayment,
|
||||
type SettlementBillRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
|
||||
import {
|
||||
deleteAdminPlayer,
|
||||
getAdminPlayer,
|
||||
@@ -57,18 +52,21 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
||||
import { formatAdminMinorDecimal, formatAdminMinorUnits, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { AGENT_PERCENT_HARD_MAX } from "@/lib/agent-profile-caps";
|
||||
import {
|
||||
NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
|
||||
validateNativePlayerPassword,
|
||||
validateNativePlayerUsername,
|
||||
} from "@/lib/admin-input-validation";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_SETTLEMENT_AGENT_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
|
||||
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
|
||||
import { resolvePlayerSettlementHref } from "@/modules/settlement/settlement-player-deeplink";
|
||||
import { settlementCenterListHref } from "@/modules/settlement/settlement-center-nav";
|
||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -148,8 +146,6 @@ type AgentsPlayersPanelProps = {
|
||||
allowCreatePlayer?: boolean;
|
||||
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
||||
embedded?: boolean;
|
||||
/** 外部触发创建直属玩家的计数器 */
|
||||
createRequestKey?: number;
|
||||
};
|
||||
|
||||
export function AgentsPlayersPanel({
|
||||
@@ -157,10 +153,11 @@ export function AgentsPlayersPanel({
|
||||
agentNodeId,
|
||||
allowCreatePlayer,
|
||||
embedded = false,
|
||||
createRequestKey = 0,
|
||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "players", "common"]);
|
||||
const router = useRouter();
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||
const createPlayerLabel = embedded
|
||||
? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" })
|
||||
: t("playersPanel.create", { defaultValue: "创建玩家" });
|
||||
@@ -173,21 +170,6 @@ export function AgentsPlayersPanel({
|
||||
const isSiteAdmin = isSiteAdminOperator(profile);
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
|
||||
const profileAllowsCreate =
|
||||
allowCreatePlayer === undefined
|
||||
? boundAgent?.can_create_player !== false
|
||||
: allowCreatePlayer === true;
|
||||
|
||||
const canCreatePlayer =
|
||||
isSuperAdmin ||
|
||||
(profileAllowsCreate &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
||||
const canManagePlayerRows = canCreatePlayer;
|
||||
const canOperateBills =
|
||||
isSuperAdmin ||
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
|
||||
const canFinanceAdjustments = canOperateBills && boundAgent === null;
|
||||
|
||||
const effectiveAgentId = useMemo(() => {
|
||||
if (agentNodeId !== null) {
|
||||
return agentNodeId;
|
||||
@@ -195,6 +177,26 @@ export function AgentsPlayersPanel({
|
||||
return boundAgent?.id ?? null;
|
||||
}, [agentNodeId, boundAgent?.id]);
|
||||
|
||||
const profileAllowsCreate =
|
||||
allowCreatePlayer === undefined
|
||||
? boundAgent?.can_create_player !== false
|
||||
: allowCreatePlayer === true;
|
||||
|
||||
const canAttachAgentNodeOnCreate =
|
||||
effectiveAgentId !== null &&
|
||||
(isSuperAdmin ||
|
||||
isSiteAdmin ||
|
||||
(boundAgent !== null && effectiveAgentId === boundAgent.id));
|
||||
|
||||
const canCreatePlayer =
|
||||
canAttachAgentNodeOnCreate &&
|
||||
profileAllowsCreate &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||
const canManagePlayerRows = canCreatePlayer;
|
||||
const [settlementNavBusy, setSettlementNavBusy] = useState(false);
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [appliedKeyword, setAppliedKeyword] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [items, setItems] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]);
|
||||
@@ -231,18 +233,6 @@ export function AgentsPlayersPanel({
|
||||
const [editRebateRate, setEditRebateRate] = useState("");
|
||||
const [editRiskTags, setEditRiskTags] = useState("");
|
||||
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
||||
const [billingDialogOpen, setBillingDialogOpen] = useState(false);
|
||||
const [billingPlayer, setBillingPlayer] = useState<AdminPlayerRow | null>(null);
|
||||
const [billingBills, setBillingBills] = useState<SettlementBillRow[]>([]);
|
||||
const [billingLoading, setBillingLoading] = useState(false);
|
||||
const [billingBusy, setBillingBusy] = useState(false);
|
||||
const [selectedBillId, setSelectedBillId] = useState<number | null>(null);
|
||||
const [payAmount, setPayAmount] = useState("");
|
||||
const [payMethod, setPayMethod] = useState("");
|
||||
const [payProof, setPayProof] = useState("");
|
||||
const [badDebtReason, setBadDebtReason] = useState("");
|
||||
const lastCreateRequestKeyRef = useRef(createRequestKey);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteCode.trim() === "") {
|
||||
setItems([]);
|
||||
@@ -259,6 +249,7 @@ export function AgentsPlayersPanel({
|
||||
page,
|
||||
per_page: perPage,
|
||||
site_code: siteCode.trim(),
|
||||
...(appliedKeyword.trim() !== "" ? { keyword: appliedKeyword.trim() } : {}),
|
||||
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
|
||||
});
|
||||
setItems(data.items);
|
||||
@@ -272,7 +263,7 @@ export function AgentsPlayersPanel({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [effectiveAgentId, page, perPage, siteCode]);
|
||||
}, [appliedKeyword, effectiveAgentId, page, perPage, siteCode, t]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
@@ -355,9 +346,7 @@ export function AgentsPlayersPanel({
|
||||
username: username.trim(),
|
||||
password: password,
|
||||
nickname: nickname.trim() || null,
|
||||
...(effectiveAgentId != null && (isSuperAdmin || isSiteAdmin || boundAgent !== null)
|
||||
? { agent_node_id: effectiveAgentId }
|
||||
: {}),
|
||||
...(canAttachAgentNodeOnCreate ? { agent_node_id: effectiveAgentId! } : {}),
|
||||
credit_limit: parsedCreditLimit,
|
||||
...(parsedRebateRate !== null
|
||||
? { rebate_rate: parsedRebateRate }
|
||||
@@ -407,16 +396,6 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (createRequestKey === 0 || createRequestKey === lastCreateRequestKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
lastCreateRequestKeyRef.current = createRequestKey;
|
||||
if (canCreatePlayer) {
|
||||
openCreateDialog();
|
||||
}
|
||||
}, [canCreatePlayer, createRequestKey]);
|
||||
|
||||
const applyEditForm = (row: AdminPlayerRow): void => {
|
||||
const form = fillEditFormFromPlayer(row);
|
||||
setEditUsername(form.username);
|
||||
@@ -601,47 +580,19 @@ export function AgentsPlayersPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const selectedBill = useMemo(
|
||||
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null,
|
||||
[billingBills, selectedBillId],
|
||||
);
|
||||
const canOperateSelectedBill =
|
||||
selectedBill !== null &&
|
||||
canOperateBills &&
|
||||
settlementBillOperableByBoundAgent(selectedBill, boundAgent);
|
||||
const billingCurrency = billingPlayer?.default_currency ?? "NPR";
|
||||
|
||||
function resetBillingForm(): void {
|
||||
setPayAmount("");
|
||||
setPayMethod("");
|
||||
setPayProof("");
|
||||
setBadDebtReason("");
|
||||
}
|
||||
|
||||
async function openBillingDialog(row: AdminPlayerRow): Promise<void> {
|
||||
setBillingDialogOpen(true);
|
||||
setBillingPlayer(row);
|
||||
setBillingBills([]);
|
||||
setSelectedBillId(null);
|
||||
resetBillingForm();
|
||||
setBillingLoading(true);
|
||||
async function goToPlayerSettlement(row: AdminPlayerRow): Promise<void> {
|
||||
setSettlementNavBusy(true);
|
||||
try {
|
||||
const data = await getSettlementBills({
|
||||
bill_type: "player",
|
||||
keyword: row.site_player_id,
|
||||
per_page: 20,
|
||||
});
|
||||
const items = (data.items ?? []).filter(
|
||||
(bill) =>
|
||||
bill.bill_type === "player" &&
|
||||
bill.owner_id === row.id &&
|
||||
(bill.status === "pending_confirm" || Number(bill.unpaid_amount ?? 0) > 0),
|
||||
);
|
||||
setBillingBills(items);
|
||||
const first = items[0] ?? null;
|
||||
setSelectedBillId(first?.id ?? null);
|
||||
setPayAmount(
|
||||
first ? formatAdminMinorDecimal(first.unpaid_amount ?? 0, row.default_currency ?? "NPR") : "",
|
||||
const href = await resolvePlayerSettlementHref(row, adminSiteId);
|
||||
if (href) {
|
||||
router.push(href);
|
||||
return;
|
||||
}
|
||||
router.push(settlementCenterListHref(adminSiteId));
|
||||
toast.info(
|
||||
t("playersPanel.noPendingBillsGoCenter", {
|
||||
defaultValue: "该玩家暂无待处理账单,已打开结算中心。",
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
@@ -650,172 +601,50 @@ export function AgentsPlayersPanel({
|
||||
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingLoading(false);
|
||||
setSettlementNavBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmBill(): Promise<void> {
|
||||
if (selectedBill === null) return;
|
||||
setBillingBusy(true);
|
||||
try {
|
||||
await postSettlementBillConfirm(selectedBill.id);
|
||||
toast.success(
|
||||
t("playersPanel.billConfirmed", { defaultValue: "账单已确认,请继续登记收付或核销" }),
|
||||
);
|
||||
if (billingPlayer) {
|
||||
await openBillingDialog(billingPlayer);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError
|
||||
? e.message
|
||||
: t("playersPanel.billConfirmFailed", { defaultValue: "确认账单失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePayBill(): Promise<void> {
|
||||
if (selectedBill === null) return;
|
||||
const fallbackAmount = formatAdminMinorDecimal(
|
||||
selectedBill.unpaid_amount ?? 0,
|
||||
billingCurrency,
|
||||
);
|
||||
const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency);
|
||||
if (amount === null || amount <= 0 || amount > Number(selectedBill.unpaid_amount ?? 0)) {
|
||||
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入有效的收付金额" }));
|
||||
return;
|
||||
}
|
||||
setBillingBusy(true);
|
||||
try {
|
||||
await postSettlementBillPayment(selectedBill.id, {
|
||||
amount,
|
||||
method: payMethod.trim() || undefined,
|
||||
proof: payProof.trim() || undefined,
|
||||
});
|
||||
toast.success(t("playersPanel.billPaid", { defaultValue: "已登记收付" }));
|
||||
await load();
|
||||
if (billingPlayer) {
|
||||
await openBillingDialog(billingPlayer);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError
|
||||
? e.message
|
||||
: t("playersPanel.billPayFailed", { defaultValue: "登记收付失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWriteOffBill(): Promise<void> {
|
||||
if (selectedBill === null) return;
|
||||
const reason = badDebtReason.trim();
|
||||
if (!reason) {
|
||||
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
|
||||
return;
|
||||
}
|
||||
setBillingBusy(true);
|
||||
try {
|
||||
await postSettlementBillBadDebtWriteOff(selectedBill.id, {
|
||||
reason,
|
||||
});
|
||||
toast.success(t("playersPanel.billWrittenOff", { defaultValue: "已核销坏账" }));
|
||||
await load();
|
||||
if (billingPlayer) {
|
||||
await openBillingDialog(billingPlayer);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError
|
||||
? e.message
|
||||
: t("playersPanel.billWriteOffFailed", { defaultValue: "核销坏账失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBillingBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function requestConfirmBillAction(): void {
|
||||
if (selectedBill === null) return;
|
||||
requestConfirm({
|
||||
title: t("playersPanel.confirmBillTitle", { defaultValue: "确认账单?" }),
|
||||
description: t("playersPanel.confirmBillDescription", {
|
||||
defaultValue: "确认后账单会进入待收付状态,请确认金额与玩家无误。",
|
||||
}),
|
||||
confirmLabel: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
|
||||
confirmVariant: "default",
|
||||
onConfirm: handleConfirmBill,
|
||||
});
|
||||
}
|
||||
|
||||
function requestPayBillAction(): void {
|
||||
if (selectedBill === null) return;
|
||||
const fallbackAmount = formatAdminMinorDecimal(
|
||||
selectedBill.unpaid_amount ?? 0,
|
||||
billingCurrency,
|
||||
);
|
||||
const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency);
|
||||
if (amount === null || amount <= 0) {
|
||||
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的有效金额" }));
|
||||
return;
|
||||
}
|
||||
if (amount > Number(selectedBill.unpaid_amount ?? 0)) {
|
||||
toast.error(t("playersPanel.paymentAmountTooLarge", { defaultValue: "收付金额不能超过未结金额" }));
|
||||
return;
|
||||
}
|
||||
|
||||
requestConfirm({
|
||||
title: t("playersPanel.payBillConfirmTitle", { defaultValue: "确认登记收付?" }),
|
||||
description: t("playersPanel.payBillConfirmDescription", {
|
||||
defaultValue: "这会写入收付记录并更新玩家账单金额,请确认金额与凭证无误。",
|
||||
}),
|
||||
confirmLabel: t("agents:settlementBills.paid", { defaultValue: "登记收付" }),
|
||||
confirmVariant: "default",
|
||||
onConfirm: handlePayBill,
|
||||
});
|
||||
}
|
||||
|
||||
function requestWriteOffBillAction(): void {
|
||||
if (selectedBill === null) return;
|
||||
if (!badDebtReason.trim()) {
|
||||
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
|
||||
return;
|
||||
}
|
||||
|
||||
requestConfirm({
|
||||
title: t("playersPanel.writeOffBillConfirmTitle", { defaultValue: "确认核销坏账?" }),
|
||||
description: t("playersPanel.writeOffBillConfirmDescription", {
|
||||
defaultValue: "核销会把该玩家账单未结金额归档为坏账记录,请确认已无法收回。",
|
||||
}),
|
||||
confirmLabel: t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" }),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: handleWriteOffBill,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConfirmDialog />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{!embedded ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("playersPanel.creditListHint", {
|
||||
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{canCreatePlayer && !embedded ? (
|
||||
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{createPlayerLabel}
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field min-w-[12rem] flex-1">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setAppliedKeyword(keyword);
|
||||
setPage(1);
|
||||
}
|
||||
}}
|
||||
placeholder={t("playersPanel.searchPh", { defaultValue: "搜索玩家账号 / 标识" })}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAppliedKeyword(keyword);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common:actions.search", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
) : null}
|
||||
{canCreatePlayer ? (
|
||||
<Button type="button" size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
{createPlayerLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -825,41 +654,51 @@ export function AgentsPlayersPanel({
|
||||
{listErr ? (
|
||||
<p className="mb-3 text-sm text-destructive">{listErr}</p>
|
||||
) : null}
|
||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "admin-table-inset overflow-hidden"
|
||||
: "admin-table-shell overflow-hidden rounded-xl border border-border/70"
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
|
||||
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("players:riskTags", { defaultValue: "风控标签" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("players:fundingMode", { defaultValue: "资金模式" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
|
||||
{!embedded ? (
|
||||
<>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("players:riskTags", { defaultValue: "风控标签" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("players:fundingMode", { defaultValue: "资金模式" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
|
||||
</>
|
||||
) : null}
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("players:rebateRate", { defaultValue: "回水" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
|
||||
{!embedded ? (
|
||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
|
||||
) : null}
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={embedded ? 9 : 10} cellClassName="py-12 text-center" />
|
||||
<AdminTableNoResourceRow colSpan={embedded ? 7 : 10} cellClassName="py-12 text-center" />
|
||||
) : (
|
||||
items.map((row) => {
|
||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||
@@ -876,26 +715,30 @@ export function AgentsPlayersPanel({
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[14rem]">
|
||||
{riskTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
|
||||
{riskTags.map((tag) => (
|
||||
<span
|
||||
key={`${row.id}-${tag}`}
|
||||
className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium leading-4 text-amber-900"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlayerFundingModeBadge row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
|
||||
{!embedded ? (
|
||||
<>
|
||||
<TableCell className="max-w-[14rem]">
|
||||
{riskTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
|
||||
{riskTags.map((tag) => (
|
||||
<span
|
||||
key={`${row.id}-${tag}`}
|
||||
className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium leading-4 text-amber-900"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlayerFundingModeBadge row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
|
||||
</>
|
||||
) : null}
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
<span>{balances.balance}</span>
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
@@ -911,22 +754,22 @@ export function AgentsPlayersPanel({
|
||||
>
|
||||
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||
</TableCell>
|
||||
{!embedded ? (
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
|
||||
{playerStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
className="sticky right-0 z-10 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
busy={confirmBusy}
|
||||
busy={confirmBusy || settlementNavBusy}
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
@@ -942,7 +785,7 @@ export function AgentsPlayersPanel({
|
||||
defaultValue: "处理账单",
|
||||
}),
|
||||
icon: ReceiptText,
|
||||
onClick: () => void openBillingDialog(row),
|
||||
onClick: () => void goToPlayerSettlement(row),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -1040,7 +883,10 @@ export function AgentsPlayersPanel({
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{t("playersPanel.passwordHint", { defaultValue: "至少 8 位" })}
|
||||
{t("playersPanel.passwordHint", {
|
||||
defaultValue: "至少 {{min}} 位",
|
||||
min: NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1101,157 +947,6 @@ export function AgentsPlayersPanel({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={billingDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBillingDialogOpen(open);
|
||||
if (!open) {
|
||||
setBillingPlayer(null);
|
||||
setBillingBills([]);
|
||||
setSelectedBillId(null);
|
||||
resetBillingForm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("playersPanel.manageSettlement", { defaultValue: "处理账单" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{billingLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("playersPanel.billingLoading", { defaultValue: "正在加载账单…" })}
|
||||
</p>
|
||||
) : billingBills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("playersPanel.noPendingBills", { defaultValue: "当前没有可处理的未结账单。" })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("playersPanel.selectBill", { defaultValue: "选择账单" })}</Label>
|
||||
<Select
|
||||
value={selectedBillId ? String(selectedBillId) : ""}
|
||||
onValueChange={(value) => {
|
||||
const next = billingBills.find((bill) => bill.id === Number(value)) ?? null;
|
||||
setSelectedBillId(next?.id ?? null);
|
||||
setPayAmount(
|
||||
next
|
||||
? formatAdminMinorDecimal(next.unpaid_amount ?? 0, billingCurrency)
|
||||
: "",
|
||||
);
|
||||
setPayMethod("");
|
||||
setPayProof("");
|
||||
setBadDebtReason("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("playersPanel.selectBill", { defaultValue: "选择账单" })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{billingBills.map((bill) => (
|
||||
<SelectItem key={bill.id} value={String(bill.id)}>
|
||||
{`#${bill.id} · ${bill.status} · ${bill.player_site_player_id ?? bill.owner_id} · ${formatAdminMinorUnits(bill.unpaid_amount ?? 0, billingCurrency)}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedBill ? (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 p-4">
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("playersPanel.billStatus", { defaultValue: "状态" })}:
|
||||
</span>{" "}
|
||||
{selectedBill.status}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("playersPanel.billUnpaid", { defaultValue: "未结" })}:
|
||||
</span>{" "}
|
||||
{formatAdminMinorUnits(selectedBill.unpaid_amount ?? 0, billingCurrency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canOperateSelectedBill && selectedBill.status === "pending_confirm" ? (
|
||||
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestConfirmBillAction}>
|
||||
{t("agents:settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canOperateSelectedBill && selectedBill.status !== "pending_confirm" && Number(selectedBill.unpaid_amount ?? 0) > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||
<Input
|
||||
value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)}
|
||||
inputMode="decimal"
|
||||
placeholder={formatAdminMinorDecimal(
|
||||
selectedBill.unpaid_amount ?? 0,
|
||||
billingCurrency,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
||||
<Input
|
||||
value={payMethod}
|
||||
onChange={(e) => setPayMethod(e.target.value)}
|
||||
placeholder={t("agents:settlementBills.paymentMethodPlaceholder", {
|
||||
defaultValue: "例如:现金 / 银行转账",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("agents:settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||
<Input
|
||||
value={payProof}
|
||||
onChange={(e) => setPayProof(e.target.value)}
|
||||
placeholder={t("agents:settlementBills.paymentProofPlaceholder", {
|
||||
defaultValue: "可填写流水号、截图说明或备注",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestPayBillAction}>
|
||||
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
|
||||
</Button>
|
||||
|
||||
{canFinanceAdjustments &&
|
||||
["confirmed", "partial_paid", "overdue"].includes(selectedBill.status) &&
|
||||
Number(selectedBill.unpaid_amount ?? 0) > 0 &&
|
||||
!["adjustment", "reversal", "bad_debt"].includes(selectedBill.bill_type) ? (
|
||||
<>
|
||||
<div className="space-y-1 pt-2">
|
||||
<Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||
<Input
|
||||
value={badDebtReason}
|
||||
onChange={(e) => setBadDebtReason(e.target.value)}
|
||||
placeholder={t("agents:settlementBills.badDebtReasonPlaceholder", {
|
||||
defaultValue: "例如:客户失联、确认坏账",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestWriteOffBillAction}>
|
||||
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AdminSubnavBar,
|
||||
} from "@/components/admin/admin-subnav";
|
||||
import { AdminSubnav, AdminSubnavBar, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
@@ -22,6 +20,9 @@ import { cn } from "@/lib/utils";
|
||||
export function AgentsSubnav(): React.ReactElement {
|
||||
const { t } = useTranslation("agents");
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const isAgentsHome = pathname === "/admin/agents";
|
||||
const agentsView = searchParams.get("view") === "list" ? "list" : "tree";
|
||||
const profile = useAdminProfile();
|
||||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||
@@ -74,7 +75,7 @@ export function AgentsSubnav(): React.ReactElement {
|
||||
}, [deferredKeyword, siteOptions]);
|
||||
|
||||
const siteReadOnlyLabel =
|
||||
pathname !== "/admin/agents/list" &&
|
||||
isAgentsHome &&
|
||||
!canSwitchSite &&
|
||||
selectedSite != null ? (
|
||||
<div className="flex h-10 min-w-[200px] items-center justify-end gap-2 rounded-md border border-border/70 bg-background px-3 text-sm">
|
||||
@@ -84,7 +85,7 @@ export function AgentsSubnav(): React.ReactElement {
|
||||
) : null;
|
||||
|
||||
const siteSelector =
|
||||
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||
isAgentsHome && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
@@ -152,11 +153,22 @@ export function AgentsSubnav(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<AdminSubnavBar trailing={siteSelector ?? siteReadOnlyLabel}>
|
||||
<div className="pb-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("title", { defaultValue: "代理管理" })}
|
||||
</p>
|
||||
</div>
|
||||
{isAgentsHome ? (
|
||||
<AdminSubnav aria-label={t("viewModes.label")}>
|
||||
<AdminSubnavLink href="/admin/agents" active={agentsView === "tree"}>
|
||||
{t("viewModes.tree")}
|
||||
</AdminSubnavLink>
|
||||
<AdminSubnavLink href="/admin/agents?view=list" active={agentsView === "list"}>
|
||||
{t("viewModes.list")}
|
||||
</AdminSubnavLink>
|
||||
</AdminSubnav>
|
||||
) : (
|
||||
<div className="pb-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("title", { defaultValue: "代理管理" })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AdminSubnavBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,14 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,6 +35,28 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminAuditLogListData } from "@/types/api/admin-audit";
|
||||
|
||||
const MODULE_FILTER_OPTIONS = [
|
||||
"agent",
|
||||
"system",
|
||||
"settings",
|
||||
"integration",
|
||||
"player_manage",
|
||||
"player_service",
|
||||
"draw",
|
||||
"settlement",
|
||||
"wallet",
|
||||
"reconcile",
|
||||
"reconcile_jobs",
|
||||
"report_jobs",
|
||||
"odds",
|
||||
"play_config",
|
||||
"risk_cap",
|
||||
"jackpot",
|
||||
"dashboard",
|
||||
] as const;
|
||||
|
||||
const OPERATOR_TYPE_OPTIONS = ["admin", "player", "system"] as const;
|
||||
|
||||
export function AuditLogsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["audit", "common"]);
|
||||
const tRef = useTranslationRef(["audit", "common"]);
|
||||
@@ -39,15 +68,13 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [operatorId, setOperatorId] = useState("");
|
||||
const [moduleCode, setModuleCode] = useState("");
|
||||
const [actionCode, setActionCode] = useState("");
|
||||
const [operatorType, setOperatorType] = useState("");
|
||||
const [moduleCode, setModuleCode] = useState("all");
|
||||
const [operatorType, setOperatorType] = useState("all");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [appliedOperatorId, setAppliedOperatorId] = useState("");
|
||||
const [appliedModule, setAppliedModule] = useState("");
|
||||
const [appliedAction, setAppliedAction] = useState("");
|
||||
const [appliedOpType, setAppliedOpType] = useState("");
|
||||
const [appliedModule, setAppliedModule] = useState("all");
|
||||
const [appliedOpType, setAppliedOpType] = useState("all");
|
||||
const [appliedStartDate, setAppliedStartDate] = useState("");
|
||||
const [appliedEndDate, setAppliedEndDate] = useState("");
|
||||
|
||||
@@ -66,9 +93,8 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
page,
|
||||
per_page: perPage,
|
||||
operator_id: operatorIdParam,
|
||||
module_code: appliedModule.trim() || undefined,
|
||||
action_code: appliedAction.trim() || undefined,
|
||||
operator_type: appliedOpType.trim() || undefined,
|
||||
module_code: appliedModule !== "all" ? appliedModule : undefined,
|
||||
operator_type: appliedOpType !== "all" ? appliedOpType : undefined,
|
||||
start_date: appliedStartDate || undefined,
|
||||
end_date: appliedEndDate || undefined,
|
||||
});
|
||||
@@ -79,189 +105,203 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||
|
||||
const meta = data?.meta;
|
||||
|
||||
const operatorTypeLabel = (type: string): string => {
|
||||
const key = `operatorTypes.${type}` as const;
|
||||
const translated = t(key);
|
||||
return translated === key ? type : translated;
|
||||
};
|
||||
|
||||
const showTargetColumn = (row: AdminAuditLogListData["items"][number]): boolean => {
|
||||
const summary = row.summary_label || `${row.module_label} · ${row.action_label}`;
|
||||
const target = row.target_label;
|
||||
if (!target || target === "—") {
|
||||
return false;
|
||||
}
|
||||
return !summary.includes(target);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card w-full max-w-none">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-operator-id" className="sm:shrink-0">
|
||||
{t("operator")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-operator-id"
|
||||
value={operatorId}
|
||||
onChange={(e) => setOperatorId(e.target.value)}
|
||||
placeholder={t("operatorIdPlaceholder")}
|
||||
className="w-full sm:w-36"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-mod" className="sm:shrink-0">
|
||||
{t("moduleCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-mod"
|
||||
value={moduleCode}
|
||||
onChange={(e) => setModuleCode(e.target.value)}
|
||||
placeholder={t("exactMatch")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-act" className="sm:shrink-0">
|
||||
{t("actionCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-act"
|
||||
value={actionCode}
|
||||
onChange={(e) => setActionCode(e.target.value)}
|
||||
placeholder={t("exactMatch")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-op" className="sm:shrink-0">
|
||||
{t("operatorType")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-op"
|
||||
value={operatorType}
|
||||
onChange={(e) => setOperatorType(e.target.value)}
|
||||
placeholder={t("operatorTypePlaceholder")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-date-range" className="sm:shrink-0">
|
||||
{t("time")}
|
||||
</Label>
|
||||
<div className="min-w-0 w-full sm:w-56">
|
||||
<AdminDateRangeField
|
||||
id="aud-date-range"
|
||||
from={startDate}
|
||||
to={endDate}
|
||||
onRangeChange={(range) => {
|
||||
setStartDate(range.from);
|
||||
setEndDate(range.to);
|
||||
}}
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-module" className="sm:shrink-0">
|
||||
{t("filterModule")}
|
||||
</Label>
|
||||
<Select value={moduleCode} onValueChange={(value) => setModuleCode(value ?? "all")}>
|
||||
<SelectTrigger id="aud-module" className="w-full sm:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filterModuleAll")}</SelectItem>
|
||||
{MODULE_FILTER_OPTIONS.map((code) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{t(`moduleOptions.${code}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-op-type" className="sm:shrink-0">
|
||||
{t("filterOperatorType")}
|
||||
</Label>
|
||||
<Select value={operatorType} onValueChange={(value) => setOperatorType(value ?? "all")}>
|
||||
<SelectTrigger id="aud-op-type" className="w-full sm:w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filterOperatorTypeAll")}</SelectItem>
|
||||
{OPERATOR_TYPE_OPTIONS.map((code) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{t(`operatorTypes.${code}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-operator-id" className="sm:shrink-0">
|
||||
{t("operator")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-operator-id"
|
||||
value={operatorId}
|
||||
onChange={(e) => setOperatorId(e.target.value)}
|
||||
placeholder={t("operatorIdPlaceholder")}
|
||||
className="w-full sm:w-36"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="audit-logs-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAppliedOperatorId(operatorId);
|
||||
setAppliedModule(moduleCode);
|
||||
setAppliedAction(actionCode);
|
||||
setAppliedOpType(operatorType);
|
||||
setAppliedStartDate(startDate);
|
||||
setAppliedEndDate(endDate);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOperatorId("");
|
||||
setModuleCode("");
|
||||
setActionCode("");
|
||||
setOperatorType("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setAppliedOperatorId("");
|
||||
setAppliedModule("");
|
||||
setAppliedAction("");
|
||||
setAppliedOpType("");
|
||||
setAppliedStartDate("");
|
||||
setAppliedEndDate("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("actions.reset", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="audit-logs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("operator")}</TableHead>
|
||||
<TableHead>{t("module")}</TableHead>
|
||||
<TableHead>{t("action")}</TableHead>
|
||||
<TableHead>{t("target")}</TableHead>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={6} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={6} />
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.id}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<div className="font-medium text-foreground">{row.operator_label}</div>
|
||||
{row.operator_subtitle ? (
|
||||
<div className="text-muted-foreground">{row.operator_subtitle}</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{row.module_label}</TableCell>
|
||||
<TableCell className="text-sm">{row.action_label}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{row.target_label}</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-date-range" className="sm:shrink-0">
|
||||
{t("time")}
|
||||
</Label>
|
||||
<div className="min-w-0 w-full sm:w-56">
|
||||
<AdminDateRangeField
|
||||
id="aud-date-range"
|
||||
from={startDate}
|
||||
to={endDate}
|
||||
onRangeChange={(range) => {
|
||||
setStartDate(range.from);
|
||||
setEndDate(range.to);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{meta ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="audit-logs-per-page"
|
||||
total={meta.total}
|
||||
page={meta.current_page}
|
||||
lastPage={Math.max(1, meta.last_page)}
|
||||
perPage={meta.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="audit-logs-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAppliedOperatorId(operatorId);
|
||||
setAppliedModule(moduleCode);
|
||||
setAppliedOpType(operatorType);
|
||||
setAppliedStartDate(startDate);
|
||||
setAppliedEndDate(endDate);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOperatorId("");
|
||||
setModuleCode("all");
|
||||
setOperatorType("all");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setAppliedOperatorId("");
|
||||
setAppliedModule("all");
|
||||
setAppliedOpType("all");
|
||||
setAppliedStartDate("");
|
||||
setAppliedEndDate("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("actions.reset", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="audit-logs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40 whitespace-nowrap">{t("time")}</TableHead>
|
||||
<TableHead className="w-36">{t("operator")}</TableHead>
|
||||
<TableHead>{t("summary")}</TableHead>
|
||||
<TableHead className="w-36">{t("target")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={4} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={4} message={t("empty")} />
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div className="font-medium text-foreground">{row.operator_label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{operatorTypeLabel(row.operator_type)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm leading-relaxed">
|
||||
{row.summary_label || `${row.module_label} · ${row.action_label}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{showTargetColumn(row) ? row.target_label : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{meta ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="audit-logs-per-page"
|
||||
total={meta.total}
|
||||
page={meta.current_page}
|
||||
lastPage={Math.max(1, meta.last_page)}
|
||||
perPage={meta.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<AdminNoResourceState message={t("empty")} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,18 +32,13 @@ export function ConfigDocToolbar({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border/60 bg-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:p-4">
|
||||
<div className={cn("rounded-lg border border-border/70 bg-background", className)}>
|
||||
<div className="flex flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">{switcher}</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 sm:justify-end">{actions}</div>
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="border-t border-border/50 bg-muted/20 px-3 py-2.5 sm:px-4">{footer}</div>
|
||||
<div className="border-t border-border/60 px-3 py-2 text-xs text-muted-foreground sm:px-3">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, RefreshCw, Rocket, Save } from "lucide-react";
|
||||
import { Plus, RefreshCw, Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -75,7 +75,6 @@ export function ConfigVersionActions({
|
||||
{t("versionActions.saveDraft")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={draftActionBusy} onClick={onPublish}>
|
||||
<Rocket className="size-3.5" aria-hidden />
|
||||
{resolvedPublishLabel}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ChevronRight, Layers, MoreHorizontal } from "lucide-react";
|
||||
import { Check, ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,20 +20,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import type { ConfigVersionSummary } from "@/types/api/admin-config";
|
||||
|
||||
const STATUS_ORDER = ["draft", "active", "archived"] as const;
|
||||
|
||||
export type ConfigVersionSwitcherProps = {
|
||||
versions: ConfigVersionSummary[];
|
||||
selectedId: string;
|
||||
@@ -48,34 +41,12 @@ export type ConfigVersionSwitcherProps = {
|
||||
rollbackBusy?: boolean;
|
||||
};
|
||||
|
||||
function formatVersionNote(
|
||||
reason: string | null | undefined,
|
||||
formatDt: (iso: string | null | undefined) => string,
|
||||
): string | null {
|
||||
if (!reason?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = reason.trim();
|
||||
const isoMatch = trimmed.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/);
|
||||
if (trimmed.startsWith("draft") && isoMatch) {
|
||||
return formatDt(isoMatch[0]);
|
||||
}
|
||||
if (trimmed.startsWith("seed:")) {
|
||||
return trimmed.slice(5);
|
||||
}
|
||||
if (trimmed.length > 56) {
|
||||
return `${trimmed.slice(0, 56)}…`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function ConfigVersionSwitcher({
|
||||
versions,
|
||||
selectedId,
|
||||
onSelectedIdChange,
|
||||
loading = false,
|
||||
sheetTitle,
|
||||
sheetDescription,
|
||||
className,
|
||||
onDeleteVersion,
|
||||
onRollbackVersion,
|
||||
@@ -83,48 +54,24 @@ export function ConfigVersionSwitcher({
|
||||
}: ConfigVersionSwitcherProps) {
|
||||
const { t } = useTranslation(["config", "adminUsers"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ConfigVersionSummary | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const resolvedSheetTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" });
|
||||
const resolvedSheetDescription =
|
||||
sheetDescription ?? t("versionSwitcher.sheetDescription", { ns: "config" });
|
||||
const resolvedTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" });
|
||||
|
||||
const sortedVersions = useMemo(
|
||||
() => [...versions].sort((a, b) => b.id - a.id),
|
||||
() => [...versions].sort((a, b) => b.version_no - a.version_no),
|
||||
[versions],
|
||||
);
|
||||
|
||||
const groupedVersions = useMemo(() => {
|
||||
const groups = new Map<string, ConfigVersionSummary[]>();
|
||||
for (const status of STATUS_ORDER) {
|
||||
groups.set(status, []);
|
||||
}
|
||||
for (const v of sortedVersions) {
|
||||
const list = groups.get(v.status) ?? [];
|
||||
list.push(v);
|
||||
groups.set(v.status, list);
|
||||
}
|
||||
return groups;
|
||||
}, [sortedVersions]);
|
||||
|
||||
const selectedVersion = useMemo(
|
||||
() => sortedVersions.find((v) => String(v.id) === selectedId) ?? null,
|
||||
[selectedId, sortedVersions],
|
||||
);
|
||||
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
STATUS_ORDER.map((status) => ({
|
||||
status,
|
||||
rows: groupedVersions.get(status) ?? [],
|
||||
})).filter((section) => section.rows.length > 0),
|
||||
[groupedVersions],
|
||||
);
|
||||
|
||||
function switchTo(id: number) {
|
||||
onSelectedIdChange(String(id));
|
||||
setSheetOpen(false);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
@@ -143,181 +90,120 @@ export function ConfigVersionSwitcher({
|
||||
}
|
||||
}
|
||||
|
||||
const triggerLabel = loading
|
||||
? t("versionSwitcher.loading", { ns: "config" })
|
||||
: selectedVersion
|
||||
? `v${selectedVersion.version_no}`
|
||||
: t("versionSwitcher.noneSelected", { ns: "config" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex min-w-0 items-center gap-3", className)}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{selectedVersion ? (
|
||||
<>
|
||||
<span className="font-mono text-base font-semibold tabular-nums text-foreground">
|
||||
v{selectedVersion.version_no}
|
||||
</span>
|
||||
<ConfigStatusBadge status={selectedVersion.status} className="h-5 px-1.5 text-[11px]" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{loading
|
||||
? t("versionSwitcher.loading", { ns: "config" })
|
||||
: t("versionSwitcher.noneSelected", { ns: "config" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={loading || sortedVersions.length === 0}
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="h-8 shrink-0 gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
className={cn(
|
||||
"inline-flex h-9 min-w-[10rem] max-w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none transition-colors hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Layers className="size-3.5" aria-hidden />
|
||||
{t("versionSwitcher.switch", { ns: "config" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 overflow-hidden border-l bg-background p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetHeader className="space-y-1 border-b border-border/60 px-5 py-4 text-left">
|
||||
<SheetTitle className="text-[15px] font-semibold tracking-tight">
|
||||
{resolvedSheetTitle}
|
||||
</SheetTitle>
|
||||
{resolvedSheetDescription ? (
|
||||
<SheetDescription className="text-[13px] leading-relaxed">
|
||||
{resolvedSheetDescription}
|
||||
</SheetDescription>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono tabular-nums">{triggerLabel}</span>
|
||||
{selectedVersion ? (
|
||||
<ConfigStatusBadge status={selectedVersion.status} className="h-5 shrink-0 px-1.5 text-[11px]" />
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||
<PopoverContent align="start" className="w-[min(20rem,calc(100vw-2rem))] p-0">
|
||||
<div className="border-b border-border/60 px-3 py-2 text-sm font-medium">{resolvedTitle}</div>
|
||||
<ScrollArea className="max-h-72">
|
||||
{sortedVersions.length === 0 ? (
|
||||
<AdminNoResourceState compact className="px-2 py-8" />
|
||||
<AdminNoResourceState compact className="px-3 py-6" />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{visibleSections.map((section) => (
|
||||
<section key={section.status}>
|
||||
<p className="mb-2 px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t(`versionStatus.${section.status}`, { ns: "config" })}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{section.rows.map((v) => {
|
||||
const isCurrent = selectedId === String(v.id);
|
||||
const note = formatVersionNote(v.reason, formatDt);
|
||||
const effectiveLabel = v.effective_at
|
||||
? formatDt(v.effective_at)
|
||||
: null;
|
||||
const meta = [effectiveLabel, note].filter(Boolean).join(" · ");
|
||||
const showMenu =
|
||||
(onDeleteVersion && v.status !== "active") ||
|
||||
(onRollbackVersion && v.status !== "draft");
|
||||
<ul className="p-1">
|
||||
{sortedVersions.map((v) => {
|
||||
const isCurrent = selectedId === String(v.id);
|
||||
const secondary =
|
||||
v.status === "active" && v.effective_at ? formatDt(v.effective_at) : null;
|
||||
const showMenu =
|
||||
(onDeleteVersion && v.status !== "active") ||
|
||||
(onRollbackVersion && v.status !== "draft");
|
||||
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-stretch gap-0.5 rounded-lg border transition-colors",
|
||||
isCurrent
|
||||
? "border-primary/40 bg-primary/[0.04]"
|
||||
: "border-transparent hover:border-border/60 hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left"
|
||||
onClick={() => switchTo(v.id)}
|
||||
return (
|
||||
<li key={v.id} className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors",
|
||||
isCurrent ? "bg-muted" : "hover:bg-muted/60",
|
||||
)}
|
||||
onClick={() => switchTo(v.id)}
|
||||
>
|
||||
<span className="font-mono tabular-nums">v{v.version_no}</span>
|
||||
<ConfigStatusBadge status={v.status} className="h-5 px-1.5 text-[11px]" />
|
||||
{secondary ? (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{secondary}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
{isCurrent ? <Check className="size-4 shrink-0 text-foreground" aria-hidden /> : null}
|
||||
</button>
|
||||
|
||||
{showMenu ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground"
|
||||
aria-label={t("versionSwitcher.moreActions", {
|
||||
ns: "config",
|
||||
version: v.version_no,
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-32">
|
||||
{onRollbackVersion && v.status !== "draft" ? (
|
||||
<DropdownMenuItem
|
||||
disabled={rollbackBusy}
|
||||
onClick={() => {
|
||||
onRollbackVersion(v);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[15px] font-semibold tabular-nums text-foreground">
|
||||
v{v.version_no}
|
||||
</span>
|
||||
<ConfigStatusBadge
|
||||
status={v.status}
|
||||
className="h-5 px-1.5 text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
{meta ? (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{meta}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{isCurrent ? (
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" strokeWidth={2.5} aria-hidden />
|
||||
<span className="sr-only">
|
||||
{t("versionSwitcher.current", { ns: "config" })}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showMenu ? (
|
||||
<div className="flex shrink-0 items-center pr-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 data-popup-open:opacity-100"
|
||||
aria-label={t("versionSwitcher.moreActions", {
|
||||
ns: "config",
|
||||
version: v.version_no,
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-36">
|
||||
{onRollbackVersion && v.status !== "draft" ? (
|
||||
<DropdownMenuItem
|
||||
disabled={rollbackBusy}
|
||||
onClick={() => {
|
||||
onRollbackVersion(v);
|
||||
setSheetOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("versionSwitcher.rollback", { ns: "config" })}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDeleteVersion && v.status !== "active" ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={deletingId === v.id}
|
||||
onClick={() => setDeleteTarget(v)}
|
||||
>
|
||||
{t("versionSwitcher.delete", { ns: "config" })}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{t("versionSwitcher.rollback", { ns: "config" })}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDeleteVersion && v.status !== "active" ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={deletingId === v.id}
|
||||
onClick={() => setDeleteTarget(v)}
|
||||
>
|
||||
{t("versionSwitcher.delete", { ns: "config" })}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<Dialog open={deleteTarget !== null} onOpenChange={(openState) => !openState && setDeleteTarget(null)}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("versionSwitcher.deleteConfirmDescription", {
|
||||
ns: "config",
|
||||
id: deleteTarget?.id,
|
||||
version: deleteTarget?.version_no,
|
||||
})}
|
||||
</DialogDescription>
|
||||
@@ -339,4 +225,4 @@ export function ConfigVersionSwitcher({
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
import {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -37,7 +34,7 @@ import { ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
|
||||
import { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
@@ -56,9 +53,7 @@ import type {
|
||||
} from "@/types/api/admin-config";
|
||||
|
||||
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
|
||||
import { oddsDraftIsDirty } from "@/modules/config/doc/odds-config-dirty";
|
||||
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
|
||||
import { OddsConfigSummaryPanel } from "@/modules/config/doc/odds-config-summary-panel";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -120,7 +115,6 @@ export function OddsConfigDocScreen({
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [internalSelectedId, setInternalSelectedId] = useState("");
|
||||
@@ -465,8 +459,6 @@ export function OddsConfigDocScreen({
|
||||
}
|
||||
}
|
||||
|
||||
const activeHead = resolvedList.find((x) => x.status === "active");
|
||||
|
||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||
try {
|
||||
await deleteOddsVersion(row.id);
|
||||
@@ -509,18 +501,10 @@ export function OddsConfigDocScreen({
|
||||
{ id: "d2", label: "2D" },
|
||||
];
|
||||
|
||||
const activeCatLabel = catTabs.find((tab) => tab.id === catTab)?.label ?? catTab;
|
||||
const activePlayLabel = resolvedPlayCode
|
||||
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
|
||||
: "—";
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!resolvedDetail || !isDraft) {
|
||||
return false;
|
||||
}
|
||||
return oddsDraftIsDirty(resolvedDraftRows, resolvedDetail.items);
|
||||
}, [isDraft, resolvedDetail, resolvedDraftRows]);
|
||||
|
||||
const filtersInner = (
|
||||
<>
|
||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||
@@ -602,8 +586,7 @@ export function OddsConfigDocScreen({
|
||||
selectedId={selectedId}
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={resolvedLoadingList}
|
||||
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription={embedded ? undefined : t("odds.sheetDescription", { ns: "config" })}
|
||||
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onRollbackVersion={requestRollback}
|
||||
rollbackBusy={saving}
|
||||
@@ -623,58 +606,9 @@ export function OddsConfigDocScreen({
|
||||
onPublish={() => void requestPublishConfirm()}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
!resolvedDetail ? null : (
|
||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||
<span>
|
||||
{t("odds.activeVersionPrefix", { ns: "config" })}
|
||||
{activeHead ? (
|
||||
<>
|
||||
v{activeHead.version_no}
|
||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</span>
|
||||
{!isDraft ? (
|
||||
<ConfigVersionToolbarMetaEmphasis>
|
||||
{t("odds.readOnlyHint", { ns: "config" })}
|
||||
</ConfigVersionToolbarMetaEmphasis>
|
||||
) : activeHead ? (
|
||||
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</ConfigVersionToolbarMeta>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const rebateField = (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||
<div className="grid max-w-xs gap-1.5">
|
||||
<Label htmlFor="odds-rebate-rate">{t("odds.rebateRate", { ns: "config" })}</Label>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
id="odds-rebate-rate"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-9 text-base font-semibold"
|
||||
disabled={saving}
|
||||
value={rebatePercentUi}
|
||||
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue className="h-9 w-full max-w-xs justify-center text-base font-semibold">
|
||||
{rebatePercentUi}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
|
||||
const row = scopeRows[scope];
|
||||
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||
@@ -726,6 +660,26 @@ export function OddsConfigDocScreen({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{t("odds.rebateRate", { ns: "config" })}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="ml-auto h-9 w-full max-w-[9rem] text-base font-semibold"
|
||||
disabled={saving}
|
||||
value={rebatePercentUi}
|
||||
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue className="ml-auto h-9 w-full max-w-[9rem] justify-center text-base font-semibold">
|
||||
{rebatePercentUi}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
@@ -798,19 +752,7 @@ export function OddsConfigDocScreen({
|
||||
/>
|
||||
) : resolvedPlayCode ? (
|
||||
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
|
||||
{mergedLayout ? (
|
||||
<div className="space-y-4">
|
||||
{mergedOddsTable}
|
||||
{rebateField}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{classicOddsGrid}
|
||||
{!embedded ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{mergedLayout ? mergedOddsTable : classicOddsGrid}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -889,51 +831,31 @@ export function OddsConfigDocScreen({
|
||||
if (embedded && mergedLayout) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">{toolbarBlock}</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_min(100%,260px)] xl:items-start">
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||
<div className="grid gap-0 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
|
||||
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
|
||||
<OddsConfigPlayNav
|
||||
catTab={catTab}
|
||||
onCatTabChange={setCatTab}
|
||||
onPlayCodeChange={setPlayCode}
|
||||
types={sortedTypes}
|
||||
resolvedPlayCode={resolvedPlayCode}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="border-b border-border/50 px-4 py-3 sm:px-5">
|
||||
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("odds.currentSelection", {
|
||||
ns: "config",
|
||||
category: activeCatLabel,
|
||||
play: activePlayLabel,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
|
||||
{isDraft && canManage ? (
|
||||
<OddsConfigDraftBar
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loadingDetail={resolvedLoadingDetail}
|
||||
onSave={() => void handleSave()}
|
||||
onPublish={() => void requestPublishConfirm()}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{toolbarBlock}
|
||||
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
|
||||
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
|
||||
<OddsConfigPlayNav
|
||||
catTab={catTab}
|
||||
onCatTabChange={setCatTab}
|
||||
onPlayCodeChange={setPlayCode}
|
||||
types={sortedTypes}
|
||||
resolvedPlayCode={resolvedPlayCode}
|
||||
/>
|
||||
</aside>
|
||||
<div className="min-w-0">
|
||||
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
|
||||
</div>
|
||||
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
|
||||
{isDraft && canManage ? (
|
||||
<OddsConfigDraftBar
|
||||
saving={saving}
|
||||
loadingDetail={resolvedLoadingDetail}
|
||||
onSave={() => void handleSave()}
|
||||
onPublish={() => void requestPublishConfirm()}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OddsConfigSummaryPanel
|
||||
compact
|
||||
detail={resolvedDetail}
|
||||
activeHead={activeHead ?? null}
|
||||
/>
|
||||
</div>
|
||||
{dialogs}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Rocket, Save } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OddsConfigDraftBarProps = {
|
||||
isDirty: boolean;
|
||||
saving: boolean;
|
||||
loadingDetail?: boolean;
|
||||
onSave: () => void;
|
||||
@@ -16,7 +15,6 @@ type OddsConfigDraftBarProps = {
|
||||
};
|
||||
|
||||
export function OddsConfigDraftBar({
|
||||
isDirty,
|
||||
saving,
|
||||
loadingDetail = false,
|
||||
onSave,
|
||||
@@ -29,23 +27,17 @@ export function OddsConfigDraftBar({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky bottom-0 z-10 flex flex-col gap-3 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-5",
|
||||
"sticky bottom-0 z-10 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 bg-card/95 px-4 py-2.5 backdrop-blur sm:px-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDirty ? t("odds.draftBar.unsaved") : t("odds.draftBar.saved")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
|
||||
<Save className="size-3.5" aria-hidden />
|
||||
{t("versionActions.saveDraft")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={busy} onClick={onPublish}>
|
||||
<Rocket className="size-3.5" aria-hidden />
|
||||
{t("versionActions.publishCurrent")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
|
||||
<Save className="size-3.5" aria-hidden />
|
||||
{t("versionActions.saveDraft")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={busy} onClick={onPublish}>
|
||||
{t("versionActions.publishCurrent")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,8 +136,8 @@ export function OddsConfigPlayNav({
|
||||
className={cn(
|
||||
"w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors",
|
||||
active
|
||||
? "bg-primary font-medium text-primary-foreground shadow-sm"
|
||||
: "text-foreground hover:bg-muted/80",
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-foreground hover:bg-muted/60",
|
||||
)}
|
||||
onClick={() => onPlayCodeChange(type.play_code)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -17,11 +17,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
import {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
|
||||
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -52,7 +48,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
@@ -152,7 +148,6 @@ export function PlayConfigDocScreen() {
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
|
||||
@@ -429,8 +424,6 @@ export function PlayConfigDocScreen() {
|
||||
return <span>{name || row.play_code}</span>;
|
||||
}
|
||||
|
||||
const activeHead = list.find((x) => x.status === "active");
|
||||
|
||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||
try {
|
||||
await deletePlayConfigVersion(row.id);
|
||||
@@ -488,7 +481,7 @@ export function PlayConfigDocScreen() {
|
||||
selectedId={selectedId}
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loadingList}
|
||||
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onRollbackVersion={requestRollback}
|
||||
rollbackBusy={saving}
|
||||
@@ -515,100 +508,63 @@ export function PlayConfigDocScreen() {
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
detail ? (
|
||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||
{activeHead ? (
|
||||
<span>
|
||||
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
|
||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{!isDraft ? (
|
||||
<ConfigVersionToolbarMetaEmphasis>
|
||||
{t("play.readOnlyHint", { ns: "config" })}
|
||||
</ConfigVersionToolbarMetaEmphasis>
|
||||
) : activeHead ? (
|
||||
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</ConfigVersionToolbarMeta>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{detail ? (
|
||||
<ConfigSection
|
||||
title={t("play.filters.sectionTitle", { ns: "config" })}
|
||||
description={isDraft ? t("play.filters.sectionDescription", { ns: "config" }) : undefined}
|
||||
>
|
||||
{!isDraft ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
|
||||
{t("play.readOnlyDraftHint", { ns: "config" })}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="flex flex-1 flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
|
||||
<div className="flex min-w-0 flex-col gap-1.5 md:w-[320px]">
|
||||
<span className="text-sm font-medium">{t("play.filters.keyword", { ns: "config" })}</span>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 md:w-[140px]">
|
||||
<span className="text-sm font-medium">{t("play.filters.category", { ns: "config" })}</span>
|
||||
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue>
|
||||
{categoryFilter === "all"
|
||||
? t("play.filters.allCategories", { ns: "config" })
|
||||
: categoryLabel(categoryFilter)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
|
||||
{categoryOptions.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{categoryLabel(category)}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="uncategorized">
|
||||
{t("play.filters.uncategorized", { ns: "config" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 md:w-[140px]">
|
||||
<span className="text-sm font-medium">{t("play.filters.status", { ns: "config" })}</span>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue>
|
||||
{statusFilter === "all"
|
||||
? t("play.filters.allStatuses", { ns: "config" })
|
||||
: statusFilter === "enabled"
|
||||
? t("play.states.enabled", { ns: "config" })
|
||||
: t("play.states.disabled", { ns: "config" })}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("play.filters.allStatuses", { ns: "config" })}</SelectItem>
|
||||
<SelectItem value="enabled">{t("play.states.enabled", { ns: "config" })}</SelectItem>
|
||||
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-start lg:flex-none">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
|
||||
className="h-8 w-full min-w-[12rem] max-w-xs"
|
||||
/>
|
||||
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
|
||||
<SelectTrigger className="h-8 w-[8.5rem]">
|
||||
<SelectValue>
|
||||
{categoryFilter === "all"
|
||||
? t("play.filters.allCategories", { ns: "config" })
|
||||
: categoryLabel(categoryFilter)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
|
||||
{categoryOptions.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{categoryLabel(category)}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="uncategorized">
|
||||
{t("play.filters.uncategorized", { ns: "config" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[6.5rem]">
|
||||
<SelectValue>
|
||||
{statusFilter === "all"
|
||||
? t("play.filters.allStatuses", { ns: "config" })
|
||||
: statusFilter === "enabled"
|
||||
? t("play.states.enabled", { ns: "config" })
|
||||
: t("play.states.disabled", { ns: "config" })}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("play.filters.allStatuses", { ns: "config" })}</SelectItem>
|
||||
<SelectItem value="enabled">{t("play.states.enabled", { ns: "config" })}</SelectItem>
|
||||
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(keyword || categoryFilter !== "all" || statusFilter !== "all") ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setKeyword("");
|
||||
setCategoryFilter("all");
|
||||
@@ -617,76 +573,52 @@ export function PlayConfigDocScreen() {
|
||||
>
|
||||
{t("play.filters.reset", { ns: "config" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{isDraft ? (
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t("play.batchSwitchesTitle", { ns: "config" })}
|
||||
</div>
|
||||
<ConfigChipGroup>
|
||||
{batchSwitchStates.map((group) => {
|
||||
const groupOn = group.allEnabled;
|
||||
const isPartial =
|
||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||
return (
|
||||
<div
|
||||
key={group.key}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-card px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{group.total > 0
|
||||
? isPartial
|
||||
? t("play.batchPartialEnabled", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.noPlayTypes", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={groupOn}
|
||||
indeterminate={isPartial}
|
||||
disabled={saving || group.total === 0 || confirmBusy}
|
||||
aria-label={t("play.aria.batchGroupSwitch", {
|
||||
<ConfigChipGroup label={t("play.batchSwitchesTitle", { ns: "config" })}>
|
||||
{batchSwitchStates.map((group) => {
|
||||
const groupOn = group.allEnabled;
|
||||
const isPartial =
|
||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||
return (
|
||||
<label
|
||||
key={group.key}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm"
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
<Checkbox
|
||||
checked={groupOn}
|
||||
indeterminate={isPartial}
|
||||
disabled={saving || group.total === 0 || confirmBusy}
|
||||
aria-label={t("play.aria.batchGroupSwitch", {
|
||||
ns: "config",
|
||||
group: group.label,
|
||||
})}
|
||||
onCheckedChange={(checked) => {
|
||||
const enable = checked === true;
|
||||
const action = enable
|
||||
? t("play.batchSwitchEnable", { ns: "config" })
|
||||
: t("play.batchSwitchDisable", { ns: "config" });
|
||||
requestConfirm({
|
||||
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||
description: t("play.batchSwitchConfirmDescription", {
|
||||
ns: "config",
|
||||
action,
|
||||
group: group.label,
|
||||
})}
|
||||
onCheckedChange={(checked) => {
|
||||
const enable = checked === true;
|
||||
const action = enable
|
||||
? t("play.batchSwitchEnable", { ns: "config" })
|
||||
: t("play.batchSwitchDisable", { ns: "config" });
|
||||
requestConfirm({
|
||||
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||
description: t("play.batchSwitchConfirmDescription", {
|
||||
ns: "config",
|
||||
action,
|
||||
group: group.label,
|
||||
count: group.total,
|
||||
}),
|
||||
confirmVariant: enable ? "default" : "destructive",
|
||||
onConfirm: () => applyBatchSwitch(group, enable),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ConfigChipGroup>
|
||||
</div>
|
||||
count: group.total,
|
||||
}),
|
||||
confirmVariant: enable ? "default" : "destructive",
|
||||
onConfirm: () => applyBatchSwitch(group, enable),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</ConfigChipGroup>
|
||||
) : null}
|
||||
</ConfigSection>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
@@ -481,8 +481,7 @@ export function RebateConfigDocScreen({
|
||||
selectedId={selectedId}
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={resolvedLoading}
|
||||
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
|
||||
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onRollbackVersion={requestRollback}
|
||||
rollbackBusy={saving}
|
||||
|
||||
@@ -15,14 +15,11 @@ import {
|
||||
putRiskCapItems,
|
||||
} from "@/api/admin-config";
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
import {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
import { ConfigVersionToolbarMeta } from "@/modules/config/config-version-toolbar-meta";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -35,7 +32,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -44,13 +40,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
@@ -63,9 +53,7 @@ import { PRD_RISK_CAP_MANAGE } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
||||
import type {
|
||||
AdminDrawListItem,
|
||||
} from "@/types/api/admin-draws";
|
||||
import type { AdminDrawListItem } from "@/types/api/admin-draws";
|
||||
import type {
|
||||
ConfigVersionSummary,
|
||||
RiskCapItemRow,
|
||||
@@ -102,6 +90,14 @@ function formatMinorToEditableMajor(minor: number, currencyCode: string): string
|
||||
return formatAdminMinorDecimal(minor, currencyCode).replace(/,/g, "");
|
||||
}
|
||||
|
||||
function syncDefaultCapInput(rows: DraftRiskRow[], currencyCode: string): string {
|
||||
const defaultRow = rows.find(isDefaultRiskRow);
|
||||
if (!defaultRow) {
|
||||
return "";
|
||||
}
|
||||
return formatMinorToEditableMajor(defaultRow.cap_amount, currencyCode);
|
||||
}
|
||||
|
||||
export function RiskCapDocScreen() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["config", "common"]);
|
||||
@@ -113,16 +109,15 @@ export function RiskCapDocScreen() {
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState<RiskCapVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<DraftRiskRow[]>([]);
|
||||
const [defaultCapInput, setDefaultCapInput] = useState("");
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawOptions, setDrawOptions] = useState<AdminDrawListItem[]>([]);
|
||||
|
||||
const [defaultCapStr, setDefaultCapStr] = useState("");
|
||||
const [syncOpen, setSyncOpen] = useState(false);
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
||||
const [showNumberCaps, setShowNumberCaps] = useState(false);
|
||||
|
||||
const amountCurrencyCode = "NPR";
|
||||
|
||||
@@ -159,40 +154,49 @@ export function RiskCapDocScreen() {
|
||||
void loadDrawOptions();
|
||||
}, []);
|
||||
|
||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
||||
const defaultRow = rows.find(isDefaultRiskRow);
|
||||
if (!defaultRow) {
|
||||
setDefaultCapStr("");
|
||||
return;
|
||||
}
|
||||
setDefaultCapStr(formatMinorToEditableMajor(defaultRow.cap_amount, amountCurrencyCode));
|
||||
}
|
||||
const applyDefaultCapInput = useCallback(
|
||||
(value: string) =>
|
||||
(prev: DraftRiskRow[]): DraftRiskRow[] => {
|
||||
const n = parseAdminMajorToMinor(value, amountCurrencyCode);
|
||||
const rest = prev.filter((row) => !isDefaultRiskRow(row));
|
||||
if (n != null && n > 0) {
|
||||
return [defaultRiskRowFromAmount(n), ...rest];
|
||||
}
|
||||
return rest;
|
||||
},
|
||||
[amountCurrencyCode],
|
||||
);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const d = await getRiskCapVersion(id);
|
||||
setDetail(d);
|
||||
const mapped = d.items.map((it) => ({
|
||||
clientKey: `srv-${it.id}`,
|
||||
draw_id: it.draw_id,
|
||||
normalized_number: it.normalized_number,
|
||||
cap_amount: it.cap_amount,
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(mapped);
|
||||
syncDefaultCapFromRows(mapped);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
syncDefaultCapFromRows([]);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, []);
|
||||
const loadDetail = useCallback(
|
||||
async (id: number) => {
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const d = await getRiskCapVersion(id);
|
||||
setDetail(d);
|
||||
const mapped = d.items.map((it) => ({
|
||||
clientKey: `srv-${it.id}`,
|
||||
draw_id: it.draw_id,
|
||||
normalized_number: it.normalized_number,
|
||||
cap_amount: it.cap_amount,
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(mapped);
|
||||
setDefaultCapInput(syncDefaultCapInput(mapped, amountCurrencyCode));
|
||||
setShowNumberCaps(mapped.some((row) => !isDefaultRiskRow(row)));
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||
);
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
setDefaultCapInput("");
|
||||
setShowNumberCaps(false);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
},
|
||||
[amountCurrencyCode, tRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0) {
|
||||
@@ -201,7 +205,7 @@ export function RiskCapDocScreen() {
|
||||
setSelectedId("");
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
syncDefaultCapFromRows([]);
|
||||
setDefaultCapInput("");
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -251,11 +255,13 @@ export function RiskCapDocScreen() {
|
||||
if (!detail || !canEditDraft) {
|
||||
return;
|
||||
}
|
||||
if (draftRows.length === 0) {
|
||||
const rowsToSave = applyDefaultCapInput(defaultCapInput)(draftRows);
|
||||
setDraftRows(rowsToSave);
|
||||
if (rowsToSave.length === 0) {
|
||||
toast.error(t("riskCap.validation.requireAtLeastOne", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
for (const r of draftRows) {
|
||||
for (const r of rowsToSave) {
|
||||
if (isDefaultRiskRow(r)) {
|
||||
if (r.cap_amount <= 0) {
|
||||
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
|
||||
@@ -278,7 +284,7 @@ export function RiskCapDocScreen() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = draftRows.map((r) => ({
|
||||
const payload = rowsToSave.map((r) => ({
|
||||
draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null,
|
||||
normalized_number: r.normalized_number,
|
||||
cap_amount: r.cap_amount,
|
||||
@@ -294,7 +300,7 @@ export function RiskCapDocScreen() {
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(saved);
|
||||
syncDefaultCapFromRows(saved);
|
||||
setDefaultCapInput(syncDefaultCapInput(saved, amountCurrencyCode));
|
||||
toast.success(t("versionActions.saveDraft", { ns: "config" }));
|
||||
void refreshList();
|
||||
} catch (e) {
|
||||
@@ -320,7 +326,7 @@ export function RiskCapDocScreen() {
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(pub);
|
||||
syncDefaultCapFromRows(pub);
|
||||
setDefaultCapInput(syncDefaultCapInput(pub, amountCurrencyCode));
|
||||
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
@@ -351,7 +357,7 @@ export function RiskCapDocScreen() {
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(nd);
|
||||
syncDefaultCapFromRows(nd);
|
||||
setDefaultCapInput(syncDefaultCapInput(nd, amountCurrencyCode));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
@@ -359,37 +365,25 @@ export function RiskCapDocScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyDefaultCap() {
|
||||
const n = parseAdminMajorToMinor(defaultCapStr, amountCurrencyCode);
|
||||
if (n == null || !Number.isFinite(n) || n <= 0) {
|
||||
toast.error(t("riskCap.validation.enterValidCapAmount", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
setDraftRows((prev) => {
|
||||
const next = prev.filter((row) => !isDefaultRiskRow(row));
|
||||
return [defaultRiskRowFromAmount(n), ...next];
|
||||
});
|
||||
setSyncOpen(false);
|
||||
toast.message(t("riskCap.savedLocalDraft", { ns: "config" }));
|
||||
}
|
||||
|
||||
const specialRows = useMemo(
|
||||
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
|
||||
() =>
|
||||
draftRows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => !isDefaultRiskRow(row) && row.draw_id == null),
|
||||
[draftRows],
|
||||
);
|
||||
const globalRows = useMemo(
|
||||
() => specialRows.filter(({ row }) => row.draw_id == null),
|
||||
[specialRows],
|
||||
);
|
||||
const drawRows = useMemo(
|
||||
() => specialRows.filter(({ row }) => row.draw_id != null),
|
||||
[specialRows],
|
||||
|
||||
const drawBoundRows = useMemo(
|
||||
() => draftRows.filter((row) => !isDefaultRiskRow(row) && row.draw_id != null),
|
||||
[draftRows],
|
||||
);
|
||||
|
||||
function drawLabel(drawId: number): string {
|
||||
return drawOptions.find((draw) => draw.id === drawId)?.draw_no ?? String(drawId);
|
||||
}
|
||||
|
||||
const defaultCapDisplay = detail
|
||||
? formatAdminMinorDecimal(
|
||||
draftRows.find(isDefaultRiskRow)?.cap_amount ?? 0,
|
||||
amountCurrencyCode,
|
||||
)
|
||||
? formatAdminMinorDecimal(draftRows.find(isDefaultRiskRow)?.cap_amount ?? 0, amountCurrencyCode)
|
||||
: formatAdminMinorDecimal(0, amountCurrencyCode);
|
||||
|
||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||
@@ -436,7 +430,7 @@ export function RiskCapDocScreen() {
|
||||
cap_type: it.cap_type,
|
||||
}));
|
||||
setDraftRows(mapped);
|
||||
syncDefaultCapFromRows(mapped);
|
||||
setDefaultCapInput(syncDefaultCapInput(mapped, amountCurrencyCode));
|
||||
setRollbackOpen(false);
|
||||
setRollbackTarget(null);
|
||||
} catch (e) {
|
||||
@@ -458,7 +452,7 @@ export function RiskCapDocScreen() {
|
||||
selectedId={selectedId}
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loadingList}
|
||||
sheetTitle={`${t("nav.items.risk-cap", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onRollbackVersion={requestRollback}
|
||||
rollbackBusy={saving}
|
||||
@@ -486,276 +480,191 @@ export function RiskCapDocScreen() {
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
detail ? (
|
||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||
detail?.effective_at ? (
|
||||
<ConfigVersionToolbarMeta>
|
||||
<span>
|
||||
{t("riskCap.effectiveAt", {
|
||||
ns: "config",
|
||||
value: detail.effective_at ? formatDt(detail.effective_at) : "—",
|
||||
value: formatDt(detail.effective_at),
|
||||
})}
|
||||
</span>
|
||||
{!isDraft ? (
|
||||
<ConfigVersionToolbarMetaEmphasis>
|
||||
{t("riskCap.readOnlyHint", { ns: "config" })}
|
||||
</ConfigVersionToolbarMetaEmphasis>
|
||||
) : (
|
||||
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||
)}
|
||||
</ConfigVersionToolbarMeta>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
contentClassName="space-y-8"
|
||||
contentClassName="space-y-6"
|
||||
>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
key: "default",
|
||||
label: t("riskCap.summary.defaultCap", { ns: "config" }),
|
||||
value: defaultCapDisplay,
|
||||
hint: t("riskCap.summary.defaultHint", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
key: "global",
|
||||
label: t("riskCap.summary.globalCaps", { ns: "config" }),
|
||||
value: t("riskCap.groups.count", { ns: "config", count: globalRows.length }),
|
||||
hint: t("riskCap.summary.globalHint", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
key: "draw",
|
||||
label: t("riskCap.summary.drawCaps", { ns: "config" }),
|
||||
value: t("riskCap.groups.count", { ns: "config", count: drawRows.length }),
|
||||
hint: t("riskCap.summary.drawHint", { ns: "config" }),
|
||||
},
|
||||
].map((card) => (
|
||||
<div key={card.key} className="rounded-xl border border-border/60 bg-background p-4 shadow-sm">
|
||||
<p className="text-xs text-muted-foreground">{card.label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">{card.value}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{card.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConfigSection title={t("riskCap.defaultCap.title", { ns: "config" })}>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
id="default-cap"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-9 w-[220px] text-base font-semibold"
|
||||
disabled={saving}
|
||||
value={defaultCapStr}
|
||||
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
|
||||
onChange={(e) => setDefaultCapStr(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue className="h-9 w-[220px] text-base font-semibold">
|
||||
{defaultCapDisplay}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</div>
|
||||
{canEditDraft ? (
|
||||
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
|
||||
{t("riskCap.actions.update", { ns: "config" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title={t("riskCap.specialCaps.title", { ns: "config" })}
|
||||
description={t("riskCap.specialCaps.description", { ns: "config" })}
|
||||
actions={
|
||||
canEditDraft ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<AdminPageCard title={t("riskCap.defaultCap.title", { ns: "config" })}>
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
id="default-cap"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="font-semibold tabular-nums"
|
||||
disabled={saving}
|
||||
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
|
||||
>
|
||||
{t("riskCap.actions.addSpecialCap", { ns: "config" })}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
value={defaultCapInput}
|
||||
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
|
||||
onChange={(e) => setDefaultCapInput(e.target.value)}
|
||||
onBlur={() => setDraftRows(applyDefaultCapInput(defaultCapInput))}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue className="font-semibold tabular-nums">{defaultCapDisplay}</ConfigReadonlyValue>
|
||||
)}
|
||||
</div>
|
||||
</AdminPageCard>
|
||||
|
||||
{canEditDraft && !showNumberCaps && specialRows.length === 0 && drawBoundRows.length === 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={saving || loadingDetail}
|
||||
onClick={() => {
|
||||
setShowNumberCaps(true);
|
||||
setDraftRows((prev) => [...prev, newRow()]);
|
||||
}}
|
||||
>
|
||||
{t("riskCap.actions.addNumberCap", { ns: "config" })}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{showNumberCaps || specialRows.length > 0 || drawBoundRows.length > 0 ? (
|
||||
<AdminPageCard
|
||||
title={t("riskCap.specialCaps.title", { ns: "config" })}
|
||||
actions={
|
||||
canEditDraft ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
|
||||
>
|
||||
{t("riskCap.actions.addSpecialCap", { ns: "config" })}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{loadingDetail ? (
|
||||
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
|
||||
) : specialRows.length === 0 ? (
|
||||
<AdminLoadingState
|
||||
minHeight="6rem"
|
||||
className="py-4"
|
||||
label={t("riskCap.loadingDetails", { ns: "config" })}
|
||||
/>
|
||||
) : specialRows.length === 0 && drawBoundRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
key: "global",
|
||||
title: t("riskCap.groups.globalTitle", { ns: "config" }),
|
||||
description: t("riskCap.groups.globalDescription", { ns: "config" }),
|
||||
rows: globalRows,
|
||||
emptyText: t("riskCap.groups.globalEmpty", { ns: "config" }),
|
||||
},
|
||||
{
|
||||
key: "draw",
|
||||
title: t("riskCap.groups.drawTitle", { ns: "config" }),
|
||||
description: t("riskCap.groups.drawDescription", { ns: "config" }),
|
||||
rows: drawRows,
|
||||
emptyText: t("riskCap.groups.drawEmpty", { ns: "config" }),
|
||||
},
|
||||
].map((group) => (
|
||||
<div key={group.key} className="rounded-xl border border-border/60 bg-muted/10 p-3">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{group.description}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-background px-2 py-1 text-xs text-muted-foreground">
|
||||
{t("riskCap.groups.count", { ns: "config", count: group.rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{group.rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{group.emptyText}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">{t("riskCap.table.scope", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("riskCap.table.actions", { ns: "config" })}
|
||||
</TableHead>
|
||||
{specialRows.length > 0 ? (
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("riskCap.table.actions", { ns: "config" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{specialRows.map(({ row: r, index: idx }) => (
|
||||
<TableRow key={r.clientKey}>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
className="h-8 font-mono tabular-nums"
|
||||
maxLength={4}
|
||||
disabled={saving}
|
||||
value={r.normalized_number}
|
||||
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-8 tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatMinorToEditableMajor(r.cap_amount, amountCurrencyCode)}
|
||||
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
cap_amount: parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue>
|
||||
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canEditDraft ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={saving}
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: t("actions.delete", { ns: "adminUsers" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => removeRow(idx),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.rows.map(({ row: r, index: idx }) => (
|
||||
<TableRow key={r.clientKey}>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Select
|
||||
value={r.draw_id == null ? "__global__" : String(r.draw_id)}
|
||||
onValueChange={(value) =>
|
||||
updateRow(idx, { draw_id: value === "__global__" ? null : Number(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__global__">
|
||||
{t("riskCap.scope.global", { ns: "config" })}
|
||||
</SelectItem>
|
||||
{drawOptions.map((draw) => (
|
||||
<SelectItem key={draw.id} value={String(draw.id)}>
|
||||
{draw.draw_no}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<ConfigReadonlyValue>
|
||||
{r.draw_id == null
|
||||
? t("riskCap.scope.global", { ns: "config" })
|
||||
: drawOptions.find((draw) => draw.id === r.draw_id)?.draw_no ??
|
||||
t("riskCap.scope.drawId", { ns: "config", id: r.draw_id })}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
className="h-8 font-mono tabular-nums"
|
||||
maxLength={4}
|
||||
disabled={saving}
|
||||
value={r.normalized_number}
|
||||
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-8 tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatMinorToEditableMajor(r.cap_amount, amountCurrencyCode)}
|
||||
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
cap_amount:
|
||||
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue>
|
||||
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canEditDraft ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={saving}
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: t("actions.delete", { ns: "adminUsers" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => removeRow(idx),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("riskCap.readOnly", { ns: "config" })}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
|
||||
{drawBoundRows.length > 0 ? (
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("riskCap.table.draw", { ns: "config" })}</TableHead>
|
||||
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead>{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{drawBoundRows.map((row) => (
|
||||
<TableRow key={row.clientKey}>
|
||||
<TableCell>{drawLabel(row.draw_id ?? 0)}</TableCell>
|
||||
<TableCell className="font-mono">{row.normalized_number}</TableCell>
|
||||
<TableCell>{formatAdminMinorDecimal(row.cap_amount, amountCurrencyCode)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</ConfigSection>
|
||||
|
||||
<RiskCapRuntimePanel />
|
||||
|
||||
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("riskCap.syncDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("riskCap.syncDialog.description", { ns: "config", value: defaultCapStr || "(empty)" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
|
||||
{t("actions.cancel", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
<Button type="button" onClick={applyDefaultCap}>
|
||||
{t("riskCap.syncDialog.confirm", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminPageCard>
|
||||
) : null}
|
||||
|
||||
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
@@ -782,4 +691,4 @@ export function RiskCapDocScreen() {
|
||||
<ConfirmDialog />
|
||||
</ConfigDocPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
Network,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -22,20 +29,20 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import {
|
||||
DashboardKpiCard,
|
||||
DashboardQuickLinksCard,
|
||||
DashboardReportsTeaser,
|
||||
DashboardScopeMetric,
|
||||
DashboardSignedStatRow,
|
||||
DashboardStatRow,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
|
||||
import {
|
||||
formatDashboardCreditMajor,
|
||||
formatDashboardMoneyMinor,
|
||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AdminDashboardAgentOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function buildTodayBetHint(
|
||||
@@ -52,6 +59,13 @@ function buildTodayBetHint(
|
||||
return `${dateHint} · ${t("agent.noBetToday")}`;
|
||||
}
|
||||
|
||||
function agentLineHref(agentNodeId: number | null | undefined): string {
|
||||
if (agentNodeId != null && agentNodeId > 0) {
|
||||
return `/admin/agents?agent_node_id=${agentNodeId}`;
|
||||
}
|
||||
return "/admin/agents";
|
||||
}
|
||||
|
||||
export function AgentDashboardConsole(): ReactElement {
|
||||
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
|
||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||
@@ -69,25 +83,33 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
}, [i18n.language, i18n.resolvedLanguage, t]);
|
||||
|
||||
useAdminCurrencyCatalog();
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
|
||||
|
||||
const analyticsScope = useMemo(
|
||||
() => ({
|
||||
siteCode: agent?.site_code ?? "",
|
||||
agentNodeId: agent?.id,
|
||||
}),
|
||||
[agent?.id, agent?.site_code],
|
||||
);
|
||||
|
||||
const canReports = adminHasAnyPermission(permissions, [PRD_REPORT_VIEW]);
|
||||
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
|
||||
const lineHref = agentLineHref(agent?.id);
|
||||
|
||||
const quickLinks = useMemo(
|
||||
() => [
|
||||
{ href: "/admin/tickets", label: t("agent.quickLinks.tickets"), icon: ClipboardList },
|
||||
{ href: lineHref, label: t("agent.quickLinks.players"), icon: Users },
|
||||
{ href: lineHref, label: t("agent.quickLinks.agents"), icon: Network },
|
||||
{
|
||||
href: settlementCenterScopeHref(undefined, "awaiting_payment"),
|
||||
label: t("agent.quickLinks.bills"),
|
||||
icon: Wallet,
|
||||
},
|
||||
...(canReports
|
||||
? [{ href: "/admin/reports", label: t("agent.quickLinks.reports"), icon: BarChart3 }]
|
||||
: []),
|
||||
],
|
||||
[canReports, lineHref, t],
|
||||
);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
@@ -99,14 +121,8 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
|
||||
try {
|
||||
const d = await getAdminDashboard();
|
||||
setHall(d.hall);
|
||||
setOverview(d.agent_overview);
|
||||
setApiWarnings(d.warnings ?? []);
|
||||
if (d.resolved_draw != null) {
|
||||
setDrawId(d.resolved_draw.id);
|
||||
} else {
|
||||
setDrawId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||
@@ -175,6 +191,7 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
|
||||
href="/admin/tickets"
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("agent.todayShareProfit")}
|
||||
@@ -184,12 +201,14 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
|
||||
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
|
||||
})}`}
|
||||
href={canReports ? "/admin/reports" : undefined}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("agent.activePlayersToday")}
|
||||
value={overview.active_player_count_today}
|
||||
icon={<Users className="size-4" />}
|
||||
hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })}
|
||||
href={lineHref}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("agent.pendingBills")}
|
||||
@@ -199,6 +218,7 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
|
||||
})}
|
||||
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
|
||||
href={settlementCenterScopeHref(undefined, "awaiting_payment")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -289,6 +309,8 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
|
||||
</section>
|
||||
) : (
|
||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||
@@ -296,22 +318,14 @@ export function AgentDashboardConsole(): ReactElement {
|
||||
</AdminNoResourceState>
|
||||
)}
|
||||
|
||||
<DashboardCurrentDrawCard
|
||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
||||
hall={hall}
|
||||
drawId={drawId}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{canAnalytics ? (
|
||||
<DashboardAnalyticsPanel
|
||||
enabled={canAnalytics}
|
||||
playOptions={playOptions}
|
||||
scope={analyticsScope}
|
||||
<DashboardReportsTeaser
|
||||
title={t("analytics.title")}
|
||||
description={t("agent.shareProfitScopeHint")}
|
||||
/>
|
||||
) : !loading ? (
|
||||
<p className="text-xs text-muted-foreground">{t("warnings.analyticsUnavailable")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminDashboardDrawPanel,
|
||||
AdminDashboardLifetimeFinance,
|
||||
AdminDashboardPlatformRisk,
|
||||
AdminDashboardResultBatchQueue,
|
||||
@@ -172,7 +171,6 @@ export function DashboardConsole(): ReactElement {
|
||||
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
|
||||
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
|
||||
const [resultBatchQueue, setResultBatchQueue] = useState<AdminDashboardResultBatchQueue | null>(
|
||||
@@ -201,7 +199,6 @@ export function DashboardConsole(): ReactElement {
|
||||
setError(null);
|
||||
setFinance(null);
|
||||
setCapabilities(null);
|
||||
setDrawPanel(null);
|
||||
setResultBatchQueue(null);
|
||||
setLifetimeFinance(null);
|
||||
setTodayFinance(null);
|
||||
@@ -230,9 +227,6 @@ export function DashboardConsole(): ReactElement {
|
||||
setTodayFinance(d.today_finance);
|
||||
setApiWarnings(d.warnings ?? []);
|
||||
setPlatformRisk(d.platform_risk);
|
||||
if (d.draw != null) {
|
||||
setDrawPanel(d.draw);
|
||||
}
|
||||
if (d.risk != null) {
|
||||
setRiskLocked(d.risk.locked_amount);
|
||||
setRiskCap(d.risk.cap_amount);
|
||||
@@ -544,6 +538,23 @@ export function DashboardConsole(): ReactElement {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{showAnalytics ? (
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!showAnalytics ? (
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
@@ -565,22 +576,7 @@ export function DashboardConsole(): ReactElement {
|
||||
{showAnalytics ? (
|
||||
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
||||
<DashboardPlayRankingCard analytics={analytics} />
|
||||
<DashboardAgentRankingCard analytics={analytics} />
|
||||
|
||||
<Card className="admin-list-card min-w-0 py-0">
|
||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-4">
|
||||
{loading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DashboardAgentRankingCard analytics={analytics} />
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,13 @@ import Link from "next/link";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRightIcon,
|
||||
CheckCircle2,
|
||||
ChevronRightIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -19,7 +25,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -268,6 +274,7 @@ export function DashboardKpiCard({
|
||||
currencyCode,
|
||||
sparklineValues,
|
||||
deltaLabel,
|
||||
href,
|
||||
}: {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
@@ -281,6 +288,8 @@ export function DashboardKpiCard({
|
||||
currencyCode?: string | null;
|
||||
sparklineValues?: number[];
|
||||
deltaLabel?: ReactNode;
|
||||
/** 整张卡片可点击跳转 */
|
||||
href?: string;
|
||||
}): ReactElement {
|
||||
const resolvedValue =
|
||||
typeof signedAmountMinor === "number" && currencyCode !== undefined
|
||||
@@ -295,17 +304,30 @@ export function DashboardKpiCard({
|
||||
? String(resolvedValue)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
|
||||
const card = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4",
|
||||
href && "transition-colors group-hover/kpi:border-primary/30 group-hover/kpi:shadow-sm",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4",
|
||||
kpiAccentClass(accent),
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-lg [&_svg]:size-4",
|
||||
kpiAccentClass(accent),
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
{href ? (
|
||||
<ChevronRightIcon
|
||||
className="size-4 text-muted-foreground/50 transition group-hover/kpi:text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
@@ -336,10 +358,126 @@ export function DashboardKpiCard({
|
||||
</div>
|
||||
) : null}
|
||||
{hint ? (
|
||||
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 text-[11px] leading-snug",
|
||||
href ? "text-primary/80 group-hover/kpi:underline" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!href) {
|
||||
return card;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group/kpi block h-full min-h-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{card}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export type DashboardQuickLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
/** 仪表盘快捷入口:按钮或图标卡片两种布局 */
|
||||
export function DashboardQuickLinksCard({
|
||||
title,
|
||||
links,
|
||||
variant = "buttons",
|
||||
}: {
|
||||
title: string;
|
||||
links: readonly DashboardQuickLink[];
|
||||
variant?: "buttons" | "tiles";
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (links.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(
|
||||
variant === "buttons" ? "flex flex-wrap gap-2" : "grid gap-3 sm:grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{links.map((link) => {
|
||||
if (variant === "buttons") {
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="flex flex-col gap-2 rounded-xl border bg-muted/20 px-4 py-4 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
{Icon ? <Icon className="size-5 text-primary" aria-hidden /> : null}
|
||||
<span className="text-sm font-medium">{link.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{t("cs.openModule")}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 轻量报表入口:替代站点/代理仪表盘内嵌完整 analytics 面板 */
|
||||
export function DashboardReportsTeaser({
|
||||
title,
|
||||
description,
|
||||
href = "/admin/reports",
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
href?: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("viewReports")}
|
||||
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniSparkline({
|
||||
|
||||
@@ -14,10 +14,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import {
|
||||
DashboardKpiCard,
|
||||
DashboardScopeMetric,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type {
|
||||
@@ -136,18 +133,21 @@ export function SiteCsDashboardConsole(): ReactElement {
|
||||
value={overview.player_count}
|
||||
icon={<Users className="size-4" />}
|
||||
hint={t("cs.playerCountHint")}
|
||||
href="/admin/players"
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("cs.ticketsToday")}
|
||||
value={overview.ticket_order_count_today}
|
||||
icon={<ClipboardList className="size-4" />}
|
||||
hint={activityHint}
|
||||
href="/admin/tickets"
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("cs.activePlayersToday")}
|
||||
value={overview.active_player_count_today}
|
||||
icon={<Search className="size-4" />}
|
||||
hint={t("cs.activePlayersHint")}
|
||||
href="/admin/tickets"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -172,19 +172,6 @@ export function SiteCsDashboardConsole(): ReactElement {
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("cs.scopeTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||
<DashboardScopeMetric label={t("cs.playerCount")} value={String(overview.player_count)} />
|
||||
<DashboardScopeMetric
|
||||
label={t("cs.ticketsToday")}
|
||||
value={String(overview.ticket_order_count_today)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
) : (
|
||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
Network,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||
import { normalizeAdminLanguage } from "@/i18n";
|
||||
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,19 +27,19 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
||||
import {
|
||||
DashboardKpiCard,
|
||||
DashboardQuickLinksCard,
|
||||
DashboardReportsTeaser,
|
||||
DashboardScopeMetric,
|
||||
DashboardSignedStatRow,
|
||||
DashboardStatRow,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
|
||||
import {
|
||||
formatDashboardMoneyMinor,
|
||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AdminDashboardSiteOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function buildTodayBetHint(
|
||||
@@ -65,26 +72,32 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
return formatAdminCalendarToday(locale, weekday);
|
||||
}, [i18n.language, i18n.resolvedLanguage, t]);
|
||||
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
|
||||
|
||||
const analyticsScope = useMemo(
|
||||
() => ({
|
||||
siteCode: site?.code ?? overview?.site_code ?? "",
|
||||
agentNodeId: undefined,
|
||||
}),
|
||||
[overview?.site_code, site?.code],
|
||||
);
|
||||
|
||||
const canReports = adminHasAnyPermission(permissions, [PRD_REPORT_VIEW]);
|
||||
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
|
||||
|
||||
const quickLinks = useMemo(
|
||||
() => [
|
||||
{ href: "/admin/tickets", label: t("site.quickLinks.tickets"), icon: ClipboardList },
|
||||
{ href: "/admin/players", label: t("site.quickLinks.players"), icon: Users },
|
||||
{ href: "/admin/agents", label: t("site.quickLinks.agents"), icon: Network },
|
||||
{
|
||||
href: settlementCenterScopeHref(site?.id, "awaiting_payment"),
|
||||
label: t("site.quickLinks.bills"),
|
||||
icon: Wallet,
|
||||
},
|
||||
...(canReports
|
||||
? [{ href: "/admin/reports", label: t("site.quickLinks.reports"), icon: BarChart3 }]
|
||||
: []),
|
||||
],
|
||||
[canReports, site?.id, t],
|
||||
);
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
@@ -95,14 +108,8 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
|
||||
try {
|
||||
const d = await getAdminDashboard();
|
||||
setHall(d.hall);
|
||||
setOverview(d.site_overview);
|
||||
setApiWarnings(d.warnings ?? []);
|
||||
if (d.resolved_draw != null) {
|
||||
setDrawId(d.resolved_draw.id);
|
||||
} else {
|
||||
setDrawId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||
@@ -171,6 +178,7 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
|
||||
href="/admin/tickets"
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("site.todayProfit")}
|
||||
@@ -180,12 +188,14 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
hint={t("todayPayoutHint", {
|
||||
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
|
||||
})}
|
||||
href={canReports ? "/admin/reports" : undefined}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("site.activePlayersToday")}
|
||||
value={overview.active_player_count_today}
|
||||
icon={<Users className="size-4" />}
|
||||
hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })}
|
||||
href="/admin/players"
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("site.pendingBills")}
|
||||
@@ -195,6 +205,7 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
|
||||
})}
|
||||
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
|
||||
href={settlementCenterScopeHref(site?.id, "awaiting_payment")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -245,6 +256,8 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
|
||||
</section>
|
||||
) : (
|
||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||
@@ -252,18 +265,10 @@ export function SiteDashboardConsole(): ReactElement {
|
||||
</AdminNoResourceState>
|
||||
)}
|
||||
|
||||
<DashboardCurrentDrawCard
|
||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
||||
hall={hall}
|
||||
drawId={drawId}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{canAnalytics ? (
|
||||
<DashboardAnalyticsPanel
|
||||
enabled={canAnalytics}
|
||||
playOptions={playOptions}
|
||||
scope={analyticsScope}
|
||||
<DashboardReportsTeaser
|
||||
title={t("analytics.title")}
|
||||
description={t("site.profitScopeHint")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -13,13 +13,12 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
||||
import {
|
||||
AbnormalTransferPanelFooter,
|
||||
DashboardKpiCard,
|
||||
DashboardScopeMetric,
|
||||
DashboardStatRow,
|
||||
DashboardQuickLinksCard,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
@@ -27,7 +26,6 @@ import type {
|
||||
AdminDashboardSiteFinanceOverview,
|
||||
AdminDashboardWarning,
|
||||
} from "@/types/api/admin-dashboard";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function SiteFinanceDashboardConsole(): ReactElement {
|
||||
@@ -40,8 +38,6 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [overview, setOverview] = useState<AdminDashboardSiteFinanceOverview | null>(null);
|
||||
const [walletPermission, setWalletPermission] = useState(false);
|
||||
|
||||
@@ -55,15 +51,9 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
||||
|
||||
try {
|
||||
const d = await getAdminDashboard();
|
||||
setHall(d.hall);
|
||||
setOverview(d.site_finance_overview);
|
||||
setApiWarnings(d.warnings ?? []);
|
||||
setWalletPermission(d.capabilities?.wallet_transfer_view ?? false);
|
||||
if (d.resolved_draw != null) {
|
||||
setDrawId(d.resolved_draw.id);
|
||||
} else {
|
||||
setDrawId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||
@@ -80,15 +70,19 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
||||
|
||||
const displayCurrency = overview?.currency_code ?? "NPR";
|
||||
const abnormalCount = overview?.abnormal_transfer_count ?? null;
|
||||
const abnormalHref = "/admin/wallet/transfer-orders?abnormal=1";
|
||||
|
||||
const quickLinks = useMemo(
|
||||
() => [
|
||||
{ href: "/admin/reconcile", label: t("finance.quickLinks.reconcile") },
|
||||
{ href: "/admin/wallet/transfer-orders", label: t("finance.quickLinks.transfers") },
|
||||
{ href: "/admin/settlement-center", label: t("finance.quickLinks.bills") },
|
||||
{
|
||||
href: settlementCenterScopeHref(site?.id, "awaiting_payment"),
|
||||
label: t("finance.quickLinks.bills"),
|
||||
},
|
||||
{ href: "/admin/reports", label: t("finance.quickLinks.reports") },
|
||||
],
|
||||
[t],
|
||||
[site?.id, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -144,6 +138,7 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
||||
icon={<AlertTriangle className="size-4" />}
|
||||
hint={t("abnormalTransferScope")}
|
||||
accent={(abnormalCount ?? 0) > 0 ? "destructive" : "muted"}
|
||||
href={abnormalHref}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("finance.pendingConfirmBills")}
|
||||
@@ -151,6 +146,7 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
||||
icon={<ClipboardList className="size-4" />}
|
||||
hint={t("finance.pendingConfirmHint")}
|
||||
accent={overview.pending_confirm_bill_count > 0 ? "primary" : "muted"}
|
||||
href={settlementCenterScopeHref(site?.id, "pending_confirm")}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("finance.payableBills")}
|
||||
@@ -160,85 +156,43 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
||||
amount: formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency),
|
||||
})}
|
||||
accent={overview.payable_bill_count > 0 ? "destructive" : "muted"}
|
||||
href={settlementCenterScopeHref(site?.id, "awaiting_payment")}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
label={t("finance.walletPlayers")}
|
||||
value={overview.wallet_player_count}
|
||||
icon={<Users className="size-4" />}
|
||||
hint={t("finance.creditPlayersHint", { count: overview.credit_player_count })}
|
||||
href="/admin/players"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("finance.settlementTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<DashboardStatRow
|
||||
label={t("finance.pendingConfirmBills")}
|
||||
value={String(overview.pending_confirm_bill_count)}
|
||||
/>
|
||||
<DashboardStatRow
|
||||
label={t("finance.payableBills")}
|
||||
value={String(overview.payable_bill_count)}
|
||||
/>
|
||||
<DashboardStatRow
|
||||
label={t("finance.payableUnpaidLabel")}
|
||||
value={formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("finance.reconcileTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<AbnormalTransferPanelFooter
|
||||
total={abnormalCount}
|
||||
walletPermission={walletPermission}
|
||||
/>
|
||||
<Link
|
||||
href="/admin/wallet/transfer-orders?abnormal=1"
|
||||
className={buttonVariants({ variant: "outline", size: "sm", className: "w-full" })}
|
||||
>
|
||||
<Wallet className="size-3.5" />
|
||||
{t("viewTransferOrders")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
|
||||
<CardTitle className="text-sm font-semibold">{t("finance.reconcileTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{quickLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<CardContent className="space-y-3">
|
||||
<AbnormalTransferPanelFooter
|
||||
total={abnormalCount}
|
||||
walletPermission={walletPermission}
|
||||
/>
|
||||
<Link
|
||||
href={abnormalHref}
|
||||
className={buttonVariants({ variant: "outline", size: "sm", className: "w-full" })}
|
||||
>
|
||||
<Wallet className="size-3.5" />
|
||||
{t("viewTransferOrders")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
|
||||
</section>
|
||||
) : (
|
||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||
{t("finance.overviewEmpty")}
|
||||
</AdminNoResourceState>
|
||||
)}
|
||||
|
||||
<DashboardCurrentDrawCard
|
||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
||||
hall={hall}
|
||||
drawId={drawId}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,9 @@ import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminDraw,
|
||||
getAdminDrawFinanceSummary,
|
||||
postAdminCancelDraw,
|
||||
postAdminManualCloseDraw,
|
||||
@@ -17,23 +15,20 @@ import {
|
||||
} from "@/api/admin-draws";
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||
import { canManageDrawResults } from "@/lib/draw-access";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||
|
||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
import {
|
||||
PRD_DRAW_REOPEN_MANAGE,
|
||||
PRD_PAYOUT_MANAGE,
|
||||
@@ -50,28 +45,20 @@ function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
|
||||
return (
|
||||
<ol className="grid gap-3 sm:grid-cols-3">
|
||||
{steps.map((step, index) => (
|
||||
<li
|
||||
key={step.key}
|
||||
className={cn(
|
||||
"relative rounded-lg border bg-muted/20 px-3 py-2.5",
|
||||
index < steps.length - 1 &&
|
||||
"sm:after:absolute sm:after:top-1/2 sm:after:left-full sm:after:h-px sm:after:w-3 sm:after:-translate-y-1/2 sm:after:bg-border",
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">{step.label}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
|
||||
<ol className="grid gap-2 sm:grid-cols-3">
|
||||
{steps.map((step) => (
|
||||
<li key={step.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">{step.label}</p>
|
||||
<p className="mt-0.5 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const idNum = Number(drawId);
|
||||
const { draw: data, loading, error, refresh, drawId: idNum } = useDrawDetail();
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
||||
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
||||
@@ -79,41 +66,29 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
]);
|
||||
const [data, setData] = useState<AdminDrawShowData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
setError(tRef.current("invalidDrawId"));
|
||||
setLoading(false);
|
||||
const loadFinance = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum) || !data) {
|
||||
setFinanceSummary(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const draw = await getAdminDraw(idNum);
|
||||
setData(draw);
|
||||
if (draw.capabilities?.can_view_draw_finance !== false) {
|
||||
try {
|
||||
setFinanceSummary(await getAdminDrawFinanceSummary(idNum));
|
||||
} catch {
|
||||
setFinanceSummary(null);
|
||||
}
|
||||
} else {
|
||||
setFinanceSummary(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setData(null);
|
||||
if (data.capabilities?.can_view_draw_finance === false) {
|
||||
setFinanceSummary(null);
|
||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}, [idNum, tRef]);
|
||||
try {
|
||||
setFinanceSummary(await getAdminDrawFinanceSummary(idNum));
|
||||
} catch {
|
||||
setFinanceSummary(null);
|
||||
}
|
||||
}, [data, idNum]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void loadFinance();
|
||||
}, [loadFinance]);
|
||||
|
||||
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
||||
if (!Number.isFinite(idNum)) return;
|
||||
@@ -121,7 +96,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
try {
|
||||
await action();
|
||||
toast.success(t("actionSuccess", { name }));
|
||||
await load();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name }));
|
||||
} finally {
|
||||
@@ -129,10 +104,6 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
const scheduleSteps = useMemo((): ScheduleStep[] => {
|
||||
if (!data) return [];
|
||||
const steps: ScheduleStep[] = [
|
||||
@@ -241,182 +212,116 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
const pendingReview = batch.pending_review ?? 0;
|
||||
const totalBatches = batch.total ?? batch.published;
|
||||
const financeCurrency = financeSummary?.currency_code ?? "NPR";
|
||||
const hasResultActivity =
|
||||
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
|
||||
const showActions =
|
||||
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
|
||||
const hasResultActivity = totalBatches > 0 || pendingReview > 0 || batch.published > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3">
|
||||
<Link
|
||||
href="/admin/draws"
|
||||
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
<CardTitle className="font-mono text-xl tracking-tight">{data.draw_no}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("detailSubtitle", {
|
||||
date: data.business_date,
|
||||
seq: data.sequence_no,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DrawStatusBadge
|
||||
status={data.status}
|
||||
label={drawStatusLabel(data.status, t)}
|
||||
/>
|
||||
{hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">{t("hallPreviewStatusLabel")}</span>
|
||||
<DrawStatusBadge
|
||||
status={data.hall_preview_status}
|
||||
label={drawStatusLabel(data.hall_preview_status, t)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 border-t pt-6">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("overviewTitle")}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("overviewBetTotal")}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("overviewPayoutTotal")}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 font-mono text-sm tabular-nums",
|
||||
signedMoneyClass(
|
||||
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
|
||||
true,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
|
||||
<ScheduleTimeline steps={scheduleSteps} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("resultBatchesTitle")}</h3>
|
||||
{hasResultActivity ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{canManageDraw ? (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1">
|
||||
{t("batchSummaryTotal", { count: totalBatches })}
|
||||
</span>
|
||||
) : null}
|
||||
{canManageDraw ? (
|
||||
pendingReview > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="rounded-md bg-amber-500/15 px-2.5 py-1 font-medium text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t("batchSummaryPending", { count: pendingReview })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
|
||||
{t("batchSummaryPending", { count: 0 })}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
{batch.published > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
className="rounded-md bg-emerald-500/15 px-2.5 py-1 font-medium text-emerald-800 dark:text-emerald-200"
|
||||
>
|
||||
{t("batchSummaryPublished", { count: batch.published })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
|
||||
{t("batchSummaryPublished", { count: 0 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noResultBatchesYet")}
|
||||
<span className="ml-1">{t("reviewQueueHint")}</span>
|
||||
{canManageDraw ? (
|
||||
<>
|
||||
{" "}
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("goToReviewTab")}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-border/60 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{t("overviewBetTotal")}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</section>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{t("overviewPayoutTotal")}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{t("overviewProfitLoss")}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 font-mono text-sm tabular-nums",
|
||||
signedMoneyClass(
|
||||
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
|
||||
true,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{formatAdminMinorUnits(
|
||||
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
|
||||
financeCurrency,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions ? (
|
||||
<section className="space-y-3 border-t pt-6">
|
||||
<h3 className="text-sm font-medium">{t("drawActions")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={action.variant}
|
||||
disabled={acting !== null}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: action.confirmTitle,
|
||||
description: action.confirmDescription,
|
||||
confirmVariant: action.confirmVariant,
|
||||
onConfirm: () => runAction(action.label, action.onConfirm),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === action.label ? t("processing") : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<ScheduleTimeline steps={scheduleSteps} />
|
||||
|
||||
{hasResultActivity ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{canManageDraw && totalBatches > 0 ? (
|
||||
<span className="rounded-md bg-muted px-2 py-1 tabular-nums">
|
||||
{t("batchSummaryTotal", { count: totalBatches })}
|
||||
</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{canManageDraw && pendingReview > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="rounded-md bg-amber-500/15 px-2 py-1 font-medium text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t("batchSummaryPending", { count: pendingReview })}
|
||||
</Link>
|
||||
) : null}
|
||||
{batch.published > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
className="rounded-md bg-emerald-500/15 px-2 py-1 font-medium text-emerald-800 dark:text-emerald-200"
|
||||
>
|
||||
{t("batchSummaryPublished", { count: batch.published })}
|
||||
</Link>
|
||||
) : null}
|
||||
{data.capabilities?.can_view_draw_finance !== false ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/finance`}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("viewFinance")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : canManageDraw ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("goToReviewTab")}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{availableActions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 border-t border-border/60 pt-4">
|
||||
{availableActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={action.variant}
|
||||
disabled={acting !== null}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: action.confirmTitle,
|
||||
description: action.confirmDescription,
|
||||
confirmVariant: action.confirmVariant,
|
||||
onConfirm: () => runAction(action.label, action.onConfirm),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === action.label ? t("processing") : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
79
src/modules/draws/draw-detail-context.tsx
Normal file
79
src/modules/draws/draw-detail-context.tsx
Normal 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;
|
||||
}
|
||||
52
src/modules/draws/draw-detail-header.tsx
Normal file
52
src/modules/draws/draw-detail-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/modules/draws/draw-detail-shell.tsx
Normal file
23
src/modules/draws/draw-detail-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
@@ -37,7 +37,7 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
|
||||
import { drawStatusLabel, settlementBatchStatusLabel } from "./draw-display";
|
||||
import { settlementBatchStatusLabel } from "./draw-display";
|
||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
||||
|
||||
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
@@ -109,44 +109,25 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("financeOverview")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("drawNo")}</span>
|
||||
<p className="font-mono font-semibold">{data.draw_no}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("status")}</span>
|
||||
<p className="mt-1">
|
||||
<DrawStatusBadge status={data.draw_status} label={drawStatusLabel(data.draw_status, t)} />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("orderAndItemCount")}</span>
|
||||
<p className="tabular-nums">
|
||||
{data.order_count} / {data.ticket_item_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("actualBet")}</span>
|
||||
<p className="tabular-nums font-medium">{formatMoney(data.total_bet_minor)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("currentPayout")}</span>
|
||||
<p className="tabular-nums font-medium">{formatMoney(data.total_payout_minor)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("grossProfit")}</span>
|
||||
<p className={cn("tabular-nums font-semibold", signedMoneyClass(data.approx_house_gross_minor, true))}>
|
||||
{formatMoney(data.approx_house_gross_minor)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-border/60 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">{t("orderAndItemCount")}</p>
|
||||
<p className="mt-0.5 tabular-nums font-medium">
|
||||
{data.order_count} / {data.ticket_item_count}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">{t("actualBet")}</p>
|
||||
<p className="mt-0.5 tabular-nums font-medium">{formatMoney(data.total_bet_minor)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">{t("grossProfit")}</p>
|
||||
<p className={cn("mt-0.5 tabular-nums font-semibold", signedMoneyClass(data.approx_house_gross_minor, true))}>
|
||||
{formatMoney(data.approx_house_gross_minor)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||
@@ -174,11 +155,11 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("relatedSettlementBatches")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<div className="border-b border-border/60 px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold">{t("relatedSettlementBatches")}</h2>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{data.settlement_batches.length === 0 ? (
|
||||
<AdminNoResourceState className="py-4" />
|
||||
) : (
|
||||
@@ -232,8 +213,8 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,57 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteAdminPendingResultBatch,
|
||||
getAdminDrawResultBatches,
|
||||
postAdminPublishResultBatch,
|
||||
} from "@/api/admin-draws";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getAdminDrawResultBatches } from "@/api/admin-draws";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
|
||||
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||
|
||||
import { drawBatchStatusLabel, drawPrizeTypeLabel, drawStatusLabel } from "./draw-display";
|
||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
|
||||
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
|
||||
/** 深链兼容:/publish/[batchId] 直接打开发布弹窗 */
|
||||
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const router = useRouter();
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
]);
|
||||
const { refresh: refreshDraw } = useDrawDetail();
|
||||
const idNum = Number(drawId);
|
||||
const batchNum = Number(batchId);
|
||||
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [discarding, setDiscarding] = useState(false);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
@@ -69,53 +42,17 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum]);
|
||||
}, [idNum, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
}, [load]);
|
||||
|
||||
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
|
||||
const batch = useMemo(() => {
|
||||
if (!Number.isFinite(batchNum)) return undefined;
|
||||
return data?.batches.find((b) => b.id === batchNum);
|
||||
}, [batchNum, data]);
|
||||
|
||||
async function discardBatch(): Promise<void> {
|
||||
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
|
||||
setDiscarding(true);
|
||||
try {
|
||||
await deleteAdminPendingResultBatch(idNum, batchNum);
|
||||
toast.success(t("discardPendingBatchSuccess"));
|
||||
router.replace(`/admin/draws/${drawId}/review`);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
|
||||
);
|
||||
} finally {
|
||||
setDiscarding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(): Promise<void> {
|
||||
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
|
||||
setPublishing(true);
|
||||
try {
|
||||
const res = await postAdminPublishResultBatch(idNum, batchNum);
|
||||
toast.success(
|
||||
t("publishSuccess", {
|
||||
drawNo: res.draw_no,
|
||||
status: drawStatusLabel(res.status, t),
|
||||
}),
|
||||
);
|
||||
await load();
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("publishFailed");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
@@ -123,131 +60,29 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
if (!batch) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("batchNotFound")}</AlertTitle>
|
||||
<AlertDescription>{t("batchNotFoundDesc")}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return <AdminNoResourceState message={t("batchNotFound")} />;
|
||||
}
|
||||
|
||||
const canPublish =
|
||||
canManageDraw && batch.status === "pending_review";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Link href={`/admin/draws/${drawId}/review`} className={buttonVariants({ variant: "ghost", size: "sm" })}>
|
||||
← {t("backToReviewQueue")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("publishTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!canManageDraw ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("noPublishPermission")}</AlertTitle>
|
||||
<AlertDescription>{t("noPublishPermission")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!canPublish && canManageDraw ? (
|
||||
<Alert>
|
||||
<AlertTitle>{t("cannotPublish")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("cannotPublishDesc", { status: drawBatchStatusLabel(batch.status, t) })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{canPublish ? (
|
||||
<>
|
||||
<Alert>
|
||||
<AlertTitle>{t("checkBeforePublish")}</AlertTitle>
|
||||
<AlertDescription>{t("checkBeforePublishDesc")}</AlertDescription>
|
||||
</Alert>
|
||||
<p className="text-sm text-muted-foreground">{t("publishReadOnlyHint")}</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("prize")}</TableHead>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead className="font-mono">4D</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{batch.items.map((it) => (
|
||||
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
|
||||
<TableCell className="text-xs">
|
||||
{drawPrizeTypeLabel(it.prize_type, it.prize_index, t)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell>
|
||||
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{t("sourceTypeFull", {
|
||||
source: batch.source_type === "manual" ? t("manualEntry") : t("rngAutoGenerate"),
|
||||
count: batch.items.length,
|
||||
hash: batch.rng_seed_hash ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
|
||||
>
|
||||
{t("publishedView")}
|
||||
</Link>
|
||||
{canPublish ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={publishing || discarding}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.discardPendingBatchTitle"),
|
||||
description: t("confirm.discardPendingBatchDescription"),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: () => discardBatch(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{discarding ? t("discardingPendingBatch") : t("discardPendingBatch")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canPublish || publishing || discarding}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.publishTitle"),
|
||||
description: t("confirm.publishDescription"),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: () => publish(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{publishing ? t("submitting") : t("confirmPublish")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
<DrawPublishDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
router.replace(`/admin/draws/${drawId}/review`);
|
||||
}
|
||||
}}
|
||||
drawId={idNum}
|
||||
batch={batch}
|
||||
onPublished={() => {
|
||||
void refreshDraw();
|
||||
router.replace(`/admin/draws/${drawId}/results`);
|
||||
}}
|
||||
onDiscarded={() => {
|
||||
void refreshDraw();
|
||||
router.replace(`/admin/draws/${drawId}/review`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/modules/draws/draw-publish-dialog.tsx
Normal file
61
src/modules/draws/draw-publish-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/modules/draws/draw-publish-panel.tsx
Normal file
147
src/modules/draws/draw-publish-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -18,19 +18,14 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { canManageDrawResults } from "@/lib/draw-access";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||
|
||||
import { drawPrizeTypeLabel } from "./draw-display";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
|
||||
export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
||||
const idNum = Number(drawId);
|
||||
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -72,55 +67,28 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
const published = data.batches.filter((b) => b.status === "published");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t("resultsTitle")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("drawNo")} {data.draw_no} · <DrawStatusBadge status={data.draw_status} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{published.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<AdminNoResourceState />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AdminNoResourceState message={t("noPublishedBatch")} />
|
||||
) : (
|
||||
published.map((batch) => (
|
||||
<BatchTable key={batch.id} batch={batch} showOperationalMeta={canManageDraw} />
|
||||
<BatchTable key={batch.id} batch={batch} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchTable({
|
||||
batch,
|
||||
showOperationalMeta,
|
||||
}: {
|
||||
batch: AdminDrawBatchRow;
|
||||
showOperationalMeta: boolean;
|
||||
}) {
|
||||
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
|
||||
const { t } = useTranslation("draws");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("version", { version: batch.result_version })}</CardTitle>
|
||||
{showOperationalMeta ? (
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{t("sourceType", {
|
||||
source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
|
||||
})}{" "}
|
||||
· {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
|
||||
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
|
||||
</p>
|
||||
)}
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm">{t("version", { version: batch.result_version })}</CardTitle>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{formatDt(batch.confirmed_at)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto pt-0">
|
||||
<Table>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Dices, Rocket, Trash2 } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Dices, Rocket, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
} from "@/api/admin-draws";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,13 +27,14 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||
import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
|
||||
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||
|
||||
import { drawStatusLabel } from "./draw-display";
|
||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
|
||||
const RESULT_SLOTS = [
|
||||
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" },
|
||||
@@ -58,23 +58,22 @@ function randomDrawNumber4d(): string {
|
||||
return String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
|
||||
}
|
||||
|
||||
export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const tRef = useTranslationRef(["draws", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
]);
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||
const { refresh: refreshDraw } = useDrawDetail();
|
||||
const idNum = Number(drawId);
|
||||
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingManual, setSavingManual] = useState(false);
|
||||
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null);
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [publishBatch, setPublishBatch] = useState<AdminDrawBatchRow | null>(null);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const [manualNumbers, setManualNumbers] = useState<string[]>(
|
||||
() => RESULT_SLOTS.map(() => ""),
|
||||
);
|
||||
const [manualNumbers, setManualNumbers] = useState<string[]>(() => RESULT_SLOTS.map(() => ""));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
@@ -92,15 +91,13 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum]);
|
||||
}, [idNum, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
}, [load]);
|
||||
|
||||
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
|
||||
data,
|
||||
]);
|
||||
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [data]);
|
||||
|
||||
function fillRandomManualNumbers(): void {
|
||||
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d()));
|
||||
@@ -113,10 +110,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
await deleteAdminPendingResultBatch(idNum, batchId);
|
||||
toast.success(t("discardPendingBatchSuccess"));
|
||||
await load();
|
||||
await refreshDraw();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
|
||||
);
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"));
|
||||
} finally {
|
||||
setDiscardingBatchId(null);
|
||||
}
|
||||
@@ -141,7 +137,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
});
|
||||
toast.success(t("draftSaved", { version: res.batch.result_version }));
|
||||
setManualNumbers(RESULT_SLOTS.map(() => ""));
|
||||
setManualOpen(false);
|
||||
await load();
|
||||
await refreshDraw();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
@@ -149,6 +147,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onPublishFlowDone(): Promise<void> {
|
||||
await load();
|
||||
await refreshDraw();
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
@@ -161,138 +164,160 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("currentStatusLabel")}</span>
|
||||
<DrawStatusBadge
|
||||
status={data.draw_status}
|
||||
label={drawStatusLabel(data.draw_status, t)}
|
||||
/>
|
||||
<span>· {t("currentStatusDraftHint")}</span>
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{RESULT_SLOTS.map((slot, i) => (
|
||||
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
|
||||
</span>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={manualNumbers[i]}
|
||||
disabled={!canManageDraw || savingManual}
|
||||
placeholder="0000"
|
||||
className="font-mono"
|
||||
onChange={(e) => {
|
||||
const next = e.target.value.replace(/\D/g, "").slice(0, 4);
|
||||
setManualNumbers((old) => old.map((v, idx) => (idx === i ? next : v)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManageDraw || savingManual}
|
||||
onClick={fillRandomManualNumbers}
|
||||
>
|
||||
<Dices className="size-4" aria-hidden />
|
||||
{t("fillRandomNumbers")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={savingManual}
|
||||
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.saveManualDraftTitle"),
|
||||
description: t("confirm.saveManualDraftDescription"),
|
||||
onConfirm: () => saveManualDraft(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{savingManual ? t("saving") : t("saveDraft")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("pendingBatches")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pending.length === 0 ? (
|
||||
<AdminNoResourceState className="py-6" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("batchId")}</TableHead>
|
||||
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
||||
<TableHead>{t("numberCount")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="font-mono text-xs">{b.id}</TableCell>
|
||||
<TableCell>v{b.result_version}</TableCell>
|
||||
<TableCell>{b.items.length}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageDraw ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={discardingBatchId === b.id}
|
||||
actions={[
|
||||
{
|
||||
key: "publish",
|
||||
label: t("reviewAndPublishAction"),
|
||||
icon: Rocket,
|
||||
href: `/admin/draws/${drawId}/publish/${b.id}`,
|
||||
},
|
||||
{
|
||||
key: "discard",
|
||||
label: t("discardPendingBatch"),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: discardingBatchId !== null,
|
||||
onClick: () =>
|
||||
requestConfirm({
|
||||
title: t("confirm.discardPendingBatchTitle"),
|
||||
description: t("confirm.discardPendingBatchDescription"),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: () => discardPendingBatch(b.id),
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<div className="border-b border-border/60 px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{pending.length === 0 ? (
|
||||
<AdminNoResourceState className="py-6" message={t("noPendingBatches")} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
||||
<TableHead>{t("numberCount")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell>v{b.result_version}</TableCell>
|
||||
<TableCell className="tabular-nums">{b.items.length}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{canManageDraw ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={discardingBatchId === b.id}
|
||||
actions={[
|
||||
{
|
||||
key: "publish",
|
||||
label: t("reviewAndPublishAction"),
|
||||
icon: Rocket,
|
||||
onClick: () => setPublishBatch(b),
|
||||
},
|
||||
{
|
||||
key: "discard",
|
||||
label: t("discardPendingBatch"),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: discardingBatchId !== null,
|
||||
onClick: () =>
|
||||
requestConfirm({
|
||||
title: t("confirm.discardPendingBatchTitle"),
|
||||
description: t("confirm.discardPendingBatchDescription"),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: () => discardPendingBatch(b.id),
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManageDraw ? (
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold"
|
||||
onClick={() => setManualOpen((open) => !open)}
|
||||
>
|
||||
{t("manualResultEntry")}
|
||||
{manualOpen ? (
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{manualOpen ? (
|
||||
<div className="space-y-3 border-t border-border/60 px-3 py-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{RESULT_SLOTS.map((slot, i) => (
|
||||
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
|
||||
</span>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={manualNumbers[i]}
|
||||
disabled={savingManual}
|
||||
placeholder="0000"
|
||||
className="h-8 font-mono"
|
||||
onChange={(e) => {
|
||||
const next = e.target.value.replace(/\D/g, "").slice(0, 4);
|
||||
setManualNumbers((old) => old.map((v, idx) => (idx === i ? next : v)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={savingManual}
|
||||
onClick={fillRandomManualNumbers}
|
||||
>
|
||||
<Dices className="size-3.5" aria-hidden />
|
||||
{t("fillRandomNumbers")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={savingManual}
|
||||
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={savingManual || !["closed", "review"].includes(data.draw_status)}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.saveManualDraftTitle"),
|
||||
description: t("confirm.saveManualDraftDescription"),
|
||||
onConfirm: () => saveManualDraft(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{savingManual ? t("saving") : t("saveDraft")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DrawPublishDialog
|
||||
open={publishBatch != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPublishBatch(null);
|
||||
}
|
||||
}}
|
||||
drawId={idNum}
|
||||
batch={publishBatch}
|
||||
onPublished={() => void onPublishFlowDone()}
|
||||
onDiscarded={() => void onPublishFlowDone()}
|
||||
/>
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
|
||||
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
const segments = [
|
||||
@@ -62,6 +64,8 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const canManageDraw = canManageDrawResults(perms);
|
||||
const canViewFinance = canViewDrawFinance(perms);
|
||||
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
|
||||
const { draw } = useDrawDetail();
|
||||
const pendingReview = draw?.result_batch_counts.pending_review ?? 0;
|
||||
|
||||
const visibleSegments = useMemo(
|
||||
() =>
|
||||
@@ -85,7 +89,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminSubnav aria-label={t("subnav.aria", { defaultValue: "期号导航" })} className="mb-6">
|
||||
<AdminSubnav aria-label={t("subnav.aria", { defaultValue: "期号导航" })} className="mb-4">
|
||||
{visibleSegments.map(({ suffix, key, label }) => {
|
||||
const href = `${base}${suffix}`;
|
||||
const active =
|
||||
@@ -99,7 +103,14 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
||||
|
||||
return (
|
||||
<AdminSubnavLink key={key} href={href} active={active}>
|
||||
{t(label)}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t(label)}
|
||||
{key === "review" && pendingReview > 0 ? (
|
||||
<Badge variant="secondary" className="h-5 min-w-5 px-1 text-[11px] tabular-nums">
|
||||
{pendingReview}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</AdminSubnavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
postAdminIntegrationSiteRotateSecrets,
|
||||
putAdminIntegrationSite,
|
||||
} from "@/api/admin-integration-sites";
|
||||
import { validateAdminPassword } from "@/lib/admin-input-validation";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
@@ -337,7 +338,7 @@ export function IntegrationSitesConsole({
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "create" && form.admin_password.trim().length < 8) {
|
||||
if (mode === "create" && validateAdminPassword(form.admin_password) === "too_short") {
|
||||
toast.error(t("integrationSites.form.adminPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -771,7 +772,7 @@ export function IntegrationSitesConsole({
|
||||
type="password"
|
||||
value={form.admin_password}
|
||||
placeholder={t("integrationSites.placeholders.adminPassword", {
|
||||
defaultValue: "至少 8 位",
|
||||
defaultValue: "至少 6 位",
|
||||
})}
|
||||
onChange={(e) => updateForm("admin_password", e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
|
||||
type JackpotTab = "config" | "records";
|
||||
|
||||
function readTabFromHash(): JackpotTab {
|
||||
return window.location.hash === "#records" ? "records" : "config";
|
||||
}
|
||||
|
||||
/** 奖池:配置与流水分 Tab,避免单页堆叠过长。 */
|
||||
export function JackpotConfigScreen() {
|
||||
const { t } = useTranslation("jackpot");
|
||||
const [tab, setTab] = useState<JackpotTab>("config");
|
||||
|
||||
useEffect(() => {
|
||||
const scrollToRecords = () => {
|
||||
if (window.location.hash !== "#records") {
|
||||
return;
|
||||
}
|
||||
document.getElementById("jackpot-records")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
scrollToRecords();
|
||||
window.addEventListener("hashchange", scrollToRecords);
|
||||
return () => window.removeEventListener("hashchange", scrollToRecords);
|
||||
const sync = () => setTab(readTabFromHash());
|
||||
sync();
|
||||
window.addEventListener("hashchange", sync);
|
||||
return () => window.removeEventListener("hashchange", sync);
|
||||
}, []);
|
||||
|
||||
const switchTab = useCallback((next: JackpotTab) => {
|
||||
setTab(next);
|
||||
const path = window.location.pathname;
|
||||
if (next === "records") {
|
||||
window.history.replaceState(null, "", `${path}#records`);
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(null, "", path);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<AdminPageCard title={t("poolsSectionTitle")}>
|
||||
<Alert className="mb-4 border-primary/20 bg-primary/5 text-foreground">
|
||||
<Info className="size-4" aria-hidden />
|
||||
<AlertTitle>{t("rulesTitle")}</AlertTitle>
|
||||
<AlertDescription className="space-y-1 text-xs leading-5">
|
||||
<p>{t("rulesJoin")}</p>
|
||||
<p>{t("rulesBurst")}</p>
|
||||
<p>{t("rulesManual")}</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<JackpotPoolsConsole embedded />
|
||||
</AdminPageCard>
|
||||
<div className="flex w-full max-w-none flex-col gap-4">
|
||||
<AdminSubnav aria-label={t("pageTabs")}>
|
||||
<AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
|
||||
{t("tabConfig")}
|
||||
</AdminSubnavButton>
|
||||
<AdminSubnavButton active={tab === "records"} onClick={() => switchTab("records")}>
|
||||
{t("tabRecords")}
|
||||
</AdminSubnavButton>
|
||||
</AdminSubnav>
|
||||
|
||||
<AdminPageCard
|
||||
id="jackpot-records"
|
||||
title={t("recordsSectionTitle")}
|
||||
description={t("recordsSectionDescription")}
|
||||
>
|
||||
<JackpotRecordsConsole embedded />
|
||||
</AdminPageCard>
|
||||
{tab === "config" ? <JackpotPoolsConsole embedded /> : <JackpotRecordsConsole embedded />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,9 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -44,12 +44,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
formatRatioAsPercent,
|
||||
percentUiToRatio,
|
||||
ratioToPercentUi,
|
||||
} from "@/lib/admin-rate-percent";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||
|
||||
type Draft = {
|
||||
contribution_rate: string;
|
||||
@@ -82,7 +78,6 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
|
||||
}
|
||||
|
||||
type JackpotPoolsConsoleProps = {
|
||||
/** 嵌入运营配置单页时去掉外层脚手架与重复标题 */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
@@ -102,6 +97,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
const [adjustmentDrafts, setAdjustmentDrafts] = useState<Record<number, AdjustmentDraft>>({});
|
||||
const [adjustmentRows, setAdjustmentRows] = useState<Record<number, AdminJackpotPoolAdjustmentRow[]>>({});
|
||||
const [adjustingId, setAdjustingId] = useState<number | null>(null);
|
||||
const [adjustmentOpenId, setAdjustmentOpenId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -110,31 +106,32 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
setItems(res.items);
|
||||
const d: Record<number, Draft> = {};
|
||||
const adjDrafts: Record<number, AdjustmentDraft> = {};
|
||||
const adjRows: Record<number, AdminJackpotPoolAdjustmentRow[]> = {};
|
||||
for (const p of res.items) {
|
||||
d[p.id] = toDraft(p);
|
||||
adjDrafts[p.id] = { direction: "increase", amount: "", reason: "" };
|
||||
try {
|
||||
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
|
||||
adjRows[p.id] = ledger.items;
|
||||
} catch {
|
||||
adjRows[p.id] = [];
|
||||
}
|
||||
}
|
||||
setDrafts(d);
|
||||
setAdjustmentDrafts(adjDrafts);
|
||||
setAdjustmentRows(adjRows);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const loadAdjustments = useCallback(async (poolId: number) => {
|
||||
try {
|
||||
const ledger = await getAdminJackpotPoolAdjustments(poolId, { per_page: 5 });
|
||||
setAdjustmentRows((prev) => ({ ...prev, [poolId]: ledger.items }));
|
||||
} catch {
|
||||
setAdjustmentRows((prev) => ({ ...prev, [poolId]: [] }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateDraft = (id: number, patch: Partial<Draft>) => {
|
||||
setDrafts((prev) => ({
|
||||
...prev,
|
||||
@@ -149,6 +146,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAdjustment = (poolId: number, open: boolean) => {
|
||||
setAdjustmentOpenId(open ? poolId : null);
|
||||
if (open && adjustmentRows[poolId] === undefined) {
|
||||
void loadAdjustments(poolId);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (p: AdminJackpotPoolRow) => {
|
||||
const d = drafts[p.id];
|
||||
if (!d) return;
|
||||
@@ -203,8 +207,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
),
|
||||
);
|
||||
updateAdjustmentDraft(p.id, { amount: "", reason: "" });
|
||||
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
|
||||
setAdjustmentRows((prev) => ({ ...prev, [p.id]: ledger.items }));
|
||||
await loadAdjustments(p.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("adjustmentFailed"));
|
||||
} finally {
|
||||
@@ -237,121 +240,183 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
};
|
||||
|
||||
const poolList = (
|
||||
<div className={embedded ? "space-y-4" : "space-y-8"}>
|
||||
<div className={embedded ? "space-y-4" : "space-y-6"}>
|
||||
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<AdminNoResourceState />
|
||||
) : null}
|
||||
{!loading && items.length === 0 ? <AdminNoResourceState /> : null}
|
||||
{items.map((p) => {
|
||||
const d = drafts[p.id] ?? toDraft(p);
|
||||
const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" };
|
||||
const ledger = adjustmentRows[p.id] ?? [];
|
||||
const currentAmount = formatAdminMinorDecimal(p.current_amount, p.currency_code);
|
||||
const triggerThreshold = formatAdminMinorDecimal(p.trigger_threshold, p.currency_code);
|
||||
const minBetAmount = formatAdminMinorDecimal(p.min_bet_amount, p.currency_code);
|
||||
const statusOn = d.status === "1";
|
||||
const adjustmentOpen = adjustmentOpenId === p.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
<AdminPageCard
|
||||
key={p.id}
|
||||
className="space-y-3 rounded-xl border border-border/60 bg-background p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{p.currency_code}</h3>
|
||||
<p className="text-muted-foreground text-xs">{t("configTitle")}</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
{t("displayBalance", { amount: currentAmount })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("currentAmount")}</p>
|
||||
<p className="mt-1 text-2xl font-semibold leading-none tracking-tight">
|
||||
{currentAmount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("status")}</p>
|
||||
<div className="mt-1">
|
||||
<AdminStatusBadge status={statusOn ? "enabled" : "disabled"}>
|
||||
{statusOn ? t("enabled") : t("disabled")}
|
||||
</AdminStatusBadge>
|
||||
title={p.currency_code}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`status-${p.id}`} className="text-sm text-muted-foreground">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`status-${p.id}`}
|
||||
checked={statusOn}
|
||||
disabled={!canManageJackpot}
|
||||
aria-label={t("status")}
|
||||
onCheckedChange={(checked) => updateDraft(p.id, { status: checked ? "1" : "0" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("payoutRate")}</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{formatRatioAsPercent(percentUiToRatio(d.payout_rate))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||
<p className="text-muted-foreground text-xs">{t("forceTriggerGap")}</p>
|
||||
<p className="mt-1 text-lg font-semibold">{d.force_trigger_draw_gap}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-12">
|
||||
<div className="space-y-3 xl:col-span-8">
|
||||
{canManageJackpot ? (
|
||||
<div className="space-y-2 rounded-lg border border-border/60 bg-background p-3">
|
||||
<p className="text-sm font-medium">{t("balanceAdjustmentTitle")}</p>
|
||||
<p className="text-muted-foreground text-xs">{t("balanceAdjustmentHint")}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("adjustmentDirection")}</Label>
|
||||
<Select
|
||||
value={adj.direction}
|
||||
onValueChange={(value: "increase" | "decrease" | null) => {
|
||||
if (value === null) return;
|
||||
updateAdjustmentDraft(p.id, { direction: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 sm:max-w-[12rem]">
|
||||
<SelectValue>
|
||||
{(value) =>
|
||||
value === "increase"
|
||||
? t("adjustmentIncrease")
|
||||
: value === "decrease"
|
||||
? t("adjustmentDecrease")
|
||||
: value != null
|
||||
? String(value)
|
||||
: t("adjustmentIncrease")
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
|
||||
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
|
||||
<Input
|
||||
id={`adj-amt-${p.id}`}
|
||||
className="font-mono"
|
||||
value={adj.amount}
|
||||
placeholder={t("adjustmentAmountPlaceholder")}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
|
||||
<Textarea
|
||||
id={`adj-reason-${p.id}`}
|
||||
rows={1}
|
||||
value={adj.reason}
|
||||
placeholder={t("adjustmentReasonPlaceholder")}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={savingId === p.id}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmSavePoolTitle"),
|
||||
description: t("confirmSavePoolDescription"),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => save(p),
|
||||
})
|
||||
}
|
||||
>
|
||||
{savingId === p.id ? t("saving") : t("save")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="mb-4 text-2xl font-semibold tabular-nums tracking-tight">{currentAmount}</p>
|
||||
|
||||
<fieldset disabled={!canManageJackpot} className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
|
||||
<Input
|
||||
id={`cr-${p.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="font-mono"
|
||||
value={d.contribution_rate}
|
||||
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
|
||||
<Input
|
||||
id={`th-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.trigger_threshold}
|
||||
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
|
||||
<Input
|
||||
id={`pr-${p.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="font-mono"
|
||||
value={d.payout_rate}
|
||||
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
|
||||
<Input
|
||||
id={`min-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.min_bet_amount}
|
||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2 lg:col-span-2">
|
||||
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
|
||||
<Input
|
||||
id={`gap-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.force_trigger_draw_gap}
|
||||
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2 lg:col-span-3">
|
||||
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
|
||||
<Input
|
||||
id={`combo-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.combo_trigger_play_codes}
|
||||
placeholder={t("comboTriggerPlaysPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{canManageJackpot ? (
|
||||
<div className="mt-4 border-t border-border/60 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
onClick={() => toggleAdjustment(p.id, !adjustmentOpen)}
|
||||
>
|
||||
{t("balanceAdjustmentTitle")}
|
||||
</button>
|
||||
{adjustmentOpen ? (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("adjustmentDirection")}</Label>
|
||||
<Select
|
||||
value={adj.direction}
|
||||
onValueChange={(value: "increase" | "decrease" | null) => {
|
||||
if (value === null) return;
|
||||
updateAdjustmentDraft(p.id, { direction: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0">
|
||||
<SelectValue>
|
||||
{(value) =>
|
||||
value === "increase"
|
||||
? t("adjustmentIncrease")
|
||||
: value === "decrease"
|
||||
? t("adjustmentDecrease")
|
||||
: t("adjustmentIncrease")
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
|
||||
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
|
||||
<Input
|
||||
id={`adj-amt-${p.id}`}
|
||||
className="font-mono"
|
||||
value={adj.amount}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
|
||||
<Textarea
|
||||
id={`adj-reason-${p.id}`}
|
||||
rows={2}
|
||||
value={adj.reason}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={adjustingId === p.id}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
@@ -365,177 +430,50 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
{adjustingId === p.id ? t("processing") : t("submitAdjustment")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<fieldset
|
||||
disabled={!canManageJackpot}
|
||||
className="grid gap-2 rounded-lg border border-border/60 bg-background p-3 sm:grid-cols-2"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
|
||||
<Input
|
||||
id={`th-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.trigger_threshold}
|
||||
placeholder={t("triggerThresholdPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
|
||||
<Input
|
||||
id={`min-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.min_bet_amount}
|
||||
placeholder={t("minBetAmountPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
|
||||
<Input
|
||||
id={`pr-${p.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="font-mono"
|
||||
value={d.payout_rate}
|
||||
placeholder={t("payoutRatePlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
|
||||
<Input
|
||||
id={`gap-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.force_trigger_draw_gap}
|
||||
placeholder={t("forceTriggerGapPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
|
||||
<Input
|
||||
id={`cr-${p.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
className="font-mono"
|
||||
value={d.contribution_rate}
|
||||
placeholder={t("contributionRatePlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
|
||||
<Input
|
||||
id={`combo-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.combo_trigger_play_codes}
|
||||
placeholder={t("comboTriggerPlaysPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 sm:col-span-2">
|
||||
<Label htmlFor={`status-${p.id}`} className="text-sm font-medium">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`status-${p.id}`}
|
||||
checked={statusOn}
|
||||
disabled={!canManageJackpot}
|
||||
aria-label={t("status")}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraft(p.id, { status: checked ? "1" : "0" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{canManageJackpot ? (
|
||||
<div className="flex justify-end sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={savingId === p.id}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmSavePoolTitle"),
|
||||
description: t("confirmSavePoolDescription"),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => save(p),
|
||||
})
|
||||
}
|
||||
>
|
||||
{savingId === p.id ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 xl:col-span-4">
|
||||
<div className="rounded-lg border border-border/60 bg-background p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{t("recentAdjustments")}</p>
|
||||
<span className="text-muted-foreground text-xs">{ledger.length}</span>
|
||||
</div>
|
||||
{ledger.length > 0 ? (
|
||||
<ul className="max-h-60 space-y-2 overflow-y-auto pr-1">
|
||||
{ledger.map((row) => (
|
||||
<li key={row.id} className="rounded-md border border-border/60 bg-muted/20 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs">{row.adjustment_no}</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{ledger.length > 0 ? (
|
||||
<ul className="space-y-1.5 sm:col-span-2">
|
||||
{ledger.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<span className="truncate text-muted-foreground">{row.reason}</span>
|
||||
<span className="shrink-0 font-mono font-medium">
|
||||
{row.amount_delta > 0 ? "+" : ""}
|
||||
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{row.reason}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">—</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-background p-3">
|
||||
<p className="text-muted-foreground text-xs">{t("triggerThreshold")}</p>
|
||||
<p className="mt-1 text-lg font-semibold">{triggerThreshold}</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">{t("minBetAmount")}</p>
|
||||
<p className="mt-1 text-lg font-semibold">{minBetAmount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManualBurst ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p className="mb-1 text-sm font-medium text-destructive">{t("manualBurst")}</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t("manualBurstHint")}</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
|
||||
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
|
||||
<Input
|
||||
id={`burst-draw-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.manual_burst_draw_id}
|
||||
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="shrink-0 sm:ml-auto"
|
||||
disabled={burstingId === p.id}
|
||||
onClick={() => setConfirmBurstPoolId(p.id)}
|
||||
>
|
||||
{burstingId === p.id ? t("processing") : t("manualBurst")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canManualBurst ? (
|
||||
<div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
|
||||
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
|
||||
<Input
|
||||
id={`burst-draw-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.manual_burst_draw_id}
|
||||
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={burstingId === p.id}
|
||||
onClick={() => setConfirmBurstPoolId(p.id)}
|
||||
>
|
||||
{burstingId === p.id ? t("processing") : t("manualBurst")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminPageCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -594,4 +532,4 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
<ConfirmActionDialog />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { AdminTableExportButton } from "@/components/admin/admin-table-export-bu
|
||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
@@ -167,13 +167,15 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
return translated === key ? value : translated;
|
||||
};
|
||||
|
||||
const filterFields = (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="flex w-full min-w-0 max-w-sm flex-col gap-1.5">
|
||||
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
||||
const filterBlock = (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="jk-draw" className="text-xs text-muted-foreground">
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="jk-draw"
|
||||
className="font-mono"
|
||||
className="h-9 w-40 font-mono"
|
||||
value={drawNo}
|
||||
onChange={(e) => setDrawNo(e.target.value)}
|
||||
placeholder={t("optional")}
|
||||
@@ -184,23 +186,12 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" className="shrink-0 sm:self-end" onClick={applyDraw}>
|
||||
<Button type="button" size="sm" className="h-9" onClick={applyDraw}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filterBlock = embedded ? (
|
||||
filterFields
|
||||
) : (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("filter")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{filterFields}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const payoutFooter = payouts ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="jk-payout-per"
|
||||
@@ -325,10 +316,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{filterBlock}
|
||||
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
||||
|
||||
<AdminSubnav aria-label={t("recordTabs", { defaultValue: "奖池记录" })}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<AdminSubnav aria-label={t("pageTabs")}>
|
||||
<AdminSubnavButton
|
||||
active={recordTab === "payout"}
|
||||
onClick={() => setRecordTab("payout")}
|
||||
@@ -341,7 +330,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
>
|
||||
{t("contributionRecords")}
|
||||
</AdminSubnavButton>
|
||||
</AdminSubnav>
|
||||
</AdminSubnav>
|
||||
{filterBlock}
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
{recordTab === "payout" ? payoutTable : contributionTable}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, ShieldAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, ChevronDown, ChevronRight, RefreshCw, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
@@ -17,19 +18,18 @@ import {
|
||||
manuallyProcessTransferOrder,
|
||||
reverseTransferOrder,
|
||||
} from "@/api/admin-wallet";
|
||||
import { ReconcileItemActions } from "@/modules/reconcile/reconcile-item-actions";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { ReconcileJobDetailPanel, type ReconcileItemsFilter } from "@/modules/reconcile/reconcile-job-detail-panel";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -51,103 +51,15 @@ import type {
|
||||
AdminReconcileItemsData,
|
||||
AdminReconcileJobListData,
|
||||
} from "@/types/api/admin-reconcile";
|
||||
import {
|
||||
getJobSummaryValue,
|
||||
jobStatusLabel,
|
||||
renderPeriodRange,
|
||||
} from "@/modules/reconcile/reconcile-labels";
|
||||
|
||||
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
|
||||
|
||||
/** 与后端 reconcile_type 对齐;扩展时在 API 与下拉同步增加 */
|
||||
const RECONCILE_TYPE = "wallet_transfer" as const;
|
||||
|
||||
function jobStatusLabel(status: string, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return t("statusCompleted");
|
||||
case "running":
|
||||
return t("statusRunning");
|
||||
case "failed":
|
||||
return t("statusFailed");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function itemStatusLabel(status: string, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case "mismatch":
|
||||
return t("itemMismatch");
|
||||
case "matched":
|
||||
return t("itemMatched");
|
||||
case "pending_check":
|
||||
return t("itemPendingCheck");
|
||||
case "stale_processing":
|
||||
return t("itemStaleProcessing");
|
||||
case "pending_reconcile":
|
||||
return t("itemPendingReconcile");
|
||||
case "missing_wallet_txn":
|
||||
return t("itemMissingWalletTxn");
|
||||
case "unexpected_wallet_txn":
|
||||
return t("itemUnexpectedWalletTxn");
|
||||
case "missing_refund":
|
||||
return t("itemMissingRefund");
|
||||
case "missing_reversal":
|
||||
return t("itemMissingReversal");
|
||||
case "main_site_record_missing":
|
||||
return t("itemMainSiteRecordMissing");
|
||||
case "main_site_failed":
|
||||
return t("itemMainSiteFailed");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
||||
switch (type) {
|
||||
case "wallet_transfer":
|
||||
return t("reconcileTypeFixed");
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function mainSiteCheckLabel(status: string | null | undefined, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case "matched":
|
||||
return t("mainSiteMatched");
|
||||
case "not_found":
|
||||
return t("mainSiteNotFound");
|
||||
case "failed_on_main":
|
||||
return t("mainSiteFailed");
|
||||
case "unavailable":
|
||||
return t("mainSiteUnavailable");
|
||||
default:
|
||||
return t("mainSiteSkipped");
|
||||
}
|
||||
}
|
||||
|
||||
function itemResolutionLabel(
|
||||
row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return row.is_resolved === true || row.resolved_at ? t("itemResolved") : t("itemUnresolved");
|
||||
}
|
||||
|
||||
function itemResolutionTone(row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">): "success" | "warning" {
|
||||
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
|
||||
}
|
||||
|
||||
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
||||
const raw = summary?.[key];
|
||||
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
||||
}
|
||||
|
||||
function renderPeriodRange(
|
||||
row: Pick<AdminReconcileJobRow, "period_start" | "period_end">,
|
||||
formatTs: (value: string | null | undefined) => string,
|
||||
): string {
|
||||
const from = row.period_start ? formatTs(row.period_start) : "—";
|
||||
const to = row.period_end ? formatTs(row.period_end) : "—";
|
||||
return `${from} ~ ${to}`;
|
||||
}
|
||||
|
||||
export function ReconcileConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["reconcile", "common"]);
|
||||
const tRef = useTranslationRef(["reconcile", "common"]);
|
||||
@@ -163,18 +75,20 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [selectedJob, setSelectedJob] = useState<AdminReconcileJobRow | null>(null);
|
||||
const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
|
||||
const [itemsPage, setItemsPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
const [itemsLoading, setItemsLoading] = useState(false);
|
||||
const [itemsFilter, setItemsFilter] = useState<ReconcileItemsFilter>("open");
|
||||
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [playerSearch, setPlayerSearch] = useState("");
|
||||
const [playerResults, setPlayerResults] = useState<AdminPlayerRow[]>([]);
|
||||
const [playerLoading, setPlayerLoading] = useState(false);
|
||||
const [playerPickerOpen, setPlayerPickerOpen] = useState(false);
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
@@ -191,20 +105,20 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
}
|
||||
}, [page, perPage]);
|
||||
}, [page, perPage, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void loadJobs();
|
||||
}, [page, perPage]);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
if (selectedId == null) {
|
||||
if (selectedJob == null) {
|
||||
setItems(null);
|
||||
return;
|
||||
}
|
||||
setItemsLoading(true);
|
||||
try {
|
||||
const d = await getAdminReconcileJobItems(selectedId, {
|
||||
const d = await getAdminReconcileJobItems(selectedJob.id, {
|
||||
page: itemsPage,
|
||||
per_page: itemsPerPage,
|
||||
});
|
||||
@@ -215,11 +129,11 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setItemsLoading(false);
|
||||
}
|
||||
}, [selectedId, itemsPage, itemsPerPage]);
|
||||
}, [selectedJob, itemsPage, itemsPerPage, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void loadItems();
|
||||
}, [selectedId, itemsPage, itemsPerPage]);
|
||||
}, [selectedJob, itemsPage, itemsPerPage]);
|
||||
|
||||
const loadPlayers = useCallback(async (keyword: string) => {
|
||||
const q = keyword.trim();
|
||||
@@ -239,15 +153,14 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const q = playerSearch.trim();
|
||||
if (q === "") {
|
||||
if (!playerPickerOpen) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadPlayers(q);
|
||||
void loadPlayers(playerSearch);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadPlayers, playerSearch]);
|
||||
}, [loadPlayers, playerPickerOpen, playerSearch]);
|
||||
|
||||
async function onCreate(): Promise<void> {
|
||||
if (!dateFrom.trim() || !dateTo.trim()) {
|
||||
@@ -273,6 +186,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
: resp.item_count ?? 0;
|
||||
toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty"));
|
||||
setPage(1);
|
||||
setScanOpen(false);
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
setPlayerSearch("");
|
||||
@@ -328,36 +242,50 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
});
|
||||
}
|
||||
|
||||
function openJobDetail(job: AdminReconcileJobRow): void {
|
||||
setSelectedJob(job);
|
||||
setItemsPage(1);
|
||||
setItemsFilter("open");
|
||||
}
|
||||
|
||||
function closeJobDetail(): void {
|
||||
setSelectedJob(null);
|
||||
setItems(null);
|
||||
}
|
||||
|
||||
const jm = jobs?.meta;
|
||||
const im = items?.meta;
|
||||
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
|
||||
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
|
||||
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
|
||||
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">{t("workflowHint")}</p>
|
||||
<Link
|
||||
href="/admin/wallet/transfer-orders?abnormal=1"
|
||||
title={t("shortcutAbnormalTransfers")}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-sm font-medium hover:underline"
|
||||
>
|
||||
{t("shortcutAbnormalTransfers")}
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{canCreate ? (
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t("createHint")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<span className="text-sm font-medium leading-none sm:shrink-0">{t("reconcileType")}</span>
|
||||
<span className="inline-flex h-8 min-h-8 min-w-0 items-center rounded-md border border-border/60 bg-muted/30 px-2.5 text-sm text-foreground">
|
||||
{t("reconcileTypeFixed")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="rc-date-range" className="sm:shrink-0">
|
||||
{t("dateRange")}
|
||||
</Label>
|
||||
<div className="min-w-0 w-full sm:w-60">
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-medium"
|
||||
onClick={() => setScanOpen((open) => !open)}
|
||||
>
|
||||
{t("createTitle")}
|
||||
{scanOpen ? <ChevronDown className="size-4 text-muted-foreground" /> : <ChevronRight className="size-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{scanOpen ? (
|
||||
<div className="space-y-3 border-t border-border/60 px-3 py-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[14rem] flex-1">
|
||||
<AdminDateRangeField
|
||||
id="rc-date-range"
|
||||
label={t("dateRange")}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
@@ -366,23 +294,66 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-list-field min-w-0 flex-1">
|
||||
<Label htmlFor="rc-player-search" className="sm:shrink-0">
|
||||
{t("playerSearch")}
|
||||
</Label>
|
||||
<Input
|
||||
id="rc-player-search"
|
||||
className="w-full sm:w-52"
|
||||
value={playerSearch}
|
||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||
placeholder={t("playerSearchPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<div className="min-w-[12rem] flex-1">
|
||||
<Label htmlFor="rc-player-search" className="mb-1.5 block text-sm">
|
||||
{t("playerSearch")}
|
||||
</Label>
|
||||
<Popover open={playerPickerOpen} onOpenChange={setPlayerPickerOpen} modal={false}>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
id="rc-player-search"
|
||||
className="h-8"
|
||||
value={playerSearch}
|
||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||
placeholder={t("playerSearchPlaceholder")}
|
||||
/>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2"
|
||||
aria-label={t("searchPicker.open", { ns: "reports", defaultValue: "搜索" })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent align="start" className="w-[var(--anchor-width)] min-w-[16rem] p-2">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{playerLoading ? (
|
||||
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
|
||||
) : playerResults.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">{t("playerEmpty", { defaultValue: "无匹配玩家" })}</p>
|
||||
) : (
|
||||
playerResults.map((player) => (
|
||||
<button
|
||||
key={player.id}
|
||||
type="button"
|
||||
className="flex w-full rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(player);
|
||||
setPlayerSearch(player.site_player_id);
|
||||
setPlayerPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{player.site_player_id}
|
||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full sm:w-auto"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
@@ -399,353 +370,161 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{submitting ? t("submitting") : t("createTask")}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedPlayer ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md bg-muted/40 px-2.5 py-1.5 text-sm">
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedPlayer.site_player_id}
|
||||
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(null);
|
||||
setPlayerSearch("");
|
||||
}}
|
||||
>
|
||||
{t("playerClear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedPlayer ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
||||
<div className="min-w-0 truncate font-medium text-foreground">
|
||||
{selectedPlayer.site_player_id}
|
||||
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
||||
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
||||
{` · ${selectedPlayer.site_code}`}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(null);
|
||||
setPlayerSearch("");
|
||||
setPlayerResults([]);
|
||||
}}
|
||||
>
|
||||
{t("playerClear")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
||||
<div className="rounded-lg border bg-background">
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{playerLoading ? (
|
||||
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
|
||||
) : playerResults.length === 0 ? (
|
||||
<AdminNoResourceState compact className="px-3 py-4" />
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{playerResults.map((player) => {
|
||||
const active = selectedPlayer?.id === player.id;
|
||||
return (
|
||||
<button
|
||||
key={player.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
||||
active && "bg-muted/30 font-medium",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedPlayer(player);
|
||||
setPlayerSearch(player.site_player_id);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{player.site_player_id}
|
||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||
{player.username ? ` · ${player.username}` : ""}
|
||||
{` · ${player.site_code}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{t("noCreatePermission")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("viewOnlyHint")}</p>
|
||||
)}
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold">{t("jobsTitle")}</h2>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8" disabled={jobsLoading} onClick={() => void loadJobs()}>
|
||||
<RefreshCw className={cn("size-3.5", jobsLoading && "animate-spin")} />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
{jobsErr ? <p className="text-sm text-destructive">{jobsErr}</p> : null}
|
||||
</div>
|
||||
<div className="p-3 pt-2">
|
||||
{jobsErr ? <p className="mb-2 text-sm text-destructive">{jobsErr}</p> : null}
|
||||
{jobs ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="reconcile-jobs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="sticky left-0 z-20 w-24 bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("table.id", { ns: "common" })}
|
||||
</TableHead>
|
||||
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("jobNo")}
|
||||
</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-center">{t("itemCount")}</TableHead>
|
||||
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
|
||||
<TableHead>{t("period")}</TableHead>
|
||||
<TableHead>{t("finishedAt")}</TableHead>
|
||||
<TableHead>{t("createdAt")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("operate")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobsLoading && !jobs ? (
|
||||
<AdminTableLoadingRow colSpan={10} />
|
||||
) : jobs.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
|
||||
) : (
|
||||
jobs.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="sticky left-0 z-10 bg-card tabular-nums shadow-[1px_0_0_rgba(226,232,240,0.9)]">
|
||||
{row.id}
|
||||
</TableCell>
|
||||
<TableCell className="sticky left-24 z-10 min-w-[14rem] bg-card font-mono text-xs shadow-[1px_0_0_rgba(226,232,240,0.9)]">
|
||||
{row.job_no}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{reconcileTypeLabel(row.reconcile_type, t)}</TableCell>
|
||||
<Table id="reconcile-jobs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("jobNo")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
|
||||
<TableHead>{t("period")}</TableHead>
|
||||
<TableHead>{t("finishedAt")}</TableHead>
|
||||
<TableHead className="w-[6.5rem] text-center">{t("operate")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobsLoading && jobs.items.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={6} />
|
||||
) : jobs.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={6} />
|
||||
) : (
|
||||
jobs.items.map((row) => {
|
||||
const selected = selectedJob?.id === row.id;
|
||||
const mismatchCount = getJobSummaryValue(row.summary_json, "mismatch_count");
|
||||
return (
|
||||
<TableRow key={row.id} className={cn(selected && "bg-primary/[0.04]")}>
|
||||
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{jobStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{getJobSummaryValue(row.summary_json, "item_count")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
getJobSummaryValue(row.summary_json, "mismatch_count") > 0
|
||||
? "font-medium text-amber-700"
|
||||
: "text-muted-foreground",
|
||||
mismatchCount > 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{getJobSummaryValue(row.summary_json, "mismatch_count")}
|
||||
{mismatchCount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
|
||||
<span className="line-clamp-2">
|
||||
{renderPeriodRange(row, formatTs)}
|
||||
</span>
|
||||
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||||
{renderPeriodRange(row, formatTs)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatTs(row.finished_at)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(226,232,240,0.9)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view-details",
|
||||
label: t("viewDetails"),
|
||||
icon: Eye,
|
||||
onClick: () => {
|
||||
setSelectedId(row.id);
|
||||
setItemsPage(1);
|
||||
setDetailOpen(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
className="h-8"
|
||||
onClick={() => openJobDetail(row)}
|
||||
>
|
||||
{t("viewDetails")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{jm ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="reconcile-jobs-per-page"
|
||||
total={jm.total}
|
||||
page={jm.current_page}
|
||||
lastPage={Math.max(1, jm.last_page)}
|
||||
perPage={jm.per_page}
|
||||
loading={jobsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
<div className="mt-3 border-t border-border/60 pt-2">
|
||||
<AdminListPaginationFooter
|
||||
selectId="reconcile-jobs-per-page"
|
||||
total={jm.total}
|
||||
page={jm.current_page}
|
||||
lastPage={Math.max(1, jm.last_page)}
|
||||
perPage={jm.per_page}
|
||||
loading={jobsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : jobsLoading ? (
|
||||
<AdminNoResourceState message={t("loadFailed")} />
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={detailOpen}
|
||||
open={selectedJob != null}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open);
|
||||
if (!open) {
|
||||
setSelectedId(null);
|
||||
setItems(null);
|
||||
closeJobDetail();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="flex max-h-[min(90vh,48rem)] w-full max-w-[min(72rem,calc(100%-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,calc(100%-2rem))]"
|
||||
showCloseButton
|
||||
className="flex h-[min(86vh,780px)] !max-w-[min(920px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-base">{t("detailsTitle")}</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">
|
||||
{selectedJob ? `${selectedJob.job_no} · #${selectedJob.id}` : selectedId != null ? `#${selectedId}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
||||
{itemsLoading && !items ? (
|
||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
||||
) : null}
|
||||
{items ? (
|
||||
<>
|
||||
<div className="mb-4 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("itemCount")}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobItemCount}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("mismatchCount")}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xl font-semibold tabular-nums text-amber-700">
|
||||
<ShieldAlert className="size-4" />
|
||||
{selectedJobMismatchCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background px-4 py-3">
|
||||
<div className="text-xs text-muted-foreground">{t("matchedCount")}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobMatchedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("jobNo")} {items.job_no}</span>
|
||||
<span>·</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t("status")}
|
||||
{selectedJob ? (
|
||||
<AdminStatusBadge status={selectedJob.status}>
|
||||
{jobStatusLabel(selectedJob.status, t)}
|
||||
</AdminStatusBadge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{t("period")} {selectedJob ? renderPeriodRange(selectedJob, formatTs) : "—"}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead className="min-w-[10rem]">{t("transferNo")}</TableHead>
|
||||
<TableHead className="min-w-[10rem]">{t("walletTxnNo")}</TableHead>
|
||||
<TableHead className="min-w-[10rem]">{t("mainSiteRef")}</TableHead>
|
||||
<TableHead className="w-28">{t("mainSiteCheck")}</TableHead>
|
||||
<TableHead className="w-28 text-right">{t("differenceAmount")}</TableHead>
|
||||
<TableHead className="w-32">{t("itemResult")}</TableHead>
|
||||
<TableHead className="w-28">{t("processingStatus")}</TableHead>
|
||||
<TableHead className="min-w-[12rem]">{t("actions")}</TableHead>
|
||||
<TableHead className="w-36">{t("detectedAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={10} />
|
||||
) : (
|
||||
items.items.map((r) => (
|
||||
<TableRow
|
||||
key={r.id}
|
||||
className={cn(
|
||||
r.is_resolved !== true && "bg-amber-500/5",
|
||||
)}
|
||||
>
|
||||
<TableCell className="align-top">{r.id}</TableCell>
|
||||
<TableCell className="align-top font-mono text-xs break-all">{r.side_a_ref ?? "—"}</TableCell>
|
||||
<TableCell className="align-top font-mono text-xs break-all">{r.side_b_ref ?? "—"}</TableCell>
|
||||
<TableCell className="align-top font-mono text-xs break-all">
|
||||
{r.main_site_external_ref_no ?? r.external_ref_no ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-xs">
|
||||
{mainSiteCheckLabel(r.main_site_check, t)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-right tabular-nums">
|
||||
<span
|
||||
className={cn(
|
||||
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.difference_amount}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AdminStatusBadge status={r.status}>
|
||||
{itemStatusLabel(r.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AdminStatusBadge status={r.resolved_at ? "resolved" : "unresolved"} tone={itemResolutionTone(r)}>
|
||||
{itemResolutionLabel(r, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top min-w-[12rem]">
|
||||
<ReconcileItemActions
|
||||
row={r}
|
||||
canWriteWallet={canWriteWallet}
|
||||
busy={actionBusy}
|
||||
onCompleteCredit={(transferNo) => void runTransferAction(transferNo, "complete_credit")}
|
||||
onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")}
|
||||
onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(r.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{im ? (
|
||||
<div className="pt-4">
|
||||
<AdminListPaginationFooter
|
||||
selectId="reconcile-items-per-page"
|
||||
total={im.total}
|
||||
page={im.current_page}
|
||||
lastPage={Math.max(1, im.last_page)}
|
||||
perPage={im.per_page}
|
||||
loading={itemsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setItemsPerPage(n);
|
||||
setItemsPage(1);
|
||||
}}
|
||||
onPageChange={setItemsPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedJob ? (
|
||||
<ReconcileJobDetailPanel
|
||||
job={selectedJob}
|
||||
items={items}
|
||||
itemsLoading={itemsLoading}
|
||||
itemsFilter={itemsFilter}
|
||||
onItemsFilterChange={setItemsFilter}
|
||||
canWriteWallet={canWriteWallet}
|
||||
actionBusy={actionBusy}
|
||||
onCompleteCredit={(transferNo) => void runTransferAction(transferNo, "complete_credit")}
|
||||
onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")}
|
||||
onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")}
|
||||
onItemsPageChange={setItemsPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { RotateCcw, Wrench } from "lucide-react";
|
||||
import { ExternalLink, RotateCcw, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
@@ -19,6 +19,7 @@ type ReconcileItemActionsProps = {
|
||||
row: AdminReconcileItemRow;
|
||||
canWriteWallet: boolean;
|
||||
busy: boolean;
|
||||
compact?: boolean;
|
||||
onCompleteCredit: (transferNo: string) => void;
|
||||
onReverse: (transferNo: string) => void;
|
||||
onManualProcess: (transferNo: string) => void;
|
||||
@@ -28,6 +29,7 @@ export function ReconcileItemActions({
|
||||
row,
|
||||
canWriteWallet,
|
||||
busy,
|
||||
compact = false,
|
||||
onCompleteCredit,
|
||||
onReverse,
|
||||
onManualProcess,
|
||||
@@ -45,36 +47,60 @@ export function ReconcileItemActions({
|
||||
can_manually_process: row.can_manually_process,
|
||||
};
|
||||
|
||||
const hasWalletAction = transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet);
|
||||
|
||||
const menuActions = [
|
||||
{
|
||||
key: "complete",
|
||||
label: t("completeCredit", { ns: "wallet" }),
|
||||
hidden: !canCompleteTransferInCredit(actionRow, canWriteWallet),
|
||||
onClick: () => onCompleteCredit(transferNo),
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
label: t("markCaseClosed", { ns: "wallet" }),
|
||||
icon: Wrench,
|
||||
hidden: !canManuallyProcessTransferOrder(actionRow, canWriteWallet),
|
||||
onClick: () => onManualProcess(transferNo),
|
||||
},
|
||||
{
|
||||
key: "reverse",
|
||||
label: t("reverse", { ns: "wallet" }),
|
||||
icon: RotateCcw,
|
||||
destructive: true,
|
||||
hidden: !canReverseTransferOrder(actionRow, canWriteWallet),
|
||||
onClick: () => onReverse(transferNo),
|
||||
},
|
||||
{
|
||||
key: "transfer",
|
||||
label: t("openTransferOrder"),
|
||||
icon: ExternalLink,
|
||||
hidden: transferNo === "",
|
||||
onClick: () => {
|
||||
window.location.href = `/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "txn",
|
||||
label: t("openWalletTxn"),
|
||||
icon: ExternalLink,
|
||||
hidden: !row.side_b_ref,
|
||||
onClick: () => {
|
||||
window.location.href = `/admin/wallet/transactions?txn_no=${encodeURIComponent(row.side_b_ref ?? "")}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (compact) {
|
||||
if (!hasWalletAction && !transferNo && !row.side_b_ref) {
|
||||
return <span>—</span>;
|
||||
}
|
||||
return <AdminRowActionsMenu busy={busy} actions={menuActions} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet) ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={busy}
|
||||
actions={[
|
||||
{
|
||||
key: "complete",
|
||||
label: t("completeCredit", { ns: "wallet" }),
|
||||
hidden: !canCompleteTransferInCredit(actionRow, canWriteWallet),
|
||||
onClick: () => onCompleteCredit(transferNo),
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
label: t("markCaseClosed", { ns: "wallet" }),
|
||||
icon: Wrench,
|
||||
hidden: !canManuallyProcessTransferOrder(actionRow, canWriteWallet),
|
||||
onClick: () => onManualProcess(transferNo),
|
||||
},
|
||||
{
|
||||
key: "reverse",
|
||||
label: t("reverse", { ns: "wallet" }),
|
||||
icon: RotateCcw,
|
||||
destructive: true,
|
||||
hidden: !canReverseTransferOrder(actionRow, canWriteWallet),
|
||||
onClick: () => onReverse(transferNo),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{hasWalletAction ? <AdminRowActionsMenu busy={busy} actions={menuActions.filter((a) => a.key !== "transfer" && a.key !== "txn")} /> : null}
|
||||
{transferNo !== "" ? (
|
||||
<Link
|
||||
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`}
|
||||
@@ -94,4 +120,4 @@ export function ReconcileItemActions({
|
||||
{!transferNo && !row.side_b_ref ? <span>—</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
135
src/modules/reconcile/reconcile-item-row.tsx
Normal file
135
src/modules/reconcile/reconcile-item-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/modules/reconcile/reconcile-job-detail-panel.tsx
Normal file
204
src/modules/reconcile/reconcile-job-detail-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/modules/reconcile/reconcile-labels.ts
Normal file
131
src/modules/reconcile/reconcile-labels.ts
Normal 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;
|
||||
}
|
||||
420
src/modules/reports/report-preview-tables.tsx
Normal file
420
src/modules/reports/report-preview-tables.tsx
Normal 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 />;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
CalendarDays,
|
||||
Database,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
ListFilter,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Ticket,
|
||||
Users,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
import { ArrowUpRight, Database, FileDown, FileSpreadsheet, Search } from "lucide-react";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
import { drawStatusLabel } from "@/modules/draws/draw-display";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
@@ -42,25 +30,17 @@ import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
|
||||
import { getAdminUsers } from "@/api/admin-users";
|
||||
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AUDIT_VIEW,
|
||||
PRD_REPORT_EXPORT,
|
||||
PRD_REPORT_VIEW,
|
||||
PRD_RISK_ACCESS_ANY,
|
||||
PRD_WALLET_TRANSFER_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import { PRD_REPORT_EXPORT } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -68,559 +48,87 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { formatAdminInstant } from "@/lib/admin-datetime";
|
||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminAuditLogRow } from "@/types/api/admin-audit";
|
||||
import type { AdminDrawListItem } from "@/types/api/admin-draws";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||
import type { AdminRiskPoolRow, AdminRiskPoolShowData } from "@/types/api/admin-risk";
|
||||
import type { AdminUserPermissionRow } from "@/types/api/admin-user";
|
||||
import type { AdminTransferOrderItem } from "@/types/api/admin-wallet";
|
||||
import type {
|
||||
AdminReportDailyProfitRow,
|
||||
AdminReportPlayDimensionRow,
|
||||
AdminReportPlayerWinLossRow,
|
||||
} from "@/types/api/admin-reports";
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
|
||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
||||
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
|
||||
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
||||
type ExportFormat = "csv" | "excel";
|
||||
type ExportCell = string | number | null;
|
||||
type ExportRow = Record<string, ExportCell>;
|
||||
type SearchKind = "draw" | "player" | "operator";
|
||||
import { ReportPreviewTable } from "@/modules/reports/report-preview-tables";
|
||||
import {
|
||||
CATEGORY_SHORTCUT_HREF,
|
||||
REPORT_CATEGORY_ORDER,
|
||||
REPORT_DEFINITIONS,
|
||||
type FieldKey,
|
||||
type ReportCategory,
|
||||
type ReportKey,
|
||||
} from "@/modules/reports/reports-definitions";
|
||||
import {
|
||||
buildDailyProfitRowsAndSummary,
|
||||
buildPlayDimensionRowsAndSummary,
|
||||
buildPlayerTransferRowsAndSummary,
|
||||
createDefaultFilters,
|
||||
downloadBlob,
|
||||
drawRowsFromSummary,
|
||||
emptyFilters,
|
||||
emptySearch,
|
||||
formatExportInstant,
|
||||
formatPlainMoney,
|
||||
formatUsagePercent,
|
||||
headlineStats,
|
||||
metaFromList,
|
||||
normalizeFilenamePart,
|
||||
optionText,
|
||||
parsePositiveInteger,
|
||||
reportHasPeriodField,
|
||||
reportListParams,
|
||||
resolveDisplayCurrency,
|
||||
resolveDraw,
|
||||
type ExportFormat,
|
||||
type ReportFilters,
|
||||
type ReportResult,
|
||||
type SearchKind,
|
||||
type SearchState,
|
||||
} from "@/modules/reports/reports-utils";
|
||||
|
||||
type ReportKey =
|
||||
| "draw_profit"
|
||||
| "daily_profit"
|
||||
| "player_win_loss"
|
||||
| "player_transfer"
|
||||
| "hot_number_risk"
|
||||
| "play_dimension"
|
||||
| "sold_out_number"
|
||||
| "admin_audit";
|
||||
export type { ReportCategory } from "@/modules/reports/reports-definitions";
|
||||
|
||||
type ReportDefinition = {
|
||||
key: ReportKey;
|
||||
category: ReportCategory;
|
||||
icon: typeof FileSpreadsheet;
|
||||
filterKind: FilterKind;
|
||||
scope: string;
|
||||
fields: FieldKey[];
|
||||
connected: boolean;
|
||||
requiredAny: readonly string[];
|
||||
type ReportsConsoleProps = {
|
||||
initialCategory?: ReportCategory;
|
||||
};
|
||||
|
||||
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
|
||||
|
||||
const REPORTS: ReportDefinition[] = [
|
||||
{ key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
||||
{ key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
||||
{ key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
||||
{ key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_WALLET_TRANSFER_ACCESS_ANY },
|
||||
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
|
||||
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
||||
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
|
||||
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true, requiredAny: [PRD_AUDIT_VIEW] },
|
||||
];
|
||||
|
||||
type PreviewColumns = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
metricA: string;
|
||||
metricB: string;
|
||||
metricC: string;
|
||||
status: string;
|
||||
extra: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type ReportFilters = {
|
||||
drawNo: string;
|
||||
drawId: number | null;
|
||||
number: string;
|
||||
player: string;
|
||||
playerId: number | null;
|
||||
play: string;
|
||||
operator: string;
|
||||
operatorId: number | null;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
};
|
||||
|
||||
type ReportMeta = {
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
};
|
||||
|
||||
type ReportResult =
|
||||
| { key: "draw_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminDrawFinanceSummaryData }
|
||||
| { key: "daily_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportDailyProfitRow[] }
|
||||
| { key: "player_win_loss"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayerWinLossRow[] }
|
||||
| { key: "player_transfer"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminTransferOrderItem[] }
|
||||
| { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData }
|
||||
| { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] }
|
||||
| { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] }
|
||||
| { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
|
||||
|
||||
type StatCard = {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "default" | "good" | "warn" | "bad";
|
||||
};
|
||||
|
||||
type SearchState = {
|
||||
open: SearchKind | null;
|
||||
query: string;
|
||||
loading: boolean;
|
||||
draws: AdminDrawListItem[];
|
||||
players: AdminPlayerRow[];
|
||||
operators: AdminUserPermissionRow[];
|
||||
};
|
||||
|
||||
type PlayOption = {
|
||||
code: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const emptyFilters: ReportFilters = {
|
||||
drawNo: "",
|
||||
drawId: null,
|
||||
number: "",
|
||||
player: "",
|
||||
playerId: null,
|
||||
play: "",
|
||||
operator: "",
|
||||
operatorId: null,
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
};
|
||||
|
||||
function isoDateLocal(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function defaultReportPeriod(): Pick<ReportFilters, "dateFrom" | "dateTo"> {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 29);
|
||||
return { dateFrom: isoDateLocal(from), dateTo: isoDateLocal(to) };
|
||||
}
|
||||
|
||||
function createDefaultFilters(): ReportFilters {
|
||||
return { ...emptyFilters, ...defaultReportPeriod() };
|
||||
}
|
||||
|
||||
function reportHasPeriodField(report: ReportDefinition): boolean {
|
||||
return report.fields.includes("period");
|
||||
}
|
||||
|
||||
function resolveDisplayCurrency(apiCode?: string | null): string {
|
||||
const trimmed = apiCode?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
const fallback = getCachedAdminCurrencies().find((row) => row.is_enabled && row.is_bettable)?.code;
|
||||
return fallback?.trim() || "NPR";
|
||||
}
|
||||
|
||||
const emptySearch: SearchState = {
|
||||
open: null,
|
||||
query: "",
|
||||
loading: false,
|
||||
draws: [],
|
||||
players: [],
|
||||
operators: [],
|
||||
};
|
||||
|
||||
function categoryTone(category: ReportCategory): string {
|
||||
switch (category) {
|
||||
case "wallet":
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
case "risk":
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
case "audit":
|
||||
return "border-slate-200 bg-slate-50 text-slate-700";
|
||||
default:
|
||||
return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
}
|
||||
}
|
||||
|
||||
function statTone(tone: StatCard["tone"]): string {
|
||||
switch (tone) {
|
||||
case "good":
|
||||
return "border-emerald-200 bg-emerald-50/70 text-emerald-900";
|
||||
case "warn":
|
||||
return "border-amber-200 bg-amber-50/70 text-amber-950";
|
||||
case "bad":
|
||||
return "border-red-200 bg-red-50/70 text-red-950";
|
||||
default:
|
||||
return "border-border/70 bg-card text-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function formatKind(kind: FilterKind, t: (key: string) => string): string {
|
||||
return t(`filters.${kind}`);
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function normalizeFilenamePart(value: string): string {
|
||||
return value.trim().replace(/[\\/:*?"<>|\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function formatExportInstant(iso: string | null | undefined): ExportCell {
|
||||
return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
|
||||
}
|
||||
|
||||
function buildDailyProfitRowsAndSummary(
|
||||
items: AdminReportDailyProfitRow[],
|
||||
total: number,
|
||||
t: (key: string) => string,
|
||||
pageScopedLabel: (statKey: string) => string,
|
||||
currencyCode: string,
|
||||
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "rows" | "summary"> {
|
||||
let totalBet = 0;
|
||||
let totalPayout = 0;
|
||||
let totalGross = 0;
|
||||
|
||||
const rows = items.map((item) => {
|
||||
totalBet += item.total_bet_minor;
|
||||
totalPayout += item.total_payout_minor;
|
||||
totalGross += item.approx_house_gross_minor;
|
||||
return {
|
||||
business_date: item.business_date,
|
||||
total_bet_minor: item.total_bet_minor,
|
||||
total_payout_minor: item.total_payout_minor,
|
||||
approx_house_gross_minor: item.approx_house_gross_minor,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(total) },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
|
||||
{
|
||||
label: pageScopedLabel("houseGross"),
|
||||
value: formatPlainMoney(totalGross, currencyCode),
|
||||
tone: totalGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayerTransferRowsAndSummary(
|
||||
items: AdminTransferOrderItem[],
|
||||
total: number,
|
||||
page: number,
|
||||
perPage: number,
|
||||
t: (key: string) => string,
|
||||
pageScopedLabel: (statKey: string) => string,
|
||||
): Pick<Extract<ReportResult, { key: "player_transfer" }>, "rows" | "summary" | "meta"> {
|
||||
let transferInCount = 0;
|
||||
let transferOutCount = 0;
|
||||
|
||||
const rows = items.map((item) => {
|
||||
if (item.direction === "in") {
|
||||
transferInCount += 1;
|
||||
} else if (item.direction === "out") {
|
||||
transferOutCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
transfer_no: item.transfer_no,
|
||||
player_id: item.player_id,
|
||||
username: item.username,
|
||||
nickname: item.nickname,
|
||||
direction: item.direction,
|
||||
currency_code: item.currency_code,
|
||||
amount: item.amount,
|
||||
status: item.status,
|
||||
external_ref_no: item.external_ref_no,
|
||||
fail_reason: item.fail_reason,
|
||||
created_at: formatExportInstant(item.created_at),
|
||||
finished_at: formatExportInstant(item.finished_at),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
meta: { total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
||||
{ label: pageScopedLabel("transferIn"), value: String(transferInCount), tone: "good" },
|
||||
{ label: pageScopedLabel("transferOut"), value: String(transferOutCount), tone: "warn" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlayDimensionRowsAndSummary(
|
||||
items: AdminReportPlayDimensionRow[],
|
||||
total: number,
|
||||
t: (key: string) => string,
|
||||
pageScopedLabel: (statKey: string) => string,
|
||||
currencyCode: string,
|
||||
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
|
||||
let totalBet = 0;
|
||||
let totalPayout = 0;
|
||||
|
||||
const rows = items.map((item) => {
|
||||
totalBet += item.total_bet_minor;
|
||||
totalPayout += item.total_payout_minor;
|
||||
return {
|
||||
play_code: item.play_code,
|
||||
dimension: item.dimension,
|
||||
total_bet_minor: item.total_bet_minor,
|
||||
total_payout_minor: item.total_payout_minor,
|
||||
approx_house_gross_minor: item.approx_house_gross_minor,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
|
||||
return {
|
||||
total: meta.total,
|
||||
page: meta.current_page,
|
||||
perPage: meta.per_page,
|
||||
lastPage: meta.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPlainMoney(value: number, currencyCode: string | null | undefined): string {
|
||||
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
||||
}
|
||||
|
||||
function signedProfitCell(amount: number, currencyCode: string | null | undefined): string {
|
||||
return cn("text-center tabular-nums", signedMoneyClass(amount, true));
|
||||
}
|
||||
|
||||
function formatUsagePercent(ratio: number | null | undefined): string {
|
||||
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
|
||||
}
|
||||
|
||||
function optionText(...parts: Array<string | number | null | undefined>): string {
|
||||
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
||||
}
|
||||
|
||||
function reportListParams(
|
||||
filters: ReportFilters,
|
||||
page: number,
|
||||
perPage: number,
|
||||
) {
|
||||
return {
|
||||
page,
|
||||
per_page: perPage,
|
||||
date_from: filters.dateFrom || undefined,
|
||||
date_to: filters.dateTo || undefined,
|
||||
player_id: filters.playerId ?? undefined,
|
||||
play_code: filters.play.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
async function resolveDraw(
|
||||
filters: ReportFilters,
|
||||
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
|
||||
): Promise<{ id: number; draw_no: string }> {
|
||||
if (filters.drawId != null && filters.drawId > 0) {
|
||||
const drawNo = filters.drawNo.trim();
|
||||
return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) };
|
||||
}
|
||||
|
||||
const drawNo = filters.drawNo.trim();
|
||||
if (!drawNo) {
|
||||
throw new LotteryApiBizError(messages.drawNoRequired, -1, null);
|
||||
}
|
||||
|
||||
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
||||
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
||||
if (!matched) {
|
||||
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
|
||||
}
|
||||
return { id: matched.id, draw_no: matched.draw_no };
|
||||
}
|
||||
|
||||
function drawRowsFromSummary(summary: AdminDrawFinanceSummaryData): ExportRow[] {
|
||||
return [
|
||||
{
|
||||
row_type: "summary",
|
||||
draw_id: summary.draw_id,
|
||||
draw_no: summary.draw_no,
|
||||
draw_status: summary.draw_status,
|
||||
currency_code: summary.currency_code,
|
||||
order_count: summary.order_count,
|
||||
ticket_item_count: summary.ticket_item_count,
|
||||
total_bet_minor: summary.total_bet_minor,
|
||||
total_win_payout_minor: summary.total_win_payout_minor,
|
||||
total_jackpot_win_minor: summary.total_jackpot_win_minor,
|
||||
total_payout_minor: summary.total_payout_minor,
|
||||
approx_house_gross_minor: summary.approx_house_gross_minor,
|
||||
},
|
||||
...summary.settlement_batches.map((batch) => ({
|
||||
row_type: "settlement_batch",
|
||||
draw_id: summary.draw_id,
|
||||
draw_no: summary.draw_no,
|
||||
settlement_batch_id: batch.id,
|
||||
settlement_status: batch.status,
|
||||
total_ticket_count: batch.total_ticket_count,
|
||||
total_win_count: batch.total_win_count,
|
||||
total_payout_amount: batch.total_payout_amount,
|
||||
total_jackpot_payout_amount: batch.total_jackpot_payout_amount,
|
||||
finished_at: formatExportInstant(batch.finished_at),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function resultRowCount(result: ReportResult | null): number {
|
||||
return result?.rows.length ?? 0;
|
||||
}
|
||||
|
||||
function defaultSummaryCards(
|
||||
reportKey: ReportKey,
|
||||
filters: ReportFilters,
|
||||
t: (key: string) => string,
|
||||
): StatCard[] {
|
||||
const periodLabel =
|
||||
filters.dateFrom && filters.dateTo
|
||||
? `${filters.dateFrom} ~ ${filters.dateTo}`
|
||||
: filters.dateFrom || filters.dateTo || t("preview.stats.notQueried");
|
||||
|
||||
switch (reportKey) {
|
||||
case "draw_profit":
|
||||
return [
|
||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||
];
|
||||
case "daily_profit":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.period"), value: periodLabel },
|
||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "player_win_loss":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.players"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "player_transfer":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.transferIn"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.transferOut"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "hot_number_risk":
|
||||
return [
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||
{ label: t("fields.number"), value: filters.number || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.logs"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "play_dimension":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.play"), value: filters.play || t("filterAll") },
|
||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "sold_out_number":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.currency"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
case "admin_audit":
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") },
|
||||
{ label: t("preview.stats.modules"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.operators"), value: t("preview.stats.notQueried") },
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") },
|
||||
{ label: t("preview.stats.exportRows"), value: "0" },
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
|
||||
const { t, i18n } = useTranslation(["reports", "common"]);
|
||||
export function ReportsConsole({ initialCategory = "profit" }: ReportsConsoleProps) {
|
||||
const { t } = useTranslation(["reports", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
|
||||
const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]);
|
||||
const permissionSlugs = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
|
||||
const canExportReports = adminHasAnyPermission(permissionSlugs, [PRD_REPORT_EXPORT]);
|
||||
|
||||
useAdminCurrencyCatalog();
|
||||
useAdminPlayTypeCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const filteredReports = useMemo(() => {
|
||||
const visible = REPORTS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny));
|
||||
return initialCategory ? visible.filter((report) => report.category === initialCategory) : visible;
|
||||
}, [initialCategory, permissionSlugs]);
|
||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(
|
||||
filteredReports[0]?.key ?? REPORTS[0].key,
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
const searchParams = useSearchParams();
|
||||
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
||||
|
||||
const visibleReports = useMemo(
|
||||
() => REPORT_DEFINITIONS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny)),
|
||||
[permissionSlugs],
|
||||
);
|
||||
|
||||
const categoriesWithReports = useMemo(
|
||||
() => REPORT_CATEGORY_ORDER.filter((cat) => visibleReports.some((report) => report.category === cat)),
|
||||
[visibleReports],
|
||||
);
|
||||
|
||||
const activeCategory = categoriesWithReports.includes(initialCategory)
|
||||
? initialCategory
|
||||
: (categoriesWithReports[0] ?? "profit");
|
||||
|
||||
const categoryReports = useMemo(
|
||||
() => visibleReports.filter((report) => report.category === activeCategory),
|
||||
[activeCategory, visibleReports],
|
||||
);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(categoryReports[0]?.key ?? "daily_profit");
|
||||
const [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
|
||||
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
|
||||
const [result, setResult] = useState<ReportResult | null>(null);
|
||||
@@ -630,128 +138,26 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
||||
const [search, setSearch] = useState<SearchState>(emptySearch);
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
const searchParams = useSearchParams();
|
||||
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
||||
|
||||
const selectedReport = filteredReports.find((report) => report.key === selectedKey) ?? filteredReports[0] ?? REPORTS[0];
|
||||
const selectedReport =
|
||||
categoryReports.find((report) => report.key === selectedKey) ?? categoryReports[0] ?? REPORT_DEFINITIONS[0];
|
||||
|
||||
const canQuerySelected = adminHasAnyPermission(permissionSlugs, selectedReport.requiredAny);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredReports.some((report) => report.key === selectedKey)) {
|
||||
setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key);
|
||||
if (categoryReports.length === 0) {
|
||||
return;
|
||||
}
|
||||
}, [filteredReports, selectedKey]);
|
||||
if (!categoryReports.some((report) => report.key === selectedKey)) {
|
||||
setSelectedKey(categoryReports[0].key);
|
||||
}
|
||||
}, [categoryReports, selectedKey]);
|
||||
|
||||
const pageScopedLabel = useCallback(
|
||||
(statKey: string) => t(`preview.stats.${statKey}`),
|
||||
[t],
|
||||
);
|
||||
|
||||
const previewColumns = useMemo<PreviewColumns>(() => {
|
||||
switch (selectedReport.key) {
|
||||
case "draw_profit":
|
||||
return {
|
||||
primary: t("preview.columns.drawProfit.primary"),
|
||||
secondary: t("preview.columns.drawProfit.secondary"),
|
||||
metricA: t("preview.columns.drawProfit.metricA"),
|
||||
metricB: t("preview.columns.drawProfit.metricB"),
|
||||
metricC: t("preview.columns.drawProfit.metricC"),
|
||||
status: t("preview.columns.drawProfit.status"),
|
||||
extra: t("preview.columns.drawProfit.extra"),
|
||||
time: t("preview.columns.drawProfit.time"),
|
||||
};
|
||||
case "daily_profit":
|
||||
return {
|
||||
primary: t("preview.columns.dailyProfit.primary"),
|
||||
secondary: t("preview.columns.dailyProfit.secondary"),
|
||||
metricA: t("preview.columns.dailyProfit.metricA"),
|
||||
metricB: t("preview.columns.dailyProfit.metricB"),
|
||||
metricC: t("preview.columns.dailyProfit.metricC"),
|
||||
status: t("preview.columns.dailyProfit.status"),
|
||||
extra: t("preview.columns.dailyProfit.extra"),
|
||||
time: t("preview.columns.dailyProfit.time"),
|
||||
};
|
||||
case "player_win_loss":
|
||||
return {
|
||||
primary: t("preview.columns.playerWinLoss.primary"),
|
||||
secondary: t("agentColumns.agent", { ns: "common" }),
|
||||
metricA: t("preview.columns.playerWinLoss.metricA"),
|
||||
metricB: t("preview.columns.playerWinLoss.metricB"),
|
||||
metricC: t("preview.columns.playerWinLoss.metricC"),
|
||||
status: t("preview.columns.playerWinLoss.status"),
|
||||
extra: t("preview.columns.playerWinLoss.extra"),
|
||||
time: t("preview.columns.playerWinLoss.time"),
|
||||
};
|
||||
case "player_transfer":
|
||||
return {
|
||||
primary: t("preview.columns.playerTransfer.primary"),
|
||||
secondary: t("preview.columns.playerTransfer.secondary"),
|
||||
metricA: t("preview.columns.playerTransfer.metricA"),
|
||||
metricB: t("preview.columns.playerTransfer.metricB"),
|
||||
metricC: t("preview.columns.playerTransfer.metricC"),
|
||||
status: t("preview.columns.playerTransfer.status"),
|
||||
extra: t("preview.columns.playerTransfer.extra"),
|
||||
time: t("preview.columns.playerTransfer.time"),
|
||||
};
|
||||
case "hot_number_risk":
|
||||
return {
|
||||
primary: t("preview.columns.hotNumberRisk.primary"),
|
||||
secondary: t("preview.columns.hotNumberRisk.secondary"),
|
||||
metricA: t("preview.columns.hotNumberRisk.metricA"),
|
||||
metricB: t("preview.columns.hotNumberRisk.metricB"),
|
||||
metricC: t("preview.columns.hotNumberRisk.metricC"),
|
||||
status: t("preview.columns.hotNumberRisk.status"),
|
||||
extra: t("preview.columns.hotNumberRisk.extra"),
|
||||
time: t("preview.columns.hotNumberRisk.time"),
|
||||
};
|
||||
case "play_dimension":
|
||||
return {
|
||||
primary: t("preview.columns.playDimension.primary"),
|
||||
secondary: t("preview.columns.playDimension.secondary"),
|
||||
metricA: t("preview.columns.playDimension.metricA"),
|
||||
metricB: t("preview.columns.playDimension.metricB"),
|
||||
metricC: t("preview.columns.playDimension.metricC"),
|
||||
status: t("preview.columns.playDimension.status"),
|
||||
extra: t("preview.columns.playDimension.extra"),
|
||||
time: t("preview.columns.playDimension.time"),
|
||||
};
|
||||
case "sold_out_number":
|
||||
return {
|
||||
primary: t("preview.columns.soldOut.primary"),
|
||||
secondary: t("preview.columns.soldOut.secondary"),
|
||||
metricA: t("preview.columns.soldOut.metricA"),
|
||||
metricB: t("preview.columns.soldOut.metricB"),
|
||||
metricC: t("preview.columns.soldOut.metricC"),
|
||||
status: t("preview.columns.soldOut.status"),
|
||||
extra: t("preview.columns.soldOut.extra"),
|
||||
time: t("preview.columns.soldOut.time"),
|
||||
};
|
||||
case "admin_audit":
|
||||
return {
|
||||
primary: t("preview.columns.adminAudit.primary"),
|
||||
secondary: t("preview.columns.adminAudit.secondary"),
|
||||
metricA: t("preview.columns.adminAudit.metricA"),
|
||||
metricB: t("preview.columns.adminAudit.metricB"),
|
||||
metricC: t("preview.columns.adminAudit.metricC"),
|
||||
status: t("preview.columns.adminAudit.status"),
|
||||
extra: t("preview.columns.adminAudit.extra"),
|
||||
time: t("preview.columns.adminAudit.time"),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
primary: t("preview.columns.primary"),
|
||||
secondary: t("preview.columns.secondary"),
|
||||
metricA: t("preview.columns.metricA"),
|
||||
metricB: t("preview.columns.metricB"),
|
||||
metricC: t("preview.columns.metricC"),
|
||||
status: t("preview.columns.status"),
|
||||
extra: t("preview.columns.extra"),
|
||||
time: t("preview.columns.time"),
|
||||
};
|
||||
}
|
||||
}, [selectedReport.key, t]);
|
||||
|
||||
const exportFileBase = useMemo(() => {
|
||||
const segments: string[] = [selectedReport.key];
|
||||
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
||||
@@ -795,7 +201,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}, [search.open, search.query, loadSearchOptions]);
|
||||
|
||||
const queryReport = useCallback(async () => {
|
||||
if (!canViewReports) {
|
||||
if (!canQuerySelected) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -816,8 +222,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
rows: drawRowsFromSummary(summary),
|
||||
meta: null,
|
||||
summary: [
|
||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(summary.total_bet_minor, summary.currency_code) },
|
||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(summary.total_payout_minor, summary.currency_code) },
|
||||
{
|
||||
label: t("preview.stats.houseGross"),
|
||||
value: formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code),
|
||||
@@ -829,12 +233,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
break;
|
||||
}
|
||||
case "daily_profit": {
|
||||
const payload = await getAdminReportDailyProfit(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const payload = await getAdminReportDailyProfit(reportListParams(filters, page, perPage));
|
||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||
setDisplayCurrency(currencyCode);
|
||||
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
||||
const next = buildDailyProfitRowsAndSummary(
|
||||
payload.items,
|
||||
payload.meta.total,
|
||||
t,
|
||||
pageScopedLabel,
|
||||
currencyCode,
|
||||
);
|
||||
setResult({
|
||||
key: "daily_profit",
|
||||
raw: payload.items,
|
||||
@@ -845,38 +253,28 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
break;
|
||||
}
|
||||
case "player_win_loss": {
|
||||
const payload = await getAdminReportPlayerWinLoss(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
|
||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||
setDisplayCurrency(currencyCode);
|
||||
const rows = payload.items.map((item) => ({
|
||||
player_id: item.player_id,
|
||||
username: item.username,
|
||||
total_bet_minor: item.total_bet_minor,
|
||||
total_payout_minor: item.total_payout_minor,
|
||||
net_win_loss_minor: item.net_win_loss_minor,
|
||||
}));
|
||||
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
|
||||
setResult({
|
||||
key: "player_win_loss",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
rows: payload.items.map((item) => ({
|
||||
player_id: item.player_id,
|
||||
username: item.username,
|
||||
total_bet_minor: item.total_bet_minor,
|
||||
total_payout_minor: item.total_payout_minor,
|
||||
net_win_loss_minor: item.net_win_loss_minor,
|
||||
})),
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{
|
||||
label: pageScopedLabel("houseGross"),
|
||||
value: formatPlainMoney(
|
||||
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
||||
currencyCode,
|
||||
),
|
||||
tone: (() => {
|
||||
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
|
||||
return houseGross >= 0 ? "good" : "bad";
|
||||
})(),
|
||||
value: formatPlainMoney(houseGross, currencyCode),
|
||||
tone: houseGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
{ label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
@@ -897,7 +295,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
payload.page,
|
||||
payload.per_page,
|
||||
t,
|
||||
pageScopedLabel,
|
||||
);
|
||||
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
|
||||
setResult({
|
||||
@@ -920,45 +317,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
});
|
||||
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
||||
setDisplayCurrency(resolveDisplayCurrency(detail.currency_code));
|
||||
const rows: ExportRow[] = [
|
||||
{
|
||||
row_type: "risk_pool",
|
||||
draw_id: detail.draw_id,
|
||||
draw_no: detail.draw_no,
|
||||
number: detail.pool.normalized_number,
|
||||
total_cap_amount: detail.pool.total_cap_amount,
|
||||
locked_amount: detail.pool.locked_amount,
|
||||
remaining_amount: detail.pool.remaining_amount,
|
||||
sold_out_status: detail.pool.sold_out_status,
|
||||
is_sold_out: detail.pool.is_sold_out ? 1 : 0,
|
||||
usage_ratio: detail.pool.usage_ratio,
|
||||
version: detail.pool.version,
|
||||
},
|
||||
...detail.logs.items.map((item) => ({
|
||||
row_type: "lock_log",
|
||||
draw_id: detail.draw_id,
|
||||
draw_no: detail.draw_no,
|
||||
number: detail.pool.normalized_number,
|
||||
log_id: item.id,
|
||||
action_type: item.action_type,
|
||||
amount: item.amount,
|
||||
source_reason: item.source_reason,
|
||||
ticket_item_id: item.ticket_item_id,
|
||||
ticket_no: item.ticket_no,
|
||||
play_code: item.play_code,
|
||||
player_id: item.player_id,
|
||||
created_at: formatExportInstant(item.created_at),
|
||||
})),
|
||||
];
|
||||
setResult({
|
||||
key: "hot_number_risk",
|
||||
raw: detail,
|
||||
rows,
|
||||
rows: [],
|
||||
meta: metaFromList(detail.logs.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
|
||||
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
|
||||
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
||||
{
|
||||
label: t("preview.stats.usage"),
|
||||
value: formatUsagePercent(detail.pool.usage_ratio),
|
||||
tone: detail.pool.is_sold_out ? "bad" : "warn",
|
||||
},
|
||||
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
||||
],
|
||||
});
|
||||
@@ -970,42 +339,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
drawNoNotFound: (drawNo) =>
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
||||
const payload = await getAdminRiskPools(draw.id, {
|
||||
page,
|
||||
per_page: perPage,
|
||||
sold_out_only: true,
|
||||
sort: "number_asc",
|
||||
});
|
||||
setDisplayCurrency(resolveDisplayCurrency(payload.currency_code));
|
||||
const rows = payload.items.map((item) => ({
|
||||
draw_id: payload.draw_id,
|
||||
draw_no: payload.draw_no,
|
||||
currency_code: payload.currency_code,
|
||||
normalized_number: item.normalized_number,
|
||||
total_cap_amount: item.total_cap_amount,
|
||||
locked_amount: item.locked_amount,
|
||||
remaining_amount: item.remaining_amount,
|
||||
sold_out_status: item.sold_out_status,
|
||||
is_sold_out: item.is_sold_out ? 1 : 0,
|
||||
usage_ratio: item.usage_ratio,
|
||||
version: item.version,
|
||||
}));
|
||||
setResult({
|
||||
key: "sold_out_number",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
rows: payload.items.map((item) => ({ normalized_number: item.normalized_number })),
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total), tone: payload.meta.total > 0 ? "bad" : "good" },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{
|
||||
label: t("preview.stats.records"),
|
||||
value: String(payload.meta.total),
|
||||
tone: payload.meta.total > 0 ? "bad" : "good",
|
||||
},
|
||||
{ label: t("preview.stats.drawNo"), value: payload.draw_no },
|
||||
{ label: t("preview.stats.currency"), value: payload.currency_code || "-" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "play_dimension": {
|
||||
const payload = await getAdminReportPlayDimension(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const payload = await getAdminReportPlayDimension(reportListParams(filters, page, perPage));
|
||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||
setDisplayCurrency(currencyCode);
|
||||
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
||||
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, currencyCode);
|
||||
setResult({
|
||||
key: "play_dimension",
|
||||
raw: payload.items,
|
||||
@@ -1025,28 +386,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
start_date: filters.dateFrom || undefined,
|
||||
end_date: filters.dateTo || undefined,
|
||||
});
|
||||
const rows = payload.items.map((item) => ({
|
||||
id: item.id,
|
||||
operator_type: item.operator_type,
|
||||
operator_id: item.operator_id,
|
||||
module_code: item.module_code,
|
||||
action_code: item.action_code,
|
||||
target_type: item.target_type,
|
||||
target_id: item.target_id,
|
||||
ip: item.ip,
|
||||
user_agent: item.user_agent,
|
||||
created_at: formatExportInstant(item.created_at),
|
||||
}));
|
||||
setResult({
|
||||
key: "admin_audit",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
rows: payload.items.map((item) => ({ id: item.id })),
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.modules"), value: String(new Set(payload.items.map((item) => item.module_code)).size) },
|
||||
{ label: t("preview.stats.operators"), value: String(new Set(payload.items.map((item) => item.operator_id)).size) },
|
||||
{
|
||||
label: t("preview.stats.operators"),
|
||||
value: String(new Set(payload.items.map((item) => item.operator_id)).size),
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
@@ -1061,14 +411,12 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewReports, filters, page, perPage, selectedReport]);
|
||||
}, [canQuerySelected, filters, page, perPage, pageScopedLabel, selectedReport.key, t, tRef]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
});
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
}, [selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1076,25 +424,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
...prev,
|
||||
drawNo: drawNoFromUrl || prev.drawNo,
|
||||
}));
|
||||
if (drawNoFromUrl && filteredReports.some((report) => report.key === "draw_profit")) {
|
||||
if (drawNoFromUrl && visibleReports.some((report) => report.key === "draw_profit")) {
|
||||
setSelectedKey("draw_profit");
|
||||
}
|
||||
}, [drawNoFromUrl, filteredReports]);
|
||||
}, [drawNoFromUrl, visibleReports]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (result && result.key === selectedReport.key && selectedReport.connected) {
|
||||
queueMicrotask(() => {
|
||||
void queryReport();
|
||||
});
|
||||
if (!result || result.key !== selectedReport.key || !selectedReport.connected) {
|
||||
return;
|
||||
}
|
||||
void queryReport();
|
||||
}, [page, perPage]);
|
||||
|
||||
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
|
||||
@@ -1152,42 +491,48 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const open = search.open === kind;
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label>
|
||||
<div className="min-w-[12rem] flex-1">
|
||||
<Label className="sr-only" htmlFor={`report-${kind}`}>
|
||||
{t(`fields.${labelKey}`)}
|
||||
</Label>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setSearch((prev) =>
|
||||
nextOpen
|
||||
? {
|
||||
...prev,
|
||||
open: kind,
|
||||
query: value,
|
||||
}
|
||||
: emptySearch,
|
||||
nextOpen ? { ...prev, open: kind, query: value } : emptySearch,
|
||||
);
|
||||
}}
|
||||
modal={false}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`report-${kind}`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (kind === "draw") {
|
||||
setFilters((prev) => ({ ...prev, drawNo: next, drawId: null }));
|
||||
} else if (kind === "player") {
|
||||
setFilters((prev) => ({ ...prev, player: next, playerId: null }));
|
||||
} else {
|
||||
setFilters((prev) => ({ ...prev, operator: next, operatorId: null }));
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
id={`report-${kind}`}
|
||||
className="h-8"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (kind === "draw") {
|
||||
setFilters((prev) => ({ ...prev, drawNo: next, drawId: null }));
|
||||
} else if (kind === "player") {
|
||||
setFilters((prev) => ({ ...prev, player: next, playerId: null }));
|
||||
} else {
|
||||
setFilters((prev) => ({ ...prev, operator: next, operatorId: null }));
|
||||
}
|
||||
}}
|
||||
placeholder={t(`placeholders.${labelKey}`)}
|
||||
/>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2"
|
||||
aria-label={t("searchPicker.open")}
|
||||
/>
|
||||
}
|
||||
}}
|
||||
placeholder={t(`placeholders.${labelKey}`)}
|
||||
/>
|
||||
<PopoverTrigger render={<Button type="button" variant="outline" className="shrink-0" aria-label={t("searchPicker.open")} />}>
|
||||
<Search data-icon="inline-start" />
|
||||
{t("searchPicker.select")}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent
|
||||
@@ -1202,65 +547,70 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-2 max-h-64 overflow-auto">
|
||||
{search.loading ? (
|
||||
<AdminLoadingInline className="py-2" />
|
||||
) : null}
|
||||
{!search.loading && kind === "draw" ? (
|
||||
search.draws.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({ ...prev, drawNo: item.draw_no, drawId: item.id }));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{item.draw_no}</span>
|
||||
<DrawStatusBadge status={item.status} label={drawStatusLabel(item.status, t)} />
|
||||
</button>
|
||||
))
|
||||
) : null}
|
||||
{!search.loading && kind === "player" ? (
|
||||
search.players.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
player: optionText(item.username, item.site_player_id, `ID ${item.id}`),
|
||||
playerId: item.id,
|
||||
}));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname, item.site_player_id)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
||||
</button>
|
||||
))
|
||||
) : null}
|
||||
{!search.loading && kind === "operator" ? (
|
||||
search.operators.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
operator: optionText(item.username, item.nickname, `ID ${item.id}`),
|
||||
operatorId: item.id,
|
||||
}));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
||||
</button>
|
||||
))
|
||||
) : null}
|
||||
{search.loading ? <AdminLoadingInline className="py-2" /> : null}
|
||||
{!search.loading && kind === "draw"
|
||||
? search.draws.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({ ...prev, drawNo: item.draw_no, drawId: item.id }));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{item.draw_no}</span>
|
||||
<DrawStatusBadge
|
||||
status={item.status}
|
||||
label={drawStatusLabel(item.status, t)}
|
||||
/>
|
||||
</button>
|
||||
))
|
||||
: null}
|
||||
{!search.loading && kind === "player"
|
||||
? search.players.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
player: optionText(item.username, item.site_player_id, `ID ${item.id}`),
|
||||
playerId: item.id,
|
||||
}));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{optionText(item.username, item.nickname, item.site_player_id)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
||||
</button>
|
||||
))
|
||||
: null}
|
||||
{!search.loading && kind === "operator"
|
||||
? search.operators.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
operator: optionText(item.username, item.nickname, `ID ${item.id}`),
|
||||
operatorId: item.id,
|
||||
}));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{optionText(item.username, item.nickname)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
||||
</button>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -1294,15 +644,21 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}
|
||||
if (field === "play") {
|
||||
return (
|
||||
<div key={field} className="grid gap-1.5">
|
||||
<Label htmlFor="report-play">{t("fields.play")}</Label>
|
||||
<div key={field} className="min-w-[10rem] flex-1">
|
||||
<Label className="sr-only" htmlFor="report-play">
|
||||
{t("fields.play")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={filters.play || "__none__"}
|
||||
onValueChange={(value) => updateFilter("play", value === "__none__" ? "" : String(value))}
|
||||
>
|
||||
<SelectTrigger id="report-play" className="h-8 w-full">
|
||||
<SelectValue>{filters.play ? playOptions.find((item) => item.code === filters.play)?.label ?? filters.play : t("placeholders.play")}</SelectValue>
|
||||
<SelectValue>
|
||||
{filters.play
|
||||
? (playOptions.find((item) => item.code === filters.play)?.label ?? filters.play)
|
||||
: t("filterAll")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value="__none__">{t("filterAll")}</SelectItem>
|
||||
@@ -1316,12 +672,14 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field} className="grid gap-1.5">
|
||||
<Label htmlFor={`report-${field}`}>{t(`fields.${field}`)}</Label>
|
||||
<div key={field} className="min-w-[8rem] flex-1">
|
||||
<Label className="sr-only" htmlFor={`report-${field}`}>
|
||||
{t(`fields.${field}`)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`report-${field}`}
|
||||
className="h-8"
|
||||
value={filters.number}
|
||||
onChange={(e) => updateFilter("number", e.target.value)}
|
||||
placeholder={t(`placeholders.${field}`)}
|
||||
@@ -1330,321 +688,141 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
);
|
||||
};
|
||||
|
||||
const renderTable = () => {
|
||||
if (!selectedReport.connected) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("backendPending")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-destructive">
|
||||
{error}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (!result || result.rows.length === 0) {
|
||||
return (
|
||||
<AdminTableNoResourceRow colSpan={8} />
|
||||
);
|
||||
}
|
||||
const stats = headlineStats(result, selectedReport.key);
|
||||
const shortcutHref = CATEGORY_SHORTCUT_HREF[activeCategory];
|
||||
|
||||
if (result.key === "draw_profit") {
|
||||
const summary = result.raw;
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{summary.draw_no}</TableCell>
|
||||
<TableCell>
|
||||
<DrawStatusBadge status={summary.draw_status} label={drawStatusLabel(summary.draw_status, t)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{summary.order_count}</TableCell>
|
||||
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
|
||||
<TableCell className={signedProfitCell(summary.approx_house_gross_minor, summary.currency_code)}>
|
||||
{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell>{summary.settlement_batches.length}</TableCell>
|
||||
</TableRow>
|
||||
{summary.settlement_batches.map((batch) => (
|
||||
<TableRow key={batch.id} className="bg-muted/15">
|
||||
<TableCell>#{batch.id}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={batch.status}>{batch.status}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{batch.total_ticket_count}</TableCell>
|
||||
<TableCell className="text-center">{batch.total_win_count}</TableCell>
|
||||
<TableCell className="text-center">-</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell>
|
||||
<TableCell>{formatTs(batch.finished_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.key === "player_transfer") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-mono text-xs">{item.transfer_no}</TableCell>
|
||||
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
|
||||
<TableCell>{item.direction}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={item.status}>{item.status}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.currency_code} {item.amount}</TableCell>
|
||||
<TableCell>{item.external_ref_no || "-"}</TableCell>
|
||||
<TableCell>{item.fail_reason || "-"}</TableCell>
|
||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "hot_number_risk") {
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
|
||||
<TableCell>{result.raw.draw_no}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
|
||||
<TableCell>v{result.raw.pool.version}</TableCell>
|
||||
</TableRow>
|
||||
{result.raw.logs.items.map((item) => (
|
||||
<TableRow key={item.id} className="bg-muted/15">
|
||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
||||
<TableCell>{item.action_type}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell>{playCodeLabel(item.play_code)}</TableCell>
|
||||
<TableCell>{item.ticket_no || "-"}</TableCell>
|
||||
<TableCell>{item.player_id || "-"}</TableCell>
|
||||
<TableCell>{item.source_reason || "-"}</TableCell>
|
||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.key === "sold_out_number") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.normalized_number}>
|
||||
<TableCell className="font-medium">{item.normalized_number}</TableCell>
|
||||
<TableCell>{filters.drawNo}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_cap_amount, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, displayCurrency)}</TableCell>
|
||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
|
||||
<TableCell>v{item.version}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "daily_profit") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.business_date}>
|
||||
<TableCell className="font-medium">{item.business_date}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "player_win_loss") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.player_id}>
|
||||
<TableCell className="font-medium">{item.username}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{adminAgentDisplayLabel(item)}
|
||||
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.net_win_loss_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.net_win_loss_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "play_dimension") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={`${item.play_code}-${item.dimension}`}>
|
||||
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
|
||||
<TableCell>{item.dimension}D</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "admin_audit") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
||||
<TableCell>{item.operator_type}</TableCell>
|
||||
<TableCell>{item.operator_id}</TableCell>
|
||||
<TableCell>{item.module_code}</TableCell>
|
||||
<TableCell>{item.action_code}</TableCell>
|
||||
<TableCell>{item.target_type || "-"}</TableCell>
|
||||
<TableCell>{item.ip || "-"}</TableCell>
|
||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
if (visibleReports.length === 0) {
|
||||
return <AdminNoResourceState message={t("empty")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
{filteredReports.length === 0 ? (
|
||||
<AdminNoResourceState message={t("empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header pb-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredReports.map((report) => {
|
||||
const Icon = report.icon;
|
||||
const active = report.key === selectedReport.key;
|
||||
return (
|
||||
<button
|
||||
key={report.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedKey(report.key)}
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition",
|
||||
active
|
||||
? "border-primary bg-primary/[0.06] text-primary shadow-sm"
|
||||
: "border-border/80 bg-card text-muted-foreground hover:border-primary/35 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
<span className="truncate">{t(`items.${report.key}.title`)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canViewReports || !selectedReport.connected || loading}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
void queryReport();
|
||||
}}
|
||||
>
|
||||
<Database data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid min-w-0 gap-2 md:grid-cols-4">
|
||||
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
|
||||
<div key={item.label} className={cn("min-w-0 rounded-md border px-3 py-2.5", statTone(item.tone))}>
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
<AdminMoneyDisplay as="div" value={item.value} size="md" className="mt-0.5">
|
||||
{item.value}
|
||||
</AdminMoneyDisplay>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<AdminSubnav aria-label={t("categories.all")}>
|
||||
{categoriesWithReports.map((category) => (
|
||||
<AdminSubnavLink
|
||||
key={category}
|
||||
href={`/admin/reports/${category}`}
|
||||
active={activeCategory === category}
|
||||
>
|
||||
{t(`categories.${category}`)}
|
||||
</AdminSubnavLink>
|
||||
))}
|
||||
</AdminSubnav>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={selectedReport.key}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setSelectedKey(value as ReportKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-[12rem] max-w-md">
|
||||
<SelectValue>{t(`items.${selectedReport.key}.title`)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryReports.map((report) => (
|
||||
<SelectItem key={report.key} value={report.key}>
|
||||
{t(`items.${report.key}.title`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{shortcutHref ? (
|
||||
<Link
|
||||
href={shortcutHref}
|
||||
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{t(`shortcuts.${activeCategory}`)}
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
) : null}
|
||||
{activeCategory === "profit" ? (
|
||||
<span className="text-xs text-muted-foreground">{t("profitScopeHint")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:ml-auto">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!canQuerySelected || !selectedReport.connected || loading}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
void queryReport();
|
||||
}}
|
||||
>
|
||||
<Database className="size-3.5" data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => void exportReport("csv")}
|
||||
>
|
||||
<FileDown data-icon="inline-start" />
|
||||
<FileDown className="size-3.5" data-icon="inline-start" />
|
||||
{t("formats.csv")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => void exportReport("excel")}
|
||||
>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
<FileSpreadsheet className="size-3.5" data-icon="inline-start" />
|
||||
{t("formats.excel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<Table id="reports-preview-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{previewColumns.primary}</TableHead>
|
||||
<TableHead>{previewColumns.secondary}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
||||
<TableHead>{previewColumns.status}</TableHead>
|
||||
<TableHead>{previewColumns.extra}</TableHead>
|
||||
<TableHead>{previewColumns.time}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderTable()}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result?.meta ? (
|
||||
{stats.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cn(
|
||||
"inline-flex items-baseline gap-2 rounded-md border px-3 py-1.5 text-sm",
|
||||
item.tone === "good"
|
||||
? "border-emerald-200/80 bg-emerald-50/50"
|
||||
: item.tone === "bad"
|
||||
? "border-red-200/80 bg-red-50/50"
|
||||
: item.tone === "warn"
|
||||
? "border-amber-200/80 bg-amber-50/50"
|
||||
: "border-border/60 bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
<span className="font-semibold tabular-nums">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<ReportPreviewTable
|
||||
report={selectedReport}
|
||||
result={result}
|
||||
loading={loading}
|
||||
error={error}
|
||||
drawNoLabel={filters.drawNo}
|
||||
displayCurrency={displayCurrency}
|
||||
/>
|
||||
{result?.meta ? (
|
||||
<div className="border-t border-border/60 px-3 py-2">
|
||||
<AdminListPaginationFooter
|
||||
selectId="reports-preview-per-page"
|
||||
total={result.meta.total}
|
||||
@@ -1658,11 +836,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
141
src/modules/reports/reports-definitions.ts
Normal file
141
src/modules/reports/reports-definitions.ts
Normal 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",
|
||||
};
|
||||
358
src/modules/reports/reports-utils.ts
Normal file
358
src/modules/reports/reports-utils.ts
Normal 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);
|
||||
}
|
||||
@@ -19,11 +19,7 @@ export function RulesOddsConfigScreen() {
|
||||
return (
|
||||
<RulesPageShell>
|
||||
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
||||
<ConfigDocPage
|
||||
title={t("nav.rulesOddsTitle")}
|
||||
description={t("nav.rulesOddsDescriptionShort")}
|
||||
contentClassName="pt-2"
|
||||
>
|
||||
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="pt-2">
|
||||
<OddsConfigDocScreen embedded mergedLayout workspace={workspace} />
|
||||
</ConfigDocPage>
|
||||
</AdminPermissionGate>
|
||||
|
||||
61
src/modules/settings/components/settings-toggle-row.tsx
Normal file
61
src/modules/settings/components/settings-toggle-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,6 @@ import type { AdminCurrencyRow } from "@/types/api/admin-currency";
|
||||
type CurrencyFormState = {
|
||||
code: string;
|
||||
name: string;
|
||||
decimal_places: string;
|
||||
is_enabled: boolean;
|
||||
is_bettable: boolean;
|
||||
};
|
||||
@@ -54,7 +53,6 @@ type CurrencyFormState = {
|
||||
const EMPTY_FORM: CurrencyFormState = {
|
||||
code: "",
|
||||
name: "",
|
||||
decimal_places: "2",
|
||||
is_enabled: true,
|
||||
is_bettable: false,
|
||||
};
|
||||
@@ -63,7 +61,6 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
|
||||
return {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
decimal_places: String(row.decimal_places),
|
||||
is_enabled: row.is_enabled,
|
||||
is_bettable: row.is_enabled && row.is_bettable,
|
||||
};
|
||||
@@ -138,7 +135,6 @@ export function CurrencySettingsPanel() {
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
decimal_places: Number.parseInt(form.decimal_places || "0", 10),
|
||||
is_enabled: form.is_enabled,
|
||||
is_bettable: form.is_enabled && form.is_bettable,
|
||||
};
|
||||
@@ -150,11 +146,6 @@ export function CurrencySettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(payload.decimal_places) || payload.decimal_places < 0) {
|
||||
toast.error(t("currencies.form.decimalInvalid", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
@@ -229,7 +220,6 @@ export function CurrencySettingsPanel() {
|
||||
<TableRow>
|
||||
<TableHead className="whitespace-nowrap">{t("currencies.table.code", { ns: "config" })}</TableHead>
|
||||
<TableHead>{t("currencies.table.name", { ns: "config" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("currencies.table.decimals", { ns: "config" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("currencies.table.actions", { ns: "config" })}</TableHead>
|
||||
@@ -238,18 +228,17 @@ export function CurrencySettingsPanel() {
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
||||
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
|
||||
{t("currencies.loading", { ns: "config" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={6} cellClassName="text-center" />
|
||||
<AdminTableNoResourceRow colSpan={5} cellClassName="text-center" />
|
||||
) : (
|
||||
items.map((row) => (
|
||||
<TableRow key={row.code}>
|
||||
<TableCell className="font-mono">{row.code}</TableCell>
|
||||
<TableCell>{row.name}</TableCell>
|
||||
<TableCell>{row.decimal_places}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
|
||||
{row.is_enabled
|
||||
@@ -329,21 +318,6 @@ export function CurrencySettingsPanel() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency-decimals">{t("currencies.form.decimals", { ns: "config" })}</Label>
|
||||
<Input
|
||||
id="currency-decimals"
|
||||
type="number"
|
||||
min="0"
|
||||
max="12"
|
||||
step="1"
|
||||
value={form.decimal_places}
|
||||
placeholder={t("currencies.form.decimalsPlaceholder", { ns: "config" })}
|
||||
onChange={(e) => updateForm("decimal_places", e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t("currencies.form.enabled", { ns: "config" })}</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const settingsModuleMeta = {
|
||||
segment: "settings",
|
||||
title: "系统设置",
|
||||
description: "管理影响钱包划转与跨模块行为的全局运行参数。",
|
||||
description: "日常运营开关与限额在此调整。",
|
||||
} as const;
|
||||
|
||||
@@ -6,43 +6,28 @@ import { useTranslation } from "react-i18next";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { SettingsToggleRow } from "@/modules/settings/components/settings-toggle-row";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
interface DrawDraft {
|
||||
defaultCurrency: string;
|
||||
drawIntervalMinutes: string;
|
||||
drawBettingWindowSeconds: string;
|
||||
drawCloseBeforeDrawSeconds: string;
|
||||
drawBufferDrawsAhead: string;
|
||||
requireManualReview: boolean;
|
||||
cooldownMinutes: string;
|
||||
}
|
||||
|
||||
const INITIAL: DrawDraft = {
|
||||
defaultCurrency: "NPR",
|
||||
drawIntervalMinutes: "5",
|
||||
drawBettingWindowSeconds: "270",
|
||||
drawCloseBeforeDrawSeconds: "30",
|
||||
drawBufferDrawsAhead: "8",
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
};
|
||||
|
||||
function fromKv(kv: Record<string, unknown>): DrawDraft {
|
||||
return {
|
||||
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
||||
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
||||
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
||||
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
||||
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||
};
|
||||
@@ -50,43 +35,16 @@ function fromKv(kv: Record<string, unknown>): DrawDraft {
|
||||
|
||||
function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
|
||||
const items: AdminSettingBatchItem[] = [];
|
||||
const push = (key: string, value: unknown, changed: boolean) => {
|
||||
if (changed) {
|
||||
items.push({ key, value });
|
||||
}
|
||||
};
|
||||
|
||||
push(
|
||||
DRAW_KEYS.DEFAULT_CURRENCY,
|
||||
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
||||
draft.defaultCurrency !== saved.defaultCurrency,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
||||
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
||||
draft.drawIntervalMinutes !== saved.drawIntervalMinutes,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
||||
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
||||
draft.drawBettingWindowSeconds !== saved.drawBettingWindowSeconds,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
||||
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
||||
draft.drawCloseBeforeDrawSeconds !== saved.drawCloseBeforeDrawSeconds,
|
||||
);
|
||||
push(
|
||||
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
||||
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
||||
draft.drawBufferDrawsAhead !== saved.drawBufferDrawsAhead,
|
||||
);
|
||||
push(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview, draft.requireManualReview !== saved.requireManualReview);
|
||||
push(
|
||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||
draft.cooldownMinutes !== saved.cooldownMinutes,
|
||||
);
|
||||
if (draft.requireManualReview !== saved.requireManualReview) {
|
||||
items.push({ key: DRAW_KEYS.REQUIRE_MANUAL_REVIEW, value: draft.requireManualReview });
|
||||
}
|
||||
if (draft.cooldownMinutes !== saved.cooldownMinutes) {
|
||||
items.push({
|
||||
key: DRAW_KEYS.COOLDOWN_MINUTES,
|
||||
value: Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -114,114 +72,34 @@ export function DrawSettingsPanel() {
|
||||
description={t("system.sections.drawDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30">
|
||||
<Label className="font-medium cursor-pointer" onClick={() => updateField("requireManualReview", !draft.requireManualReview)}>{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.requireManualReview}
|
||||
disabled={!canManage || loading || saving}
|
||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("requireManualReview", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm">
|
||||
<SettingsToggleRow
|
||||
label={t("system.fields.manualReview", { ns: "config" })}
|
||||
hint={t("system.hints.manualReview", { ns: "config" })}
|
||||
checked={draft.requireManualReview}
|
||||
disabled={!canManage || loading || saving}
|
||||
onCheckedChange={(value) => updateField("requireManualReview", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-currency" className="text-muted-foreground">
|
||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="default-currency"
|
||||
value={draft.defaultCurrency}
|
||||
placeholder={t("system.placeholders.defaultCurrency", { ns: "config" })}
|
||||
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
|
||||
disabled={!canManage || loading || saving}
|
||||
maxLength={16}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draw-interval-minutes" className="text-muted-foreground">
|
||||
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-interval-minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
step="1"
|
||||
value={draft.drawIntervalMinutes}
|
||||
placeholder={t("system.placeholders.drawIntervalMinutes", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
|
||||
disabled={!canManage || loading || saving}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draw-betting-window-seconds" className="text-muted-foreground">
|
||||
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-betting-window-seconds"
|
||||
type="number"
|
||||
min="10"
|
||||
step="1"
|
||||
value={draft.drawBettingWindowSeconds}
|
||||
placeholder={t("system.placeholders.drawBettingWindowSeconds", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
|
||||
disabled={!canManage || loading || saving}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draw-close-before-seconds" className="text-muted-foreground">
|
||||
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-close-before-seconds"
|
||||
type="number"
|
||||
min="5"
|
||||
step="1"
|
||||
value={draft.drawCloseBeforeDrawSeconds}
|
||||
placeholder={t("system.placeholders.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||
disabled={!canManage || loading || saving}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draw-buffer-ahead" className="text-muted-foreground">
|
||||
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-buffer-ahead"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={draft.drawBufferDrawsAhead}
|
||||
placeholder={t("system.placeholders.drawBufferDrawsAhead", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
|
||||
disabled={!canManage || loading || saving}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cooldown-minutes" className="text-muted-foreground">
|
||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="cooldown-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
|
||||
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
||||
disabled={!canManage || loading || saving}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-sm space-y-2">
|
||||
<Label htmlFor="cooldown-minutes" className="text-muted-foreground">
|
||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("system.hints.cooldownMinutes", { ns: "config" })}
|
||||
</p>
|
||||
<Input
|
||||
id="cooldown-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
|
||||
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
||||
disabled={!canManage || loading || saving}
|
||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
@@ -248,4 +126,4 @@ export function DrawSettingsPanel() {
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||
import { SettingsToggleRow } from "@/modules/settings/components/settings-toggle-row";
|
||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
|
||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_PAYOUT_MANAGE } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
@@ -82,49 +81,35 @@ export function SettlementSettingsPanel() {
|
||||
description={t("system.sections.settlementDescription", { ns: "config" })}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
|
||||
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoSettlement", !draft.autoSettlement)}>{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoSettlement}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoSettlement", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
|
||||
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoApprove", !draft.autoApprove)}>{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoApprove}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoApprove", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
|
||||
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoPayout", !draft.autoPayout)}>{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
||||
<Switch
|
||||
checked={draft.autoPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("autoPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30">
|
||||
<div className="min-w-0 space-y-1 pr-4 cursor-pointer" onClick={() => updateField("applyRebateToPayout", !draft.applyRebateToPayout)}>
|
||||
<Label className="font-medium cursor-pointer">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
||||
<p className="text-[11px] text-muted-foreground/80">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.applyRebateToPayout}
|
||||
disabled={loading || saving}
|
||||
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm divide-y divide-border/50">
|
||||
<SettingsToggleRow
|
||||
label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||
hint={t("system.hints.autoSettlement", { ns: "config" })}
|
||||
checked={draft.autoSettlement}
|
||||
disabled={loading || saving}
|
||||
onCheckedChange={(value) => updateField("autoSettlement", value)}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
label={t("system.fields.autoApprove", { ns: "config" })}
|
||||
hint={t("system.hints.autoApprove", { ns: "config" })}
|
||||
checked={draft.autoApprove}
|
||||
disabled={loading || saving}
|
||||
onCheckedChange={(value) => updateField("autoApprove", value)}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
label={t("system.fields.autoPayout", { ns: "config" })}
|
||||
hint={t("system.hints.autoPayout", { ns: "config" })}
|
||||
checked={draft.autoPayout}
|
||||
disabled={loading || saving}
|
||||
onCheckedChange={(value) => updateField("autoPayout", value)}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||
hint={t("system.hints.applyRebateToPayout", { ns: "config" })}
|
||||
checked={draft.applyRebateToPayout}
|
||||
disabled={loading || saving}
|
||||
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSectionActions
|
||||
@@ -137,8 +122,8 @@ export function SettlementSettingsPanel() {
|
||||
description: t("system.confirmSaveSettlementDescription", { ns: "config" }),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => {
|
||||
void save();
|
||||
},
|
||||
void save();
|
||||
},
|
||||
})
|
||||
}
|
||||
onDiscard={discard}
|
||||
@@ -151,4 +136,4 @@ export function SettlementSettingsPanel() {
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,6 @@ export const FRONTEND_GROUP = "frontend";
|
||||
export const WALLET_GROUP = "wallet";
|
||||
|
||||
export const DRAW_KEYS = {
|
||||
DEFAULT_CURRENCY: "currency.default_code",
|
||||
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
||||
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
||||
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||
} as const;
|
||||
|
||||
@@ -13,6 +13,8 @@ function SystemSettingsContent() {
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{t("system.pageTitle")}</h1>
|
||||
|
||||
<DrawSettingsPanel />
|
||||
<SettlementSettingsPanel />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -37,6 +37,7 @@ type AgentBillDetailProps = {
|
||||
canManage?: boolean;
|
||||
canFinanceAdjustments?: boolean;
|
||||
boundAgent?: { id: number } | null;
|
||||
focusSection?: "confirm" | "payment" | null;
|
||||
onUpdated?: () => void;
|
||||
};
|
||||
|
||||
@@ -46,6 +47,7 @@ export function AgentBillDetail({
|
||||
canManage = true,
|
||||
canFinanceAdjustments = false,
|
||||
boundAgent = null,
|
||||
focusSection = null,
|
||||
onUpdated,
|
||||
}: AgentBillDetailProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
|
||||
@@ -62,6 +64,9 @@ export function AgentBillDetail({
|
||||
const [adjustReason, setAdjustReason] = useState("");
|
||||
const [badDebtReason, setBadDebtReason] = useState("");
|
||||
const [rebateDetailsOpen, setRebateDetailsOpen] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const confirmRef = useRef<HTMLDivElement>(null);
|
||||
const paymentRef = useRef<HTMLDivElement>(null);
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -89,6 +94,21 @@ export function AgentBillDetail({
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !bill) {
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
focusSection === "payment"
|
||||
? paymentRef.current
|
||||
: focusSection === "confirm"
|
||||
? confirmRef.current
|
||||
: null;
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, [bill, focusSection, loading]);
|
||||
|
||||
if (loading && !bill) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
@@ -378,17 +398,13 @@ export function AgentBillDetail({
|
||||
) : null}
|
||||
|
||||
{canOperateBill && bill.status === "pending_confirm" ? (
|
||||
<div className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold tracking-tight text-primary">
|
||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("settlementCenter:billDisplay.confirmHint", {
|
||||
defaultValue: "确认后才可以登记收款或付款。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
ref={confirmRef}
|
||||
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm"
|
||||
>
|
||||
<p className="font-semibold tracking-tight text-primary">
|
||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full sm:w-auto sm:min-w-[10rem]"
|
||||
@@ -401,7 +417,10 @@ export function AgentBillDetail({
|
||||
) : null}
|
||||
|
||||
{canOperateBill && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||
<div
|
||||
ref={paymentRef}
|
||||
className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-semibold tracking-tight">{paymentTitle}</p>
|
||||
@@ -461,87 +480,94 @@ export function AgentBillDetail({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canWriteOff ? (
|
||||
<div className="space-y-4 rounded-xl border border-destructive/20 bg-destructive/5 p-5 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold tracking-tight text-destructive">
|
||||
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
|
||||
</p>
|
||||
<p className="text-xs text-destructive/80">
|
||||
{t("settlementBills.badDebtHint", {
|
||||
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
|
||||
})}
|
||||
</p>
|
||||
{canWriteOff || (canFinanceAdjustments && locked) ? (
|
||||
<div className="rounded-xl border border-dashed border-border/70">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
>
|
||||
{t("settlementCenter:billDisplay.advancedActions", {
|
||||
defaultValue: "调账 / 坏账",
|
||||
})}
|
||||
<span className="text-xs text-muted-foreground">{advancedOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="space-y-4 border-t border-border/70 px-4 py-4">
|
||||
{canWriteOff ? (
|
||||
<div className="space-y-3 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<p className="font-medium text-destructive">
|
||||
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-destructive/90">
|
||||
{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}
|
||||
</Label>
|
||||
<Input
|
||||
value={badDebtReason}
|
||||
onChange={(e) => setBadDebtReason(e.target.value)}
|
||||
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
|
||||
defaultValue: "例如:客户失联、确认坏账",
|
||||
})}
|
||||
className="border-destructive/30"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={confirmBusy}
|
||||
onClick={requestBadDebtWriteOff}
|
||||
>
|
||||
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{canFinanceAdjustments && locked ? (
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium">
|
||||
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}
|
||||
</Label>
|
||||
<Input
|
||||
value={adjustAmount}
|
||||
onChange={(e) => setAdjustAmount(e.target.value)}
|
||||
inputMode="decimal"
|
||||
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
|
||||
defaultValue: "例如:35.20 或 -10.00",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}
|
||||
</Label>
|
||||
<Input
|
||||
value={adjustReason}
|
||||
onChange={(e) => setAdjustReason(e.target.value)}
|
||||
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
|
||||
defaultValue: "例如:人工复核补差、冲正错账",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={confirmBusy}
|
||||
onClick={requestAdjustment}
|
||||
>
|
||||
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5 mt-2">
|
||||
<Label className="text-destructive/90">{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||
<Input
|
||||
value={badDebtReason}
|
||||
onChange={(e) => setBadDebtReason(e.target.value)}
|
||||
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
|
||||
defaultValue: "例如:客户失联、确认坏账",
|
||||
})}
|
||||
className="bg-background/50 transition-colors focus:bg-background border-destructive/30"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="w-full mt-2"
|
||||
disabled={confirmBusy}
|
||||
onClick={requestBadDebtWriteOff}
|
||||
>
|
||||
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canFinanceAdjustments && locked ? (
|
||||
<div className="space-y-4 rounded-xl border border-dashed border-border/70 bg-card p-5 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold tracking-tight">
|
||||
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("settlementBills.adjustmentHint", {
|
||||
defaultValue: "正数表示补收,负数表示冲减;提交后会生成一张独立调账单。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 mt-2">
|
||||
<Label className="text-muted-foreground">{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
||||
<Input
|
||||
value={adjustAmount}
|
||||
onChange={(e) => setAdjustAmount(e.target.value)}
|
||||
inputMode="decimal"
|
||||
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
|
||||
defaultValue: "例如:35.20 或 -10.00",
|
||||
})}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground">{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
|
||||
<Input
|
||||
value={adjustReason}
|
||||
onChange={(e) => setAdjustReason(e.target.value)}
|
||||
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
|
||||
defaultValue: "例如:人工复核补差、冲正错账",
|
||||
})}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full mt-2"
|
||||
disabled={confirmBusy}
|
||||
onClick={requestAdjustment}
|
||||
>
|
||||
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, Eye } from "lucide-react";
|
||||
import { ArrowRight, Banknote, Check, Eye } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
formatSignedSettlementMoney,
|
||||
} from "@/modules/settlement/settlement-signed-money";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import {
|
||||
describeBillPaymentDirection,
|
||||
} from "@/modules/settlement/settlement-bill-display";
|
||||
import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display";
|
||||
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
|
||||
import {
|
||||
formatPlatformPartyLabel,
|
||||
SettlementDashCell,
|
||||
@@ -44,7 +43,11 @@ type SettlementBillsTableProps = {
|
||||
currencyCode: string;
|
||||
billTypeFilter?: BillTypeFilter;
|
||||
emptyMessage?: string;
|
||||
canOperate?: boolean;
|
||||
boundAgentId?: number | null;
|
||||
onOpenDetail: (billId: number) => void;
|
||||
onConfirmBill?: (row: SettlementBillRow) => void;
|
||||
onPayBill?: (row: SettlementBillRow) => void;
|
||||
};
|
||||
|
||||
function billRowTone(row: SettlementBillRow): string {
|
||||
@@ -120,7 +123,11 @@ export function SettlementBillsTable({
|
||||
currencyCode,
|
||||
billTypeFilter = "all",
|
||||
emptyMessage,
|
||||
canOperate = false,
|
||||
boundAgentId = null,
|
||||
onOpenDetail,
|
||||
onConfirmBill,
|
||||
onPayBill,
|
||||
}: SettlementBillsTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
|
||||
@@ -173,8 +180,44 @@ export function SettlementBillsTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const isPlayerBill = row.bill_type === "player";
|
||||
const direction = describeBillPaymentDirection(row, t);
|
||||
const operable =
|
||||
canOperate &&
|
||||
settlementBillOperableByBoundAgent(row, boundAgentId ? { id: boundAgentId } : null);
|
||||
const canConfirm = operable && row.status === "pending_confirm";
|
||||
const canPay =
|
||||
operable &&
|
||||
["confirmed", "partial_paid", "overdue"].includes(row.status) &&
|
||||
row.unpaid_amount > 0;
|
||||
|
||||
const rowActions = [
|
||||
...(canConfirm && onConfirmBill
|
||||
? [
|
||||
{
|
||||
key: "confirm",
|
||||
label: t("billsPanel.confirmOneBtn", { defaultValue: "确认" }),
|
||||
icon: Check,
|
||||
onClick: () => onConfirmBill(row),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canPay && onPayBill
|
||||
? [
|
||||
{
|
||||
key: "pay",
|
||||
label: t("billsPanel.payBtn", { defaultValue: "收付" }),
|
||||
icon: Banknote,
|
||||
onClick: () => onPayBill(row),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "detail",
|
||||
label: t("actions.detail", { defaultValue: "详情" }),
|
||||
icon: Eye,
|
||||
onClick: () => onOpenDetail(row.id),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<TableRow key={row.id} className={billRowTone(row)}>
|
||||
@@ -268,16 +311,7 @@ export function SettlementBillsTable({
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("actions.detail", { defaultValue: "详情" }),
|
||||
icon: Eye,
|
||||
onClick: () => onOpenDetail(row.id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdminRowActionsMenu actions={rowActions} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
export type SettlementPeriodView = "bills" | "operations" | "ledger";
|
||||
import type { SettlementBillListScope } from "@/api/admin-agent-settlement";
|
||||
|
||||
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "operations", "ledger"];
|
||||
export type SettlementPeriodView = "bills" | "ledger";
|
||||
|
||||
export type SettlementBillScopeFilter = "all" | SettlementBillListScope;
|
||||
|
||||
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "ledger"];
|
||||
|
||||
const VALID_BILL_SCOPES: SettlementBillScopeFilter[] = [
|
||||
"all",
|
||||
"pending_confirm",
|
||||
"awaiting_payment",
|
||||
"settled",
|
||||
"adjustment",
|
||||
];
|
||||
|
||||
function parsePositiveInt(raw: string | null): number | null {
|
||||
if (raw === null || raw === "") {
|
||||
@@ -11,16 +23,30 @@ function parsePositiveInt(raw: string | null): number | null {
|
||||
}
|
||||
|
||||
export function settlementCenterListHref(adminSiteId?: number | null): string {
|
||||
return settlementCenterScopeHref(adminSiteId, "all");
|
||||
}
|
||||
|
||||
/** 结算中心列表深链,可带账单筛选 scope(待确认 / 待收付等)。 */
|
||||
export function settlementCenterScopeHref(
|
||||
adminSiteId?: number | null,
|
||||
billScope: SettlementBillScopeFilter = "all",
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (adminSiteId != null && adminSiteId > 0) {
|
||||
return `/admin/settlement-center?site=${adminSiteId}`;
|
||||
params.set("site", String(adminSiteId));
|
||||
}
|
||||
return "/admin/settlement-center";
|
||||
if (billScope !== "all") {
|
||||
params.set("scope", billScope);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `/admin/settlement-center?${qs}` : "/admin/settlement-center";
|
||||
}
|
||||
|
||||
export function settlementPeriodViewHref(
|
||||
periodId: number,
|
||||
view: SettlementPeriodView = "bills",
|
||||
adminSiteId?: number | null,
|
||||
billScope?: SettlementBillScopeFilter | null,
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
period: String(periodId),
|
||||
@@ -29,24 +55,57 @@ export function settlementPeriodViewHref(
|
||||
if (adminSiteId != null && adminSiteId > 0) {
|
||||
params.set("site", String(adminSiteId));
|
||||
}
|
||||
if (billScope != null && billScope !== "all") {
|
||||
params.set("scope", billScope);
|
||||
}
|
||||
return `/admin/settlement-center?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function preferredBillScopeForPeriod(summary?: {
|
||||
pending_confirm?: number;
|
||||
awaiting_payment?: number;
|
||||
}): SettlementBillScopeFilter {
|
||||
const pending = summary?.pending_confirm ?? 0;
|
||||
const awaiting = summary?.awaiting_payment ?? 0;
|
||||
if (pending > 0) {
|
||||
return "pending_confirm";
|
||||
}
|
||||
if (awaiting > 0) {
|
||||
return "awaiting_payment";
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
export function parseSettlementCenterView(
|
||||
siteRaw: string | null,
|
||||
periodRaw: string | null,
|
||||
viewRaw: string | null,
|
||||
): { siteId: number | null; periodId: number | null; view: SettlementPeriodView } {
|
||||
const normalizedView = viewRaw === "reports" ? "bills" : viewRaw;
|
||||
scopeRaw?: string | null,
|
||||
): {
|
||||
siteId: number | null;
|
||||
periodId: number | null;
|
||||
view: SettlementPeriodView;
|
||||
billScope: SettlementBillScopeFilter;
|
||||
} {
|
||||
const normalizedView =
|
||||
viewRaw === "reports" || viewRaw === "operations" ? "bills" : viewRaw;
|
||||
const view =
|
||||
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
|
||||
? (normalizedView as SettlementPeriodView)
|
||||
: "bills";
|
||||
|
||||
const billScope =
|
||||
scopeRaw !== null &&
|
||||
scopeRaw !== "" &&
|
||||
VALID_BILL_SCOPES.includes(scopeRaw as SettlementBillScopeFilter)
|
||||
? (scopeRaw as SettlementBillScopeFilter)
|
||||
: "all";
|
||||
|
||||
return {
|
||||
siteId: parsePositiveInt(siteRaw),
|
||||
periodId: parsePositiveInt(periodRaw),
|
||||
view,
|
||||
billScope,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import type { SettlementPeriodCloseResult, SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel";
|
||||
import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel";
|
||||
import { SettlementOperationsPanel } from "@/modules/settlement/settlement-operations-panel";
|
||||
import { SettlementPeriodHeader } from "@/modules/settlement/settlement-period-header";
|
||||
import {
|
||||
settlementCenterListHref,
|
||||
settlementPeriodViewHref,
|
||||
type SettlementBillScopeFilter,
|
||||
type SettlementPeriodView,
|
||||
} from "@/modules/settlement/settlement-center-nav";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettlementCenterPeriodDetailProps = {
|
||||
period: SettlementPeriodRow;
|
||||
view: SettlementPeriodView;
|
||||
billScope: SettlementBillScopeFilter;
|
||||
adminSiteId: number;
|
||||
currencyCode: string;
|
||||
canOperateBills: boolean;
|
||||
canManagePeriods: boolean;
|
||||
boundAgentId?: number | null;
|
||||
refreshKey: number;
|
||||
onOpenBillDetail: (billId: number) => void;
|
||||
onOpenBillDetail: (billId: number, focus?: "confirm" | "payment") => void;
|
||||
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
|
||||
onReloadPeriods?: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function SettlementCenterPeriodDetail({
|
||||
period,
|
||||
view,
|
||||
billScope,
|
||||
adminSiteId,
|
||||
currencyCode,
|
||||
canOperateBills,
|
||||
canManagePeriods,
|
||||
boundAgentId = null,
|
||||
refreshKey,
|
||||
onOpenBillDetail,
|
||||
onPeriodClosed,
|
||||
onReloadPeriods,
|
||||
}: SettlementCenterPeriodDetailProps): React.ReactElement {
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
|
||||
const subViews: { key: SettlementPeriodView; label: string }[] = [
|
||||
{ key: "bills", label: t("nav.bills", { defaultValue: "账单" }) },
|
||||
{ key: "operations", label: t("nav.operations", { defaultValue: "收付与调账" }) },
|
||||
{ key: "ledger", label: t("nav.ledger", { defaultValue: "账务流水" }) },
|
||||
];
|
||||
|
||||
@@ -54,31 +54,21 @@ export function SettlementCenterPeriodDetail({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<Link
|
||||
href={settlementCenterListHref(adminSiteId)}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")}
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold tracking-tight">
|
||||
{formatSettlementPeriodSpan(period.period_start, period.period_end)}
|
||||
</h2>
|
||||
<AdminStatusBadge status={period.status}>
|
||||
{settlementPeriodStatusLabel(period.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettlementPeriodHeader
|
||||
period={period}
|
||||
adminSiteId={adminSiteId}
|
||||
currencyCode={currencyCode}
|
||||
billScope={billScope}
|
||||
canManage={canManagePeriods}
|
||||
onPeriodClosed={onPeriodClosed}
|
||||
onReloadPeriods={onReloadPeriods}
|
||||
/>
|
||||
|
||||
<AdminSubnav aria-label={t("nav.aria", { defaultValue: "账期视图" })}>
|
||||
{subViews.map((item) => (
|
||||
<AdminSubnavLink
|
||||
key={item.key}
|
||||
href={settlementPeriodViewHref(period.id, item.key, adminSiteId)}
|
||||
href={settlementPeriodViewHref(period.id, item.key, adminSiteId, billScope)}
|
||||
active={view === item.key}
|
||||
>
|
||||
{item.label}
|
||||
@@ -88,12 +78,14 @@ export function SettlementCenterPeriodDetail({
|
||||
|
||||
{view === "bills" ? (
|
||||
<SettlementMainPanel
|
||||
key={`${adminSiteId}-${period.id}-${refreshKey}`}
|
||||
key={`${adminSiteId}-${period.id}-${refreshKey}-${billScope}`}
|
||||
adminSiteId={adminSiteId}
|
||||
currencyCode={currencyCode}
|
||||
periodFilter={period.id}
|
||||
billScope={billScope}
|
||||
onOpenBillDetail={onOpenBillDetail}
|
||||
refreshKey={refreshKey}
|
||||
canOperateBills={canOperateBills}
|
||||
pendingConfirm={pendingConfirm}
|
||||
awaitingPayment={awaitingPayment}
|
||||
selectedPeriodStatus={period.status}
|
||||
@@ -101,17 +93,6 @@ export function SettlementCenterPeriodDetail({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "operations" ? (
|
||||
<SettlementOperationsPanel
|
||||
key={`${adminSiteId}-${period.id}-${refreshKey}-ops`}
|
||||
adminSiteId={adminSiteId}
|
||||
settlementPeriodId={period.id}
|
||||
currencyCode={currencyCode}
|
||||
refreshKey={refreshKey}
|
||||
onOpenBill={onOpenBillDetail}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "ledger" ? (
|
||||
<SettlementCreditLedgerPanel
|
||||
key={`${adminSiteId}-${period.id}-${refreshKey}`}
|
||||
@@ -121,7 +102,6 @@ export function SettlementCenterPeriodDetail({
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -15,6 +15,7 @@ import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
|
||||
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
|
||||
import {
|
||||
parseSettlementCenterView,
|
||||
preferredBillScopeForPeriod,
|
||||
settlementCenterListHref,
|
||||
settlementPeriodViewHref,
|
||||
type SettlementPeriodView,
|
||||
@@ -45,11 +46,24 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const profile = useAdminProfile();
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
|
||||
const { siteId: siteFromUrl, periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
|
||||
const {
|
||||
siteId: siteFromUrl,
|
||||
periodId: activePeriodId,
|
||||
view: activeView,
|
||||
billScope: activeBillScope,
|
||||
} = parseSettlementCenterView(
|
||||
searchParams.get("site"),
|
||||
searchParams.get("period"),
|
||||
searchParams.get("view"),
|
||||
searchParams.get("scope"),
|
||||
);
|
||||
const billIdFromUrl = Number.parseInt(searchParams.get("bill") ?? "", 10);
|
||||
const billFocusFromUrl =
|
||||
searchParams.get("focus") === "payment"
|
||||
? "payment"
|
||||
: searchParams.get("focus") === "confirm"
|
||||
? "confirm"
|
||||
: null;
|
||||
|
||||
const canOperateBills =
|
||||
profile?.is_super_admin === true ||
|
||||
@@ -66,6 +80,8 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
||||
const [periodsReady, setPeriodsReady] = useState(false);
|
||||
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
||||
const [detailBillFocus, setDetailBillFocus] = useState<"confirm" | "payment" | null>(null);
|
||||
const billDeepLinkHandledRef = useRef<number | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [periodLookupDone, setPeriodLookupDone] = useState(false);
|
||||
|
||||
@@ -261,8 +277,15 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const activePeriod =
|
||||
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
|
||||
|
||||
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
|
||||
router.push(settlementPeriodViewHref(periodId, view, siteId));
|
||||
const openPeriodView = (periodId: number, view: SettlementPeriodView = "bills"): void => {
|
||||
const period = periods.find((row) => row.id === periodId);
|
||||
const scope = preferredBillScopeForPeriod(period?.summary);
|
||||
router.push(settlementPeriodViewHref(periodId, view, siteId, scope));
|
||||
};
|
||||
|
||||
const openBillDetail = (billId: number, focus?: "confirm" | "payment"): void => {
|
||||
setDetailBillId(billId);
|
||||
setDetailBillFocus(focus ?? null);
|
||||
};
|
||||
|
||||
const isListMode = activePeriodId === null;
|
||||
@@ -279,14 +302,30 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
if (activePeriodId !== null && siteFromUrl !== siteId) {
|
||||
router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId));
|
||||
router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId, activeBillScope));
|
||||
}
|
||||
}, [activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]);
|
||||
}, [activeBillScope, activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPeriodLookupDone(false);
|
||||
}, [activePeriodId, siteId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!periodsReady ||
|
||||
!Number.isFinite(billIdFromUrl) ||
|
||||
billIdFromUrl <= 0 ||
|
||||
activePeriodId === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (billDeepLinkHandledRef.current === billIdFromUrl) {
|
||||
return;
|
||||
}
|
||||
billDeepLinkHandledRef.current = billIdFromUrl;
|
||||
openBillDetail(billIdFromUrl, billFocusFromUrl ?? undefined);
|
||||
}, [activePeriodId, billFocusFromUrl, billIdFromUrl, periodsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!periodsReady || activePeriodId === null || siteId === null) {
|
||||
return;
|
||||
@@ -306,7 +345,9 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const match = (data.items ?? []).find((row) => row.id === activePeriodId);
|
||||
if (match?.admin_site_id && match.admin_site_id !== siteId) {
|
||||
setAdminSiteId(match.admin_site_id);
|
||||
router.replace(settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id));
|
||||
router.replace(
|
||||
settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id, activeBillScope),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -381,21 +422,51 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
<SettlementCenterPeriodDetail
|
||||
period={activePeriod}
|
||||
view={activeView}
|
||||
billScope={activeBillScope}
|
||||
adminSiteId={siteId}
|
||||
currencyCode={currency}
|
||||
canOperateBills={canOperateBills}
|
||||
canManagePeriods={canManagePeriods}
|
||||
boundAgentId={boundAgent?.id ?? null}
|
||||
refreshKey={refreshKey}
|
||||
onOpenBillDetail={setDetailBillId}
|
||||
onOpenBillDetail={openBillDetail}
|
||||
onPeriodClosed={(result) => {
|
||||
setRefreshKey((n) => n + 1);
|
||||
const n = result?.unsettled_ticket_count ?? 0;
|
||||
if (n > 0) {
|
||||
toast.warning(
|
||||
t("toast.periodClosedUnsettled", {
|
||||
defaultValue: "已关账,仍有 {{count}} 笔注单未结算。",
|
||||
count: n,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onReloadPeriods={loadPeriods}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
|
||||
<Dialog
|
||||
open={detailBillId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDetailBillId(null);
|
||||
setDetailBillFocus(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(640px,calc(100vw-2rem))] sm:!max-w-[640px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
||||
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(720px,calc(100vw-2rem))] sm:!max-w-[720px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{detailBillId !== null
|
||||
? t("actions.billDetailWithId", {
|
||||
defaultValue: "账单 #{{id}}",
|
||||
id: detailBillId,
|
||||
})
|
||||
: t("actions.billDetail", { defaultValue: "账单详情" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailBillId !== null ? (
|
||||
<div className="min-h-0 overflow-y-auto px-6 py-5">
|
||||
@@ -405,6 +476,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
canManage={canOperateBills}
|
||||
boundAgent={boundAgent}
|
||||
canFinanceAdjustments={canFinanceAdjustments}
|
||||
focusSection={detailBillFocus}
|
||||
onUpdated={() => {
|
||||
void loadPeriods();
|
||||
setRefreshKey((n) => n + 1);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSettlementBills,
|
||||
postSettlementBillConfirm,
|
||||
type SettlementBillListScope,
|
||||
type SettlementBillRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
@@ -22,40 +24,45 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
|
||||
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
|
||||
import {
|
||||
settlementPeriodViewHref,
|
||||
type SettlementBillScopeFilter,
|
||||
} from "@/modules/settlement/settlement-center-nav";
|
||||
import { settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BillTypeFilter = "all" | "player" | "agent";
|
||||
type BillStatusFilter = "all" | SettlementBillListScope;
|
||||
|
||||
type BillFilters = {
|
||||
billId: string;
|
||||
ownerKeyword: string;
|
||||
billType: BillTypeFilter;
|
||||
statusScope: BillStatusFilter;
|
||||
statusScope: SettlementBillScopeFilter;
|
||||
};
|
||||
|
||||
function filtersForPeriod(boundAgentId: number | null): BillFilters {
|
||||
function filtersForPeriod(
|
||||
boundAgentId: number | null,
|
||||
billScope: SettlementBillScopeFilter,
|
||||
): BillFilters {
|
||||
return {
|
||||
billId: "",
|
||||
ownerKeyword: "",
|
||||
billType: boundAgentId !== null ? "agent" : "all",
|
||||
statusScope: "all",
|
||||
statusScope: billScope,
|
||||
};
|
||||
}
|
||||
|
||||
function apiQueryFromFilters(filters: BillFilters): {
|
||||
bill_type?: string;
|
||||
scope?: SettlementBillListScope;
|
||||
bill_id?: number;
|
||||
keyword?: string;
|
||||
} {
|
||||
const out: {
|
||||
bill_type?: string;
|
||||
scope?: SettlementBillListScope;
|
||||
bill_id?: number;
|
||||
keyword?: string;
|
||||
} = {};
|
||||
|
||||
@@ -65,10 +72,6 @@ function apiQueryFromFilters(filters: BillFilters): {
|
||||
if (filters.statusScope !== "all") {
|
||||
out.scope = filters.statusScope;
|
||||
}
|
||||
const id = Number(filters.billId.trim());
|
||||
if (filters.billId.trim() !== "" && !Number.isNaN(id) && id > 0) {
|
||||
out.bill_id = id;
|
||||
}
|
||||
const keyword = filters.ownerKeyword.trim();
|
||||
if (keyword !== "") {
|
||||
out.keyword = keyword;
|
||||
@@ -81,8 +84,10 @@ export type SettlementMainPanelProps = {
|
||||
adminSiteId: number;
|
||||
currencyCode: string;
|
||||
periodFilter: AgentSettlementPeriodFilter;
|
||||
onOpenBillDetail: (billId: number) => void;
|
||||
billScope?: SettlementBillScopeFilter;
|
||||
onOpenBillDetail: (billId: number, focus?: "confirm" | "payment") => void;
|
||||
refreshKey?: number;
|
||||
canOperateBills?: boolean;
|
||||
pendingConfirm: number;
|
||||
awaitingPayment: number;
|
||||
selectedPeriodStatus?: string | null;
|
||||
@@ -93,8 +98,10 @@ export function SettlementMainPanel({
|
||||
adminSiteId,
|
||||
currencyCode,
|
||||
periodFilter,
|
||||
billScope = "all",
|
||||
onOpenBillDetail,
|
||||
refreshKey = 0,
|
||||
canOperateBills = false,
|
||||
pendingConfirm,
|
||||
awaitingPayment,
|
||||
selectedPeriodStatus,
|
||||
@@ -103,8 +110,12 @@ export function SettlementMainPanel({
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
const periodId = periodFilter === "all" ? undefined : periodFilter;
|
||||
const periodOpen = selectedPeriodStatus === "open";
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
const initialFilters = useMemo(() => filtersForPeriod(boundAgentId), [boundAgentId]);
|
||||
const initialFilters = useMemo(
|
||||
() => filtersForPeriod(boundAgentId, billScope),
|
||||
[boundAgentId, billScope],
|
||||
);
|
||||
|
||||
const [draft, setDraft] = useState<BillFilters>(initialFilters);
|
||||
const [applied, setApplied] = useState<BillFilters>(initialFilters);
|
||||
@@ -129,7 +140,6 @@ export function SettlementMainPanel({
|
||||
settlement_period_id: periodId,
|
||||
bill_type: q.bill_type,
|
||||
scope: q.scope,
|
||||
bill_id: q.bill_id,
|
||||
keyword: q.keyword,
|
||||
page,
|
||||
per_page: perPage,
|
||||
@@ -164,7 +174,7 @@ export function SettlementMainPanel({
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const statusOptionLabel = (value: BillStatusFilter): string => {
|
||||
const statusOptionLabel = (value: SettlementBillScopeFilter): string => {
|
||||
if (value === "all") {
|
||||
return t("billsPanel.filterAll", { defaultValue: "全部状态" });
|
||||
}
|
||||
@@ -185,7 +195,7 @@ export function SettlementMainPanel({
|
||||
const emptyBillMessage = useMemo((): string | undefined => {
|
||||
if (periodOpen) {
|
||||
return t("empty.billsNeedClose", {
|
||||
defaultValue: "账单在关账后生成。请返回账期列表,对本期执行「关账」后再查看。",
|
||||
defaultValue: "账单在关账后生成。请对本账期执行「关账」后再查看。",
|
||||
});
|
||||
}
|
||||
if (applied.statusScope !== "all") {
|
||||
@@ -213,75 +223,95 @@ export function SettlementMainPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const scopeChipClass = (active: boolean): string =>
|
||||
cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border/70 bg-muted/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
);
|
||||
|
||||
const scopeChips: SettlementBillScopeFilter[] = [
|
||||
"all",
|
||||
"pending_confirm",
|
||||
"awaiting_payment",
|
||||
"settled",
|
||||
];
|
||||
|
||||
const requestConfirmBill = (row: SettlementBillRow): void => {
|
||||
requestConfirm({
|
||||
title: t("billsPanel.confirmOneTitle", { defaultValue: "确认账单 #{{id}}?", id: row.id }),
|
||||
description: t("billsPanel.confirmOneDesc", {
|
||||
defaultValue: "确认后进入待收付,可登记线下收付。",
|
||||
}),
|
||||
confirmLabel: t("billsPanel.confirmOneBtn", { defaultValue: "确认" }),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await postSettlementBillConfirm(row.id);
|
||||
await load();
|
||||
toast.success(t("billsPanel.confirmedOne", { defaultValue: "已确认" }));
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("billsPanel.confirmFailed", { defaultValue: "确认失败" }),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const canOperateRow = (row: SettlementBillRow): boolean =>
|
||||
canOperateBills && settlementBillOperableByBoundAgent(row, boundAgentId ? { id: boundAgentId } : null);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||
<div className="grid gap-x-5 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sb-bill-id" className="text-muted-foreground">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
|
||||
<Input
|
||||
id="sb-bill-id"
|
||||
inputMode="numeric"
|
||||
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
|
||||
value={draft.billId}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sb-owner" className="text-muted-foreground">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
|
||||
<Input
|
||||
id="sb-owner"
|
||||
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
||||
value={draft.ownerKeyword}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
className="bg-background/50 transition-colors focus:bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sb-status" className="text-muted-foreground">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.statusScope}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
statusScope: (v ?? "all") as BillStatusFilter,
|
||||
}))
|
||||
<div className="space-y-4">
|
||||
<ConfirmDialog />
|
||||
|
||||
{!periodOpen && periodId != null ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{scopeChips.map((scope) => {
|
||||
const active = applied.statusScope === scope;
|
||||
const href = settlementPeriodViewHref(periodId, "bills", adminSiteId, scope);
|
||||
const count =
|
||||
scope === "pending_confirm"
|
||||
? pendingConfirm
|
||||
: scope === "awaiting_payment"
|
||||
? awaitingPayment
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Link key={scope} href={href} className={scopeChipClass(active)}>
|
||||
{statusOptionLabel(scope)}
|
||||
{count != null && count > 0 ? ` ${count}` : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field min-w-[12rem] flex-1">
|
||||
<Label htmlFor="sb-owner" className="sm:shrink-0">
|
||||
{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="sb-owner"
|
||||
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
||||
value={draft.ownerKeyword}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sb-status" className="w-full bg-background/50 transition-colors focus:bg-background">
|
||||
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
[
|
||||
"all",
|
||||
"pending_confirm",
|
||||
"awaiting_payment",
|
||||
"settled",
|
||||
"adjustment",
|
||||
] as BillStatusFilter[]
|
||||
).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{statusOptionLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sb-type" className="text-muted-foreground">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{billTypeOptions.length > 1 ? (
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="sb-type" className="sm:shrink-0">
|
||||
{t("billsPanel.billType", { defaultValue: "账单类型" })}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.billType}
|
||||
@@ -292,7 +322,7 @@ export function SettlementMainPanel({
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sb-type" className="w-full bg-background/50 transition-colors focus:bg-background">
|
||||
<SelectTrigger id="sb-type" className="h-9 w-full sm:w-36">
|
||||
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -304,16 +334,15 @@ export function SettlementMainPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<Button type="button" onClick={() => runSearch()}>
|
||||
) : null}
|
||||
<div className="admin-list-actions">
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => resetFilters()}>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => resetFilters()}>
|
||||
{t("billsPanel.reset", { defaultValue: "重置" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("billsPanel.refresh", { defaultValue: "刷新" })}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -329,7 +358,23 @@ export function SettlementMainPanel({
|
||||
currencyCode={currencyCode}
|
||||
billTypeFilter={applied.billType}
|
||||
emptyMessage={emptyBillMessage}
|
||||
onOpenDetail={onOpenBillDetail}
|
||||
canOperate={canOperateBills}
|
||||
boundAgentId={boundAgentId}
|
||||
onOpenDetail={(billId) => onOpenBillDetail(billId)}
|
||||
onConfirmBill={(row) => {
|
||||
if (canOperateRow(row) && row.status === "pending_confirm") {
|
||||
requestConfirmBill(row);
|
||||
}
|
||||
}}
|
||||
onPayBill={(row) => {
|
||||
if (
|
||||
canOperateRow(row) &&
|
||||
["confirmed", "partial_paid", "overdue"].includes(row.status) &&
|
||||
row.unpaid_amount > 0
|
||||
) {
|
||||
onOpenBillDetail(row.id, "payment");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AdminListPaginationFooter
|
||||
selectId="settlement-bills-per-page"
|
||||
@@ -348,4 +393,4 @@ export function SettlementMainPanel({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
223
src/modules/settlement/settlement-period-header.tsx
Normal file
223
src/modules/settlement/settlement-period-header.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,12 @@ export function SettlementPeriodsTable({
|
||||
) : (
|
||||
periods.map((row) => {
|
||||
const canCloseRow = canManage && row.status === "open";
|
||||
const todoCount =
|
||||
(row.summary?.pending_confirm ?? 0) + (row.summary?.awaiting_payment ?? 0);
|
||||
const detailLabel =
|
||||
todoCount > 0
|
||||
? t("periodTable.processBills", { defaultValue: "处理账单" })
|
||||
: t("periodTable.viewDetail", { defaultValue: "查看详情" });
|
||||
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
@@ -144,7 +150,7 @@ export function SettlementPeriodsTable({
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("periodTable.viewDetail", { defaultValue: "查看详情" }),
|
||||
label: detailLabel,
|
||||
icon: Eye,
|
||||
onClick: () => onViewDetail(row.id),
|
||||
},
|
||||
|
||||
79
src/modules/settlement/settlement-player-deeplink.ts
Normal file
79
src/modules/settlement/settlement-player-deeplink.ts
Normal 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);
|
||||
}
|
||||
52
src/modules/wallet/wallet-cell-mono-id.tsx
Normal file
52
src/modules/wallet/wallet-cell-mono-id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Copy, RotateCcw, Wrench } from "lucide-react";
|
||||
import { RotateCcw, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
@@ -12,7 +12,6 @@ import { toast } from "sonner";
|
||||
import {
|
||||
getAdminPlayerWallets,
|
||||
getAdminTransferOrders,
|
||||
getAdminWalletTransactions,
|
||||
reverseTransferOrder,
|
||||
manuallyProcessTransferOrder,
|
||||
completeTransferInCredit,
|
||||
@@ -58,204 +57,27 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
|
||||
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { WalletCellMonoId } from "@/modules/wallet/wallet-cell-mono-id";
|
||||
import {
|
||||
TRANSFER_ORDER_STATUS_OPTIONS,
|
||||
WALLET_FILTER_ALL,
|
||||
emptyTransferFilters,
|
||||
transferFiltersFromSearchParams,
|
||||
walletAdminSelectDisplayedLabel,
|
||||
type TransferFilters,
|
||||
} from "@/modules/wallet/wallet-filter-utils";
|
||||
import { walletStatusLabel } from "@/modules/wallet/wallet-labels";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayerWalletsData,
|
||||
AdminTransferOrderItem,
|
||||
AdminTransferOrderListData,
|
||||
AdminWalletTxnListData,
|
||||
} from "@/types/api/admin-wallet";
|
||||
|
||||
/** 长单号/流水号:单行截断;点击复制全文,悬停可看全文 */
|
||||
function CellMonoId({
|
||||
value,
|
||||
empty = "—",
|
||||
copyHint,
|
||||
}: {
|
||||
value: string | null | undefined;
|
||||
empty?: string;
|
||||
/** 用于 toast / 无障碍:如「流水号」「主站流水号」 */
|
||||
copyHint?: string;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation("wallet");
|
||||
if (value == null || value === "") {
|
||||
return <span className="text-muted-foreground">{empty}</span>;
|
||||
}
|
||||
|
||||
const copy = async (e: React.MouseEvent): Promise<void> => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(
|
||||
copyHint
|
||||
? t("copySuccess", { label: copyHint })
|
||||
: t("copySuccess", { label: "" }).trim(),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
|
||||
title={value}
|
||||
aria-label={copyHint ?? t("copyTxnNo")}
|
||||
onClick={(e) => void copy(e)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{value}</span>
|
||||
<Copy
|
||||
className="size-3.5 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function walletTxnBizTypeLabel(
|
||||
bizType: string,
|
||||
ledgerSource: string | null | undefined,
|
||||
t: (key: string) => string,
|
||||
tSettlement: TFunction<"settlementCenter">,
|
||||
): string {
|
||||
if (ledgerSource === "credit_ledger") {
|
||||
return creditLedgerReasonLabel(bizType, tSettlement);
|
||||
}
|
||||
|
||||
switch (bizType) {
|
||||
case "transfer_in":
|
||||
return t("transferIn");
|
||||
case "transfer_out":
|
||||
return t("transferOut");
|
||||
case "transfer_out_refund":
|
||||
return t("transferOutRefund");
|
||||
case "bet_deduct":
|
||||
return t("bizBetDeduct");
|
||||
case "bet_reverse":
|
||||
return t("bizBetReverse");
|
||||
case "settle_payout":
|
||||
return t("bizSettlePayout");
|
||||
case "jackpot_manual_payout":
|
||||
return t("bizJackpotPayout");
|
||||
case "settlement_adjustment":
|
||||
return t("bizSettlementAdjustment");
|
||||
default:
|
||||
return bizType;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabelT(status: string, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case "processing":
|
||||
return t("statusProcessing");
|
||||
case "success":
|
||||
return t("statusSuccess");
|
||||
case "failed":
|
||||
return t("statusFailed");
|
||||
case "pending_reconcile":
|
||||
return t("statusPendingReconcile");
|
||||
case "reversed":
|
||||
return t("statusReversed");
|
||||
case "manually_processed":
|
||||
return t("statusCaseClosed");
|
||||
case "posted":
|
||||
return t("statusPosted");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
type TransferFilters = {
|
||||
playerId: string;
|
||||
playerAccount: string;
|
||||
transferNo: string;
|
||||
externalRefNo: string;
|
||||
createdFrom: string;
|
||||
createdTo: string;
|
||||
statusCsv: string;
|
||||
abnormalOnly: boolean;
|
||||
};
|
||||
|
||||
const emptyTransferFilters: TransferFilters = {
|
||||
playerId: "",
|
||||
playerAccount: "",
|
||||
transferNo: "",
|
||||
externalRefNo: "",
|
||||
createdFrom: "",
|
||||
createdTo: "",
|
||||
statusCsv: "",
|
||||
abnormalOnly: false,
|
||||
};
|
||||
|
||||
type TxnFilters = {
|
||||
playerId: string;
|
||||
playerAccount: string;
|
||||
txnNo: string;
|
||||
externalRefNo: string;
|
||||
bizType: string;
|
||||
statusCsv: string;
|
||||
createdFrom: string;
|
||||
createdTo: string;
|
||||
abnormalOnly: boolean;
|
||||
};
|
||||
|
||||
const emptyTxnFilters: TxnFilters = {
|
||||
playerId: "",
|
||||
playerAccount: "",
|
||||
txnNo: "",
|
||||
externalRefNo: "",
|
||||
bizType: "",
|
||||
statusCsv: "",
|
||||
createdFrom: "",
|
||||
createdTo: "",
|
||||
abnormalOnly: false,
|
||||
};
|
||||
|
||||
/** 下拉「不限」值;请求时转为空串不传参 */
|
||||
const WALLET_FILTER_ALL = "__all__";
|
||||
|
||||
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
|
||||
const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "transfer_in", label: "transferIn" },
|
||||
{ value: "transfer_out", label: "transferOut" },
|
||||
{ value: "transfer_out_refund", label: "transferOutRefund" },
|
||||
];
|
||||
|
||||
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
|
||||
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "posted", label: "statusPosted" },
|
||||
{ value: "pending_reconcile", label: "statusPendingReconcile" },
|
||||
{ value: "reversed", label: "statusReversed" },
|
||||
];
|
||||
|
||||
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
|
||||
const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "processing", label: "statusProcessing" },
|
||||
{ value: "success", label: "statusSuccess" },
|
||||
{ value: "failed", label: "statusFailed" },
|
||||
{ value: "pending_reconcile", label: "statusPendingReconcile" },
|
||||
{ value: "reversed", label: "statusReversed" },
|
||||
{ value: "manually_processed", label: "statusCaseClosed" },
|
||||
];
|
||||
|
||||
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
|
||||
function walletAdminSelectDisplayedLabel(
|
||||
raw: unknown,
|
||||
options: readonly { value: string; label: string }[],
|
||||
t?: (key: string) => string,
|
||||
): string {
|
||||
const v = raw == null ? "" : String(raw);
|
||||
if (v === "" || v === WALLET_FILTER_ALL) {
|
||||
return t ? t("filterAll") : "All";
|
||||
}
|
||||
const key = options.find((o) => o.value === v)?.label;
|
||||
return key ? (t ? t(key) : key) : v;
|
||||
}
|
||||
export { WalletTxnsPanel } from "@/modules/wallet/wallet-txns-panel";
|
||||
|
||||
type TransferOrderRowActionsProps = {
|
||||
row: AdminTransferOrderItem;
|
||||
@@ -317,24 +139,27 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
useAdminCurrencyCatalog();
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const searchParams = useSearchParams();
|
||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||
const transferNoFromUrl = (searchParams.get("transfer_no") ?? "").trim();
|
||||
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
|
||||
const urlFilterKey = searchParams.toString();
|
||||
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const initialTransferFilters: TransferFilters = {
|
||||
...emptyTransferFilters,
|
||||
playerId: playerIdFromUrl,
|
||||
transferNo: transferNoFromUrl,
|
||||
externalRefNo: externalRefNoFromUrl,
|
||||
};
|
||||
const [draft, setDraft] = useState<TransferFilters>(initialTransferFilters);
|
||||
const [applied, setApplied] = useState<TransferFilters>(initialTransferFilters);
|
||||
const [draft, setDraft] = useState<TransferFilters>(() =>
|
||||
transferFiltersFromSearchParams(searchParams),
|
||||
);
|
||||
const [applied, setApplied] = useState<TransferFilters>(() =>
|
||||
transferFiltersFromSearchParams(searchParams),
|
||||
);
|
||||
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const next = transferFiltersFromSearchParams(searchParams);
|
||||
setDraft(next);
|
||||
setApplied(next);
|
||||
setPage(1);
|
||||
}, [urlFilterKey, searchParams]);
|
||||
|
||||
const doAction = async (
|
||||
transferNo: string,
|
||||
fn: () => Promise<unknown>,
|
||||
@@ -399,7 +224,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
external_ref_no: applied.externalRefNo.trim() || undefined,
|
||||
created_from: applied.createdFrom.trim() || undefined,
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
status: applied.statusCsv.trim() || undefined,
|
||||
status: applied.abnormalOnly ? undefined : applied.statusCsv.trim() || undefined,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
@@ -432,6 +257,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<CardTitle>{t("transferOrders")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{applied.abnormalOnly ? (
|
||||
<p className="rounded-md border border-amber-200/80 bg-amber-50 px-3 py-2 text-sm text-amber-950">
|
||||
{t("abnormalFilterActive")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
|
||||
@@ -571,10 +401,10 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
data.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
|
||||
<CellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
|
||||
<WalletCellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
|
||||
<WalletCellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
@@ -585,7 +415,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</AdminTableMoney>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
|
||||
<AdminStatusBadge status={row.status}>{walletStatusLabel(row.status, t)}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
|
||||
{row.fail_reason?.trim() ? row.fail_reason : "—"}
|
||||
@@ -639,322 +469,6 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
export function WalletTxnsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const { t: tSettlement } = useTranslation("settlementCenter");
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("walletTransactions");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const searchParams = useSearchParams();
|
||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
||||
const txnNoFromUrl = (searchParams.get("txn_no") ?? "").trim();
|
||||
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
|
||||
const initialTxnFilters: TxnFilters = {
|
||||
...emptyTxnFilters,
|
||||
playerId: playerIdFromUrl,
|
||||
txnNo: txnNoFromUrl,
|
||||
externalRefNo: externalRefNoFromUrl,
|
||||
};
|
||||
const [draft, setDraft] = useState<TxnFilters>(initialTxnFilters);
|
||||
const [applied, setApplied] = useState<TxnFilters>(initialTxnFilters);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const player_id =
|
||||
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
|
||||
const d = await getAdminWalletTransactions({
|
||||
page,
|
||||
per_page: perPage,
|
||||
abnormal: applied.abnormalOnly || undefined,
|
||||
player_id:
|
||||
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
|
||||
? player_id
|
||||
: undefined,
|
||||
player_account: applied.playerAccount.trim() || undefined,
|
||||
txn_no: applied.txnNo.trim() || undefined,
|
||||
external_ref_no: applied.externalRefNo.trim() || undefined,
|
||||
created_from: applied.createdFrom.trim() || undefined,
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
biz_type: applied.bizType.trim() || undefined,
|
||||
status: applied.statusCsv.trim() || undefined,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, applied, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [page, perPage, applied]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyTxnFilters);
|
||||
setApplied(emptyTxnFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const showLedgerColumn =
|
||||
data?.items.some((row) => row.ledger_source === "credit_ledger") ?? false;
|
||||
const txnTableColSpan = showLedgerColumn ? 12 : 11;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("walletTransactions")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-no">{t("txnNo")}</Label>
|
||||
<Input
|
||||
id="tx-no"
|
||||
placeholder={t("search")}
|
||||
value={draft.txnNo}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-ext">{t("externalRefNo")}</Label>
|
||||
<Input
|
||||
id="tx-ext"
|
||||
placeholder={t("search")}
|
||||
value={draft.externalRefNo}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-account">{t("playerAccount")}</Label>
|
||||
<Input
|
||||
id="tx-account"
|
||||
placeholder={t("playerAccountPlaceholder")}
|
||||
value={draft.playerAccount}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-player">{t("playerId")}</Label>
|
||||
<Input
|
||||
id="tx-player"
|
||||
inputMode="numeric"
|
||||
placeholder={t("playerIdOptional")}
|
||||
value={draft.playerId}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-biz">{t("bizType")}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={
|
||||
draft.bizType === "" || !WALLET_TXN_BIZ_OPTIONS.some((o) => o.value === draft.bizType)
|
||||
? WALLET_FILTER_ALL
|
||||
: draft.bizType
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="tx-biz" className="h-8 w-full">
|
||||
<SelectValue>
|
||||
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
|
||||
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="tx-status">{t("status")}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={
|
||||
draft.statusCsv === "" || !WALLET_TXN_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
|
||||
? WALLET_FILTER_ALL
|
||||
: draft.statusCsv
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="tx-status" className="h-8 w-full">
|
||||
<SelectValue>
|
||||
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
|
||||
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
|
||||
<AdminDateRangeField
|
||||
id="tx-created-range"
|
||||
label={t("requestDateRange")}
|
||||
from={draft.createdFrom}
|
||||
to={draft.createdTo}
|
||||
onRangeChange={(r) =>
|
||||
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
|
||||
<span className="text-sm font-medium leading-none">{t("options")}</span>
|
||||
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={draft.abnormalOnly}
|
||||
onCheckedChange={(v) =>
|
||||
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
|
||||
}
|
||||
/>
|
||||
{t("abnormalOnlyPending")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="wallet-transactions-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("refreshCurrentPage")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{(loading && !data) || data ? (
|
||||
<>
|
||||
<div className="admin-table-shell overflow-x-auto rounded-md border">
|
||||
<Table id="wallet-transactions-table" className="min-w-[1180px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[10rem] whitespace-nowrap">{t("txnNo")}</TableHead>
|
||||
<TableHead className="min-w-[8rem] whitespace-nowrap">{t("externalRefNo")}</TableHead>
|
||||
<AdminAgentIdentityHeads />
|
||||
<AdminPlayerIdentityHeads />
|
||||
{showLedgerColumn ? (
|
||||
<TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead>
|
||||
) : null}
|
||||
<TableHead className="min-w-[6.5rem] whitespace-nowrap">{t("type")}</TableHead>
|
||||
<TableHead className="min-w-[6.5rem] whitespace-nowrap">{t("amount")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
|
||||
<TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("requestTime")}</TableHead>
|
||||
<TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("finishedTime")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={txnTableColSpan} />
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={txnTableColSpan} />
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="min-w-[10rem] max-w-[12rem] align-top">
|
||||
<CellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[8rem] max-w-[10rem] align-top">
|
||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
{showLedgerColumn ? (
|
||||
<TableCell className="align-top whitespace-nowrap">
|
||||
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="min-w-[6.5rem] max-w-[9rem] align-top text-xs">
|
||||
<span
|
||||
className="block truncate"
|
||||
title={walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
|
||||
>
|
||||
{walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={adminMoneyCellClassName("min-w-[6.5rem] text-right text-xs")}>
|
||||
<AdminTableMoney>
|
||||
{row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
|
||||
</AdminTableMoney>
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({row.direction === 1 ? t("in") : t("out")})
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="align-top whitespace-nowrap">
|
||||
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[8.5rem] align-top whitespace-nowrap font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[8.5rem] align-top whitespace-nowrap font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{formatTs(row.updated_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="wallet-transactions-per-page"
|
||||
total={data.total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerWalletPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const tRef = useTranslationRef(["wallet", "common"]);
|
||||
|
||||
116
src/modules/wallet/wallet-filter-utils.ts
Normal file
116
src/modules/wallet/wallet-filter-utils.ts
Normal 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;
|
||||
}
|
||||
56
src/modules/wallet/wallet-labels.ts
Normal file
56
src/modules/wallet/wallet-labels.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
421
src/modules/wallet/wallet-txns-panel.tsx
Normal file
421
src/modules/wallet/wallet-txns-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export type AgentNodeListData = {
|
||||
};
|
||||
|
||||
export type AgentProfilePayload = {
|
||||
relative_share_rate?: number;
|
||||
total_share_rate?: number;
|
||||
credit_limit?: number;
|
||||
rebate_limit?: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AdminAuditLogRow = {
|
||||
module_label: string;
|
||||
action_label: string;
|
||||
target_label: string;
|
||||
summary_label: string;
|
||||
before_json: Record<string, unknown> | null;
|
||||
after_json: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
|
||||
Reference in New Issue
Block a user