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.siteRequired": "Select an integration site",
|
||||||
"lineProvision.noUnboundSite": "No sites without a level-1 agent",
|
"lineProvision.noUnboundSite": "No sites without a level-1 agent",
|
||||||
"lineProvision.openIntegrationSites": "Go to integration sites",
|
"lineProvision.openIntegrationSites": "Go to integration sites",
|
||||||
"lineProvision.passwordHint": "At least 8 characters",
|
"lineProvision.passwordHint": "At least 6 characters",
|
||||||
"playersPanel.siteCode": "Line site",
|
"playersPanel.siteCode": "Line site",
|
||||||
"playersPanel.passwordHint": "At least 8 characters",
|
"playersPanel.passwordHint": "At least 6 characters",
|
||||||
"playersPanel.passwordMinLength": "Initial password must be at least 8 characters",
|
"playersPanel.passwordMinLength": "Initial password must be at least 6 characters",
|
||||||
"playersPanel.creditLimitInvalid": "Credit limit must be an integer ≥ 0",
|
"playersPanel.creditLimitInvalid": "Credit limit must be an integer ≥ 0",
|
||||||
"playersPanel.creditLimitExceeded": "Credit limit cannot exceed this agent’s available grant",
|
"playersPanel.creditLimitExceeded": "Credit limit cannot exceed this agent’s available grant",
|
||||||
"playersPanel.rebateRateInvalid": "Rebate rate must be between 0 and 100%",
|
"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";
|
export default function AgentsListRedirectPage() {
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
redirect("/admin/agents?view=list");
|
||||||
import { AgentsDirectoryConsole } from "@/modules/agents/agents-directory-console";
|
|
||||||
import {
|
|
||||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
|
||||||
PRD_AGENTS_ACCESS_ANY,
|
|
||||||
} from "@/lib/admin-prd";
|
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
|
||||||
|
|
||||||
export const metadata: Metadata = buildPageMetadata("agents", "listTitle");
|
|
||||||
|
|
||||||
export default function AgentsListPage() {
|
|
||||||
return (
|
|
||||||
<ModuleScaffold embedded>
|
|
||||||
<AdminPermissionGate
|
|
||||||
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
|
|
||||||
>
|
|
||||||
<AgentsDirectoryConsole />
|
|
||||||
</AdminPermissionGate>
|
|
||||||
</ModuleScaffold>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { AgentsConsole } from "@/modules/agents/agents-console";
|
import { AgentsManagementScreen } from "@/modules/agents/agents-management-screen";
|
||||||
import {
|
import {
|
||||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||||
PRD_AGENTS_ACCESS_ANY,
|
PRD_AGENTS_ACCESS_ANY,
|
||||||
@@ -16,7 +16,7 @@ export default function AgentsPage() {
|
|||||||
<AdminPermissionGate
|
<AdminPermissionGate
|
||||||
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
|
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
|
||||||
>
|
>
|
||||||
<AgentsConsole />
|
<AgentsManagementScreen />
|
||||||
</AdminPermissionGate>
|
</AdminPermissionGate>
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { DrawSubnav } from "@/modules/draws/draw-subnav";
|
import { DrawDetailShell } from "@/modules/draws/draw-detail-shell";
|
||||||
|
|
||||||
export default async function AdminDrawSegmentLayout(props: {
|
export default async function AdminDrawSegmentLayout(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -9,8 +9,7 @@ export default async function AdminDrawSegmentLayout(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold>
|
<ModuleScaffold>
|
||||||
<DrawSubnav drawId={drawId} />
|
<DrawDetailShell drawId={drawId}>{props.children}</DrawDetailShell>
|
||||||
{props.children}
|
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
|
import { PRD_REPORTS_PAGE_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
|
import { ReportsConsole } from "@/modules/reports/reports-console";
|
||||||
|
import type { ReportCategory } from "@/modules/reports/reports-definitions";
|
||||||
|
|
||||||
export const metadata: Metadata = buildPageMetadata("reports", "title");
|
export const metadata: Metadata = buildPageMetadata("reports", "title");
|
||||||
|
|
||||||
|
const VALID_CATEGORIES = new Set<ReportCategory>(["profit", "wallet", "risk", "audit"]);
|
||||||
|
|
||||||
export default async function AdminReportsCategoryPage({
|
export default async function AdminReportsCategoryPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ category: string }>;
|
params: Promise<{ category: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { category } = await params;
|
const { category } = await params;
|
||||||
if (!["profit", "wallet", "risk", "audit"].includes(category)) {
|
if (!VALID_CATEGORIES.has(category as ReportCategory)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
redirect("/admin/reports");
|
|
||||||
|
return (
|
||||||
|
<AdminPermissionGate requiredAny={PRD_REPORTS_PAGE_ACCESS_ANY}>
|
||||||
|
<Suspense fallback={<AdminLoadingState minHeight="12rem" />}>
|
||||||
|
<ReportsConsole initialCategory={category as ReportCategory} />
|
||||||
|
</Suspense>
|
||||||
|
</AdminPermissionGate>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,5 @@
|
|||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
|
||||||
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
|
||||||
import { ReportsConsole } from "@/modules/reports/reports-console";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
|
|
||||||
export default function AdminReportsPage() {
|
export default function AdminReportsPage() {
|
||||||
return (
|
redirect("/admin/reports/profit");
|
||||||
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
|
|
||||||
<Suspense fallback={<AdminLoadingState minHeight="12rem" />}>
|
|
||||||
<ReportsConsole />
|
|
||||||
</Suspense>
|
|
||||||
</AdminPermissionGate>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { WalletScopeHint } from "@/modules/wallet/wallet-scope-hint";
|
|
||||||
import { WalletSubnav } from "@/modules/wallet/wallet-subnav";
|
import { WalletSubnav } from "@/modules/wallet/wallet-subnav";
|
||||||
|
|
||||||
export default function AdminWalletLayout({ children }: { children: ReactNode }) {
|
export default function AdminWalletLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
|
<div className="mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<div className="sticky top-14 z-20 space-y-2 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
<div className="sticky top-14 z-20 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||||
<WalletSubnav />
|
<WalletSubnav />
|
||||||
<WalletScopeHint />
|
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -168,6 +168,24 @@
|
|||||||
@apply overflow-x-auto rounded-2xl border border-border/80 bg-card shadow-sm;
|
@apply overflow-x-auto rounded-2xl border border-border/80 bg-card shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flat table inside an existing card — same white surface, no nested panel or blue header tint */
|
||||||
|
.admin-table-inset {
|
||||||
|
@apply overflow-x-auto rounded-xl border border-border/80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-inset [data-slot="table-header"] {
|
||||||
|
@apply bg-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-inset [data-slot="table-head"][class*="sticky"],
|
||||||
|
.admin-table-inset [data-slot="table-cell"][class*="sticky"] {
|
||||||
|
background-color: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-inset [data-slot="table-row"]:hover > [data-slot="table-cell"][class*="sticky"] {
|
||||||
|
@apply bg-muted/20;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sticky columns need an opaque background so scrolled cells/headers do not show through */
|
/* Sticky columns need an opaque background so scrolled cells/headers do not show through */
|
||||||
[data-slot="table-head"][class*="sticky"] {
|
[data-slot="table-head"][class*="sticky"] {
|
||||||
@apply bg-muted;
|
@apply bg-muted;
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ADMIN_PERMISSION_PACKAGES } from "@/lib/admin-permission-packages";
|
import {
|
||||||
|
applyProfileLevel,
|
||||||
|
permissionProfileForGroup,
|
||||||
|
resolveProfileLevel,
|
||||||
|
type PermissionAccessLevel,
|
||||||
|
type PermissionGroupProfile,
|
||||||
|
} from "@/lib/admin-permission-profiles";
|
||||||
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
||||||
|
|
||||||
type PackageSelectorProps = {
|
type PackageSelectorProps = {
|
||||||
@@ -22,10 +19,7 @@ type PackageSelectorProps = {
|
|||||||
selectedSlugs: string[];
|
selectedSlugs: string[];
|
||||||
onChange: (next: string[]) => void;
|
onChange: (next: string[]) => void;
|
||||||
resolveGroupLabel: (key: string, fallback: string) => string;
|
resolveGroupLabel: (key: string, fallback: string) => string;
|
||||||
resolvePackageLabel: (key: string, fallback: string) => string;
|
isSuperAdmin?: boolean;
|
||||||
selectableSlugs?: string[] | null;
|
|
||||||
helperText?: string;
|
|
||||||
summaryText?: string;
|
|
||||||
emptyText: string;
|
emptyText: string;
|
||||||
heightClassName?: string;
|
heightClassName?: string;
|
||||||
};
|
};
|
||||||
@@ -33,24 +27,8 @@ type PackageSelectorProps = {
|
|||||||
type RenderGroup = {
|
type RenderGroup = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
packages: Array<{ key: string; label: string; slugs: string[] }>;
|
profile: PermissionGroupProfile;
|
||||||
};
|
levels: Array<{ key: PermissionAccessLevel; label: string; slugs: string[] }>;
|
||||||
|
|
||||||
const PACKAGE_LEVEL_ORDER: Record<string, number> = {
|
|
||||||
view: 10,
|
|
||||||
node_view: 10,
|
|
||||||
role_view: 11,
|
|
||||||
user_view: 12,
|
|
||||||
review: 20,
|
|
||||||
export: 20,
|
|
||||||
manage: 30,
|
|
||||||
node_manage: 30,
|
|
||||||
role_manage: 31,
|
|
||||||
user_manage: 32,
|
|
||||||
config: 30,
|
|
||||||
control: 30,
|
|
||||||
reopen: 32,
|
|
||||||
special: 40,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminPermissionPackageSelector({
|
export function AdminPermissionPackageSelector({
|
||||||
@@ -58,197 +36,101 @@ export function AdminPermissionPackageSelector({
|
|||||||
selectedSlugs,
|
selectedSlugs,
|
||||||
onChange,
|
onChange,
|
||||||
resolveGroupLabel,
|
resolveGroupLabel,
|
||||||
resolvePackageLabel,
|
isSuperAdmin = false,
|
||||||
selectableSlugs = null,
|
|
||||||
helperText,
|
|
||||||
summaryText,
|
|
||||||
emptyText,
|
emptyText,
|
||||||
heightClassName = "h-[52vh]",
|
heightClassName = "h-[52vh]",
|
||||||
}: PackageSelectorProps): React.ReactElement {
|
}: PackageSelectorProps): React.ReactElement {
|
||||||
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]);
|
const { t } = useTranslation("adminUsers");
|
||||||
const catalogSlugSet = useMemo(
|
const catalogSlugSet = useMemo(
|
||||||
() => new Set((catalog?.permissions ?? []).map((permission) => permission.slug)),
|
() => new Set((catalog?.permissions ?? []).map((permission) => permission.slug)),
|
||||||
[catalog],
|
[catalog],
|
||||||
);
|
);
|
||||||
const allowedSet = useMemo(
|
|
||||||
() => (selectableSlugs ? new Set(selectableSlugs) : null),
|
|
||||||
[selectableSlugs],
|
|
||||||
);
|
|
||||||
|
|
||||||
const groups = useMemo<RenderGroup[]>(() => {
|
const groups = useMemo<RenderGroup[]>(() => {
|
||||||
const defs = catalog?.permission_menu_groups ?? [];
|
const defs = catalog?.permission_menu_groups ?? [];
|
||||||
const out: RenderGroup[] = [];
|
const out: RenderGroup[] = [];
|
||||||
|
|
||||||
for (const group of defs) {
|
for (const group of defs) {
|
||||||
const bundles = ADMIN_PERMISSION_PACKAGES[group.key] ?? [];
|
const profile = permissionProfileForGroup(group.key);
|
||||||
if (bundles.length === 0) {
|
if (profile === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const renderedPackages = bundles
|
if (profile.platformOnly && !isSuperAdmin) {
|
||||||
.map((bundle) => {
|
|
||||||
const slugs = bundle.slugs.filter((slug) => {
|
|
||||||
if (!catalogSlugSet.has(slug)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (allowedSet && !allowedSet.has(slug)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
key: bundle.key,
|
|
||||||
label: resolvePackageLabel(bundle.key, bundle.label),
|
|
||||||
slugs,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((bundle) => bundle.slugs.length > 0);
|
|
||||||
if (renderedPackages.length === 0) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const levels = profile.levels
|
||||||
|
.map((level) => ({
|
||||||
|
key: level.key,
|
||||||
|
label: t(level.labelKey),
|
||||||
|
slugs: level.slugs.filter((slug) => catalogSlugSet.has(slug)),
|
||||||
|
}))
|
||||||
|
.filter((level) => level.key === "none" || level.slugs.length > 0);
|
||||||
|
|
||||||
|
if (levels.length <= 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
out.push({
|
out.push({
|
||||||
key: group.key,
|
key: group.key,
|
||||||
label: resolveGroupLabel(group.key, group.label),
|
label: resolveGroupLabel(group.key, group.label),
|
||||||
packages: renderedPackages,
|
profile,
|
||||||
|
levels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}, [allowedSet, catalog, catalogSlugSet, resolveGroupLabel, resolvePackageLabel]);
|
}, [catalog, catalogSlugSet, isSuperAdmin, resolveGroupLabel, t]);
|
||||||
|
|
||||||
const bundleCount = useMemo(
|
if (groups.length === 0) {
|
||||||
() => groups.reduce((sum, group) => sum + group.packages.length, 0),
|
|
||||||
[groups],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (groups.length === 0 || bundleCount === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-dashed p-4">
|
<div className="rounded-lg border border-dashed p-4">
|
||||||
<AdminNoResourceState compact className="py-4" />
|
<AdminNoResourceState compact className="py-4" message={emptyText} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleBundle = (group: RenderGroup, bundleKey: string, slugs: string[], checked: boolean) => {
|
const setGroupLevel = (group: RenderGroup, levelKey: PermissionAccessLevel) => {
|
||||||
const next = new Set(selectedSet);
|
onChange(applyProfileLevel(group.profile, levelKey, selectedSlugs));
|
||||||
const currentLevel = PACKAGE_LEVEL_ORDER[bundleKey] ?? 10;
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
const implied = group.packages
|
|
||||||
.filter((item) => (PACKAGE_LEVEL_ORDER[item.key] ?? 10) <= currentLevel)
|
|
||||||
.flatMap((item) => item.slugs);
|
|
||||||
for (const slug of implied.length > 0 ? implied : slugs) {
|
|
||||||
next.add(slug);
|
|
||||||
}
|
|
||||||
for (const item of group.packages) {
|
|
||||||
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
|
|
||||||
for (const slug of item.slugs) {
|
|
||||||
next.delete(slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const slug of slugs) {
|
|
||||||
next.delete(slug);
|
|
||||||
}
|
|
||||||
if (bundleKey === "manage" || bundleKey === "reopen") {
|
|
||||||
for (const item of group.packages) {
|
|
||||||
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
|
|
||||||
for (const slug of item.slugs) {
|
|
||||||
next.delete(slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(Array.from(next).sort());
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleGroup = (group: RenderGroup, checked: boolean) => {
|
|
||||||
const next = new Set(selectedSet);
|
|
||||||
if (checked) {
|
|
||||||
const viewBundle =
|
|
||||||
group.packages.find((item) => item.key === "view") ?? group.packages[0];
|
|
||||||
for (const slug of viewBundle?.slugs ?? []) {
|
|
||||||
next.add(slug);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const slug of group.packages.flatMap((item) => item.slugs)) {
|
|
||||||
next.delete(slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onChange(Array.from(next).sort());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className={cn("overflow-y-auto rounded-lg border bg-card", heightClassName)}>
|
||||||
{helperText || summaryText ? (
|
<ul className="divide-y divide-border/60">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
{groups.map((group) => {
|
||||||
<span>{helperText}</span>
|
const activeLevel = resolveProfileLevel(group.profile, selectedSlugs);
|
||||||
<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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={group.key} className="hover:bg-muted/10 transition-colors">
|
<li
|
||||||
<td style={{ textAlign: "left" }} className="align-top py-4 pl-4">
|
key={group.key}
|
||||||
<label
|
className="flex flex-col gap-2.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
style={{ display: "flex", justifyContent: "flex-start" }}
|
>
|
||||||
className="cursor-pointer items-center gap-2 font-medium w-full"
|
<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
|
{level.label}
|
||||||
checked={groupChecked}
|
</button>
|
||||||
onCheckedChange={(value) =>
|
);
|
||||||
toggleGroup(group, value === true)
|
})}
|
||||||
}
|
</div>
|
||||||
/>
|
</li>
|
||||||
<span>{group.label}</span>
|
);
|
||||||
</label>
|
})}
|
||||||
</td>
|
</ul>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ export function LoginForm() {
|
|||||||
const passwordIssue = validateAdminPassword(password);
|
const passwordIssue = validateAdminPassword(password);
|
||||||
if (passwordIssue === "too_short") {
|
if (passwordIssue === "too_short") {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("passwordMinLength", { defaultValue: "密码至少需要 8 个字符" }),
|
t("passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"noRoles": "No roles available yet. Wait for the list to finish loading and try again.",
|
"noRoles": "No roles available yet. Wait for the list to finish loading and try again.",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordOptional": "Password (optional)",
|
"passwordOptional": "Password (optional)",
|
||||||
"passwordPlaceholderCreate": "At least 8 characters",
|
"passwordPlaceholderCreate": "at least 6 characters",
|
||||||
"passwordPlaceholderEdit": "Leave empty to keep unchanged",
|
"passwordPlaceholderEdit": "Leave empty to keep unchanged",
|
||||||
"rolesDescription": "After creation, adjust per-site role bindings in Assign Roles.",
|
"rolesDescription": "After creation, adjust per-site role bindings in Assign Roles.",
|
||||||
"rolesRequired": "Roles (at least one)",
|
"rolesRequired": "Roles (at least one)",
|
||||||
@@ -56,9 +56,9 @@
|
|||||||
"listTitle": "Admin user list",
|
"listTitle": "Admin user list",
|
||||||
"loadFailed": "Failed to load admin list",
|
"loadFailed": "Failed to load admin list",
|
||||||
"modelGuide": "Accounts bind roles only. Maintain functional permissions in Role Management.",
|
"modelGuide": "Accounts bind roles only. Maintain functional permissions in Role Management.",
|
||||||
"newPasswordMin": "New password must be at least 8 characters",
|
"newPasswordMin": "New password must be at least 6 characters",
|
||||||
"nicknameRequired": "Enter a nickname",
|
"nicknameRequired": "Enter a nickname",
|
||||||
"passwordMin": "Password must be at least 8 characters",
|
"passwordMin": "Password must be at least 6 characters",
|
||||||
"permissionDialog": {
|
"permissionDialog": {
|
||||||
"rolePermissionCount": "Contains {{count}} functional permissions",
|
"rolePermissionCount": "Contains {{count}} functional permissions",
|
||||||
"rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management. Save roles per site.",
|
"rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management. Save roles per site.",
|
||||||
@@ -179,9 +179,18 @@
|
|||||||
"roleListTitle": "Role Management",
|
"roleListTitle": "Role Management",
|
||||||
"roleLoadFailed": "Failed to load role list",
|
"roleLoadFailed": "Failed to load role list",
|
||||||
"rolePermissionDialog": {
|
"rolePermissionDialog": {
|
||||||
"packageHint": "Checking a module row on the left grants View only. Select Manage separately for data entry, close, draw, and other admin actions.",
|
"title": "Role permissions",
|
||||||
"title": "Role Permissions"
|
"titleWithName": "{{name}}"
|
||||||
},
|
},
|
||||||
|
"permissionAccess": {
|
||||||
|
"none": "No access",
|
||||||
|
"view": "View only",
|
||||||
|
"review": "Can review",
|
||||||
|
"export": "Can export",
|
||||||
|
"manage": "Can manage",
|
||||||
|
"special": "Includes privileged actions"
|
||||||
|
},
|
||||||
|
|
||||||
"rolePermissionSaveFailed": "Failed to save role permissions",
|
"rolePermissionSaveFailed": "Failed to save role permissions",
|
||||||
"rolePermissionSaveSuccess": "Role permissions updated",
|
"rolePermissionSaveSuccess": "Role permissions updated",
|
||||||
"roleRequired": "Select at least one role",
|
"roleRequired": "Select at least one role",
|
||||||
@@ -190,6 +199,7 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"name": "Role",
|
"name": "Role",
|
||||||
"permissions": "Permission count",
|
"permissions": "Permission count",
|
||||||
|
"enabledAreas": "Enabled modules",
|
||||||
"slug": "Role Code",
|
"slug": "Role Code",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
"noUnboundSite": "No sites without a level-1 agent",
|
"noUnboundSite": "No sites without a level-1 agent",
|
||||||
"openIntegrationSites": "Go to integration sites",
|
"openIntegrationSites": "Go to integration sites",
|
||||||
"password": "Initial password",
|
"password": "Initial password",
|
||||||
"passwordHint": "At least 8 characters",
|
"passwordHint": "at least 6 characters",
|
||||||
"secretsOnce": "Integration secrets are shown once — save them now",
|
"secretsOnce": "Integration secrets are shown once — save them now",
|
||||||
"siteCode": "Integration site",
|
"siteCode": "Integration site",
|
||||||
"siteCodePlaceholder": "Select site",
|
"siteCodePlaceholder": "Select site",
|
||||||
@@ -82,7 +82,8 @@
|
|||||||
"downlineEmptyShort": "No direct downline yet.",
|
"downlineEmptyShort": "No direct downline yet.",
|
||||||
"downlineEmptyTitle": "No direct downline yet",
|
"downlineEmptyTitle": "No direct downline yet",
|
||||||
"editAccount": "Account & status",
|
"editAccount": "Account & status",
|
||||||
"editAgent": "Edit agent",
|
"editAgent": "Edit",
|
||||||
|
"parentMeta": "Parent {{name}}",
|
||||||
"editCurrent": "Edit this agent",
|
"editCurrent": "Edit this agent",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"kicker": "Credit share · Agent tree",
|
"kicker": "Credit share · Agent tree",
|
||||||
@@ -112,6 +113,8 @@
|
|||||||
"tabDownline": "Downline",
|
"tabDownline": "Downline",
|
||||||
"tabOverview": "Overview",
|
"tabOverview": "Overview",
|
||||||
"tabPlayers": "Players",
|
"tabPlayers": "Players",
|
||||||
|
"tabProfileShort": "Share & credit",
|
||||||
|
"openInTree": "Open in tree",
|
||||||
"createDirectPlayer": "Create direct player",
|
"createDirectPlayer": "Create direct player",
|
||||||
"createDownline": "Create child agent",
|
"createDownline": "Create child agent",
|
||||||
"editDownline": "Edit agent",
|
"editDownline": "Edit agent",
|
||||||
@@ -125,6 +128,11 @@
|
|||||||
},
|
},
|
||||||
"listFlatHint": "All operating agents in a flat list. Use row actions to add a child under a specific agent.",
|
"listFlatHint": "All operating agents in a flat list. Use row actions to add a child under a specific agent.",
|
||||||
"listSearch": "Search name / code / login",
|
"listSearch": "Search name / code / login",
|
||||||
|
"viewModes": {
|
||||||
|
"label": "View",
|
||||||
|
"tree": "Tree",
|
||||||
|
"list": "List"
|
||||||
|
},
|
||||||
"listTitle": "Agents",
|
"listTitle": "Agents",
|
||||||
"loadFailed": "Failed to load agent tree",
|
"loadFailed": "Failed to load agent tree",
|
||||||
"modelGuide": "Agent layer controls data scope and delegation ceiling. Account permissions are assigned through roles.",
|
"modelGuide": "Agent layer controls data scope and delegation ceiling. Account permissions are assigned through roles.",
|
||||||
@@ -134,9 +142,9 @@
|
|||||||
"noAccess": "You do not have permission to manage agents. Contact an administrator.",
|
"noAccess": "You do not have permission to manage agents. Contact an administrator.",
|
||||||
"pageGuide": "Manage the agent tree, agent roles, agent accounts, and delegation ceilings here. Platform accounts and platform roles stay in platform governance pages.",
|
"pageGuide": "Manage the agent tree, agent roles, agent accounts, and delegation ceilings here. Platform accounts and platform roles stay in platform governance pages.",
|
||||||
"parentAgent": "Parent",
|
"parentAgent": "Parent",
|
||||||
"passwordMinLength": "Password must be at least 8 characters",
|
"passwordMinLength": "Password must be at least 6 characters",
|
||||||
"passwordOptionalHint": "Leave empty to keep unchanged, or enter an 8-character password",
|
"passwordOptionalHint": "Leave empty to keep unchanged, or enter a 6-character password",
|
||||||
"passwordPlaceholder": "Enter an 8-character password",
|
"passwordPlaceholder": "Enter a 6-character password",
|
||||||
"passwordRequired": "Password is required",
|
"passwordRequired": "Password is required",
|
||||||
"path": "Path",
|
"path": "Path",
|
||||||
"playersPanel": {
|
"playersPanel": {
|
||||||
@@ -157,8 +165,8 @@
|
|||||||
"initialPassword": "Initial password",
|
"initialPassword": "Initial password",
|
||||||
"loginRequired": "Enter login username and initial password",
|
"loginRequired": "Enter login username and initial password",
|
||||||
"loginUsername": "Login username",
|
"loginUsername": "Login username",
|
||||||
"passwordHint": "At least 8 characters",
|
"passwordHint": "at least 6 characters",
|
||||||
"passwordMinLength": "Initial password must be at least 8 characters",
|
"passwordMinLength": "Initial password must be at least 6 characters",
|
||||||
"playerRef": "Player ref",
|
"playerRef": "Player ref",
|
||||||
"rebateInherited": "Inherit agent default rebate",
|
"rebateInherited": "Inherit agent default rebate",
|
||||||
"rebateRate": "Rebate rate (%)",
|
"rebateRate": "Rebate rate (%)",
|
||||||
@@ -168,7 +176,11 @@
|
|||||||
"riskTagsPlaceholder": "Comma-separated",
|
"riskTagsPlaceholder": "Comma-separated",
|
||||||
"scopedTo": "Direct players: {{agent}}",
|
"scopedTo": "Direct players: {{agent}}",
|
||||||
"siteCode": "Line site",
|
"siteCode": "Line site",
|
||||||
"usernameNickname": "Username / nickname"
|
"usernameNickname": "Username / nickname",
|
||||||
|
"searchPh": "Search player account / ID",
|
||||||
|
"manageSettlement": "Process bill",
|
||||||
|
"noPendingBillsGoCenter": "No pending bills for this player. Opened settlement center.",
|
||||||
|
"billingLoadFailed": "Failed to load bills"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"availableCredit": "Available to grant {{amount}}",
|
"availableCredit": "Available to grant {{amount}}",
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
{
|
{
|
||||||
"title": "Audit Logs",
|
"title": "Activity Log",
|
||||||
"moduleCode": "Module code",
|
"filterModule": "Business area",
|
||||||
"actionCode": "Action code",
|
"filterModuleAll": "All areas",
|
||||||
"operatorType": "Operator type",
|
"filterOperatorType": "Actor type",
|
||||||
"operatorIdPlaceholder": "Enter operator ID",
|
"filterOperatorTypeAll": "All",
|
||||||
"exactMatch": "Exact match",
|
"operatorIdPlaceholder": "Optional operator ID",
|
||||||
"operatorTypePlaceholder": "For example admin / system",
|
"operator": "Actor",
|
||||||
"operator": "Operator",
|
"summary": "What happened",
|
||||||
"module": "Module",
|
"target": "Related to",
|
||||||
"action": "Action",
|
|
||||||
"target": "Target",
|
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
"empty": "No data",
|
"empty": "No records",
|
||||||
|
"moduleOptions": {
|
||||||
|
"agent": "Agents",
|
||||||
|
"system": "System admin",
|
||||||
|
"settings": "Settings",
|
||||||
|
"integration": "Integration sites",
|
||||||
|
"player_manage": "Player management",
|
||||||
|
"player_service": "Player service",
|
||||||
|
"risk_cap": "Risk caps",
|
||||||
|
"odds": "Odds",
|
||||||
|
"play_config": "Play config",
|
||||||
|
"settlement": "Settlement",
|
||||||
|
"report_jobs": "Report export",
|
||||||
|
"reconcile_jobs": "Reconcile jobs",
|
||||||
|
"draw": "Draws",
|
||||||
|
"wallet": "Wallet",
|
||||||
|
"reconcile": "Reconcile",
|
||||||
|
"jackpot": "Jackpot",
|
||||||
|
"dashboard": "Dashboard"
|
||||||
|
},
|
||||||
"operatorTypes": {
|
"operatorTypes": {
|
||||||
"admin": "Admin",
|
"admin": "Admin user",
|
||||||
"player": "Player",
|
"player": "Player",
|
||||||
"system": "System"
|
"system": "System"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"captchaLoadFailed": "Failed to load captcha. Check the API or network.",
|
"captchaLoadFailed": "Failed to load captcha. Check the API or network.",
|
||||||
"apiBaseMissingToast": "API proxy is not enabled: check LOTTERY_API_UPSTREAM points to Laravel",
|
"apiBaseMissingToast": "API proxy is not enabled: check LOTTERY_API_UPSTREAM points to Laravel",
|
||||||
"captchaRequired": "Refresh the captcha first",
|
"captchaRequired": "Refresh the captcha first",
|
||||||
|
"passwordMinLength": "Password must be at least 6 characters",
|
||||||
"welcome": "Welcome, {{name}}",
|
"welcome": "Welcome, {{name}}",
|
||||||
"networkFailed": "Network request failed",
|
"networkFailed": "Network request failed",
|
||||||
"loginFailed": "Login failed"
|
"loginFailed": "Login failed"
|
||||||
|
|||||||
@@ -51,7 +51,8 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "{{field}} is required",
|
"required": "{{field}} is required",
|
||||||
"passwordMismatch": "Passwords do not match"
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"passwordMinLength": "Password must be at least 6 characters"
|
||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
@@ -156,7 +157,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"admin_users": "Admin Users",
|
"admin_users": "Platform accounts",
|
||||||
"admin_roles": "Role Management",
|
"admin_roles": "Role Management",
|
||||||
"players": "Players",
|
"players": "Players",
|
||||||
"currencies": "Currencies",
|
"currencies": "Currencies",
|
||||||
@@ -171,21 +172,20 @@
|
|||||||
"settlement": "Settlement",
|
"settlement": "Settlement",
|
||||||
"jackpot": "Jackpot",
|
"jackpot": "Jackpot",
|
||||||
"reconcile": "Reconcile",
|
"reconcile": "Reconcile",
|
||||||
"tickets": "Ticket list",
|
"tickets": "Tickets",
|
||||||
"audit": "Audit Logs",
|
"audit": "Audit Logs",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"account": "Account settings",
|
"account": "Account settings",
|
||||||
"integration": "Integration",
|
"integration": "Integration",
|
||||||
"agents": "Agent lines",
|
"agents": "Agent management",
|
||||||
"agent_list": "Agent list",
|
"settlement_center": "Credit settlement",
|
||||||
"settlement_center": "Settlement center",
|
|
||||||
"config": "Operations config"
|
"config": "Operations config"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
"group": {
|
"group": {
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"agent": "Agent organization",
|
"agent": "Agents",
|
||||||
"operations": "Operations",
|
"operations": "Operations",
|
||||||
"finance": "Finance & reports",
|
"finance": "Finance & reports",
|
||||||
"rules": "Rules & parameters",
|
"rules": "Rules & parameters",
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"adminNicknameRequired": "Site admin nickname is required",
|
"adminNicknameRequired": "Site admin nickname is required",
|
||||||
"adminPasswordRequired": "Initial site admin password must be at least 8 characters",
|
"adminPasswordRequired": "Initial site admin password must be at least 6 characters",
|
||||||
"adminUsernameRequired": "Site admin username is required",
|
"adminUsernameRequired": "Site admin username is required",
|
||||||
"codeRequired": "site_code is required",
|
"codeRequired": "site_code is required",
|
||||||
"required": "Site name is required"
|
"required": "Site name is required"
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"adminEmail": "Enter email",
|
"adminEmail": "Enter email",
|
||||||
"adminNickname": "Enter account nickname",
|
"adminNickname": "Enter account nickname",
|
||||||
"adminPassword": "At least 8 characters",
|
"adminPassword": "at least 6 characters",
|
||||||
"adminUsername": "Enter admin username",
|
"adminUsername": "Enter admin username",
|
||||||
"code": "Enter site identifier, for example partner-a",
|
"code": "Enter site identifier, for example partner-a",
|
||||||
"connectivityPlayerId": "Enter player ID, for example 10001",
|
"connectivityPlayerId": "Enter player ID, for example 10001",
|
||||||
@@ -527,10 +527,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
|
"pageTitle": "System settings",
|
||||||
|
|
||||||
"confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.",
|
"confirmSaveCurrencyFormatDescription": "This updates decimal places and separators.",
|
||||||
"confirmSaveCurrencyFormatTitle": "Save currency display format?",
|
"confirmSaveCurrencyFormatTitle": "Save currency display format?",
|
||||||
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
|
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
|
||||||
"confirmSaveDrawDescription": "This updates draw review, schedule timing, and cooldown in this block only.",
|
"confirmSaveDrawDescription": "This updates manual draw review and cooldown duration only.",
|
||||||
"confirmSaveDrawTitle": "Save draw parameters?",
|
"confirmSaveDrawTitle": "Save draw parameters?",
|
||||||
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed.",
|
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed.",
|
||||||
"confirmSaveFrontendTitle": "Save front-end display settings?",
|
"confirmSaveFrontendTitle": "Save front-end display settings?",
|
||||||
@@ -591,12 +593,33 @@
|
|||||||
"saveSettlementSuccess": "Settlement automation saved",
|
"saveSettlementSuccess": "Settlement automation saved",
|
||||||
"saveSuccess": "System settings saved",
|
"saveSuccess": "System settings saved",
|
||||||
"sections": {
|
"sections": {
|
||||||
|
"deployment": "Deployment (read-only)",
|
||||||
|
"deploymentDescription": "Default currency and draw schedule are set in server .env; this block shows effective values.",
|
||||||
"currencyFormat": "Currency display format",
|
"currencyFormat": "Currency display format",
|
||||||
"currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).",
|
"currencyFormatDescription": "Decimals and separators for amounts across the site (separate from currency master data).",
|
||||||
"draw": "Draw schedule and review",
|
"draw": "Draw review",
|
||||||
"drawDescription": "Controls draw timing, close window, manual review, and cooldown. Only changed fields in this block are submitted.",
|
"drawDescription": "Manual review after RNG and post-publish cooldown.",
|
||||||
"settlement": "Settlement automation",
|
"settlement": "Settlement automation",
|
||||||
"settlementDescription": "Controls whether tick auto-runs settlement, approval, and payout. Only changed fields in this block are submitted."
|
"settlementDescription": "In order: auto settlement → auto approve → auto payout. When off, handle manually in the relevant module."
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"footer": "Restart PHP or refresh config cache after changes. Contact ops if unsure.",
|
||||||
|
"fields": {
|
||||||
|
"default_currency": "Default currency",
|
||||||
|
"draw_timezone": "Draw timezone",
|
||||||
|
"draw_interval_minutes": "Draw interval (minutes)",
|
||||||
|
"draw_betting_window_seconds": "Betting window (seconds)",
|
||||||
|
"draw_close_before_draw_seconds": "Close before draw (seconds)",
|
||||||
|
"draw_buffer_draws_ahead": "Pre-generated future draws"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"default_currency": "Default for new players when currency is unspecified",
|
||||||
|
"draw_timezone": "Timezone for draw schedule and display",
|
||||||
|
"draw_interval_minutes": "Minutes between consecutive draw times",
|
||||||
|
"draw_betting_window_seconds": "How long betting stays open each draw",
|
||||||
|
"draw_close_before_draw_seconds": "Seconds before draw time to stop accepting bets",
|
||||||
|
"draw_buffer_draws_ahead": "How many future draws the scheduler pre-creates"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"states": {
|
"states": {
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
@@ -630,19 +653,17 @@
|
|||||||
"count": "{{count}} items",
|
"count": "{{count}} items",
|
||||||
"current": "Current",
|
"current": "Current",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteConfirmDescription": "Version ID {{id}} (version_no {{version}}) will be permanently deleted. Active versions cannot be deleted.",
|
"deleteConfirmDescription": "Version v{{version}} will be deleted. This cannot be undone.",
|
||||||
"deleteConfirmTitle": "Delete this version?",
|
"deleteConfirmTitle": "Delete this version?",
|
||||||
"effectiveAt": "Effective at: {{value}}",
|
"effectiveAt": "Effective at: {{value}}",
|
||||||
"empty": "No version records yet.",
|
"empty": "No version records yet.",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"moreActions": "More actions for v{{version}}",
|
"moreActions": "More actions for v{{version}}",
|
||||||
"noneSelected": "No version selected",
|
"noneSelected": "Select version",
|
||||||
"note": "Note: {{value}}",
|
"note": "Note: {{value}}",
|
||||||
"rollback": "Rollback",
|
"rollback": "Rollback",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"sheetDescription": "Choose a version to view on this page. Drafts are editable, while active and archived versions are read-only.",
|
"sheetTitle": "Versions",
|
||||||
"sheetTitle": "Switch configuration version",
|
|
||||||
"switch": "Switch version",
|
|
||||||
"view": "View"
|
"view": "View"
|
||||||
},
|
},
|
||||||
"versionToolbar": {
|
"versionToolbar": {
|
||||||
|
|||||||
@@ -70,8 +70,8 @@
|
|||||||
"batchSummaryPending": "{{count}} pending",
|
"batchSummaryPending": "{{count}} pending",
|
||||||
"batchSummaryPublished": "{{count}} published",
|
"batchSummaryPublished": "{{count}} published",
|
||||||
"noResultBatchesYet": "No result batches yet.",
|
"noResultBatchesYet": "No result batches yet.",
|
||||||
"reviewQueueHint": "After results are generated, continue in Review & publish.",
|
"reviewQueueHint": "",
|
||||||
"goToReviewTab": "Review & publish",
|
"goToReviewTab": "Publish",
|
||||||
"businessDate": "Business date",
|
"businessDate": "Business date",
|
||||||
"sequenceNo": "Sequence no.",
|
"sequenceNo": "Sequence no.",
|
||||||
"plannedDraw": "Planned draw",
|
"plannedDraw": "Planned draw",
|
||||||
@@ -96,12 +96,12 @@
|
|||||||
"published": "Published",
|
"published": "Published",
|
||||||
"viewFinance": "View draw finance",
|
"viewFinance": "View draw finance",
|
||||||
"drawActions": "Draw actions",
|
"drawActions": "Draw actions",
|
||||||
"drawActionsDesc": "Manual close / cancel / RNG / reopen / settlement all call backend APIs directly.",
|
"drawActionsDesc": "",
|
||||||
"manualClose": "Manual close",
|
"manualClose": "Manual close",
|
||||||
"cancelDraw": "Cancel draw",
|
"cancelDraw": "Cancel draw",
|
||||||
"cancelBeforeDraw": "Cancel before draw",
|
"cancelBeforeDraw": "Cancel before draw",
|
||||||
"rngDraw": "RNG draw",
|
"rngDraw": "RNG draw",
|
||||||
"rngAutoGenerate": "RNG auto generate",
|
"rngAutoGenerate": "Auto draw",
|
||||||
"reopen": "Reopen",
|
"reopen": "Reopen",
|
||||||
"cooldownReopen": "Reopen in cooldown",
|
"cooldownReopen": "Reopen in cooldown",
|
||||||
"runSettlement": "Run settlement",
|
"runSettlement": "Run settlement",
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
"actualBet": "Actual bet deducted",
|
"actualBet": "Actual bet deducted",
|
||||||
"currentPayout": "Current payout total",
|
"currentPayout": "Current payout total",
|
||||||
"grossProfit": "Approx. gross profit",
|
"grossProfit": "Approx. gross profit",
|
||||||
"settlementBatchList": "Settlement batch list (filter by draw)",
|
"settlementBatchList": "Settlement records",
|
||||||
"relatedSettlementBatches": "Related settlement batches",
|
"relatedSettlementBatches": "Settlement batches",
|
||||||
"noSettlementBatches": "No settlement batch records.",
|
"noSettlementBatches": "No settlement batch records.",
|
||||||
"ticketCount": "Tickets",
|
"ticketCount": "Tickets",
|
||||||
"winCount": "Wins",
|
"winCount": "Wins",
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
"resultsTitle": "Results",
|
"resultsTitle": "Results",
|
||||||
"reviewAndPublish": "Review / publish",
|
"reviewAndPublish": "Review / publish",
|
||||||
"viewReviewQueue": "View review queue",
|
"viewReviewQueue": "View review queue",
|
||||||
"noPublishedBatch": "No published batches.",
|
"noPublishedBatch": "None",
|
||||||
"version": "Version v{{version}}",
|
"version": "Version v{{version}}",
|
||||||
"sourceType": "Source {{source}}",
|
"sourceType": "Source {{source}}",
|
||||||
"manualEntry": "Manual",
|
"manualEntry": "Manual",
|
||||||
@@ -135,28 +135,28 @@
|
|||||||
"tail3": "Last 3",
|
"tail3": "Last 3",
|
||||||
"tail2": "Last 2",
|
"tail2": "Last 2",
|
||||||
"headTail": "Head/Tail",
|
"headTail": "Head/Tail",
|
||||||
"manualResultEntry": "Manual result entry",
|
"manualResultEntry": "Enter numbers",
|
||||||
"currentStatusAndDraft": "Current status {{status}}. Saving creates a pending batch and does not publish it.",
|
"currentStatusAndDraft": "",
|
||||||
"currentStatusLabel": "Current status",
|
"currentStatusLabel": "Status",
|
||||||
"currentStatusDraftHint": "Saving creates a pending batch and does not publish it.",
|
"currentStatusDraftHint": "",
|
||||||
"hallPreviewStatusLabel": "Hall preview",
|
"hallPreviewStatusLabel": "Hall preview",
|
||||||
"enter23Numbers": "Please enter all 23 groups of 4 digits",
|
"enter23Numbers": "Please enter all 23 groups of 4 digits",
|
||||||
"draftSaved": "Draft v{{version}} saved, waiting to be published",
|
"draftSaved": "Saved v{{version}}",
|
||||||
"saveFailed": "Failed to save",
|
"saveFailed": "Failed to save",
|
||||||
"fillRandomNumbers": "Fill randomly",
|
"fillRandomNumbers": "Fill randomly",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"saveDraft": "Save draft",
|
"saveDraft": "Save draft",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"pendingBatches": "Pending batches",
|
"pendingBatches": "Pending publish",
|
||||||
"noPendingBatches": "There are no batches pending review.",
|
"noPendingBatches": "None",
|
||||||
"batchId": "Batch ID",
|
"batchId": "Batch",
|
||||||
"numberCount": "Number count",
|
"numberCount": "Numbers",
|
||||||
"reviewAndPublishAction": "Review and publish",
|
"reviewAndPublishAction": "Publish",
|
||||||
"discardPendingBatch": "Delete draft",
|
"discardPendingBatch": "Delete",
|
||||||
"discardingPendingBatch": "Deleting…",
|
"discardingPendingBatch": "Deleting…",
|
||||||
"discardPendingBatchSuccess": "Pending batch removed. You can re-enter numbers or run RNG.",
|
"discardPendingBatchSuccess": "Pending batch removed. You can re-enter numbers or run RNG.",
|
||||||
"discardPendingBatchFailed": "Delete failed",
|
"discardPendingBatchFailed": "Delete failed",
|
||||||
"publishReadOnlyHint": "This page is read-only for verification. To change numbers, delete this batch and save a new draft under manual entry.",
|
"publishReadOnlyHint": "",
|
||||||
"noPublishPermission": "No publish permission",
|
"noPublishPermission": "No publish permission",
|
||||||
"batchNotFound": "Batch not found",
|
"batchNotFound": "Batch not found",
|
||||||
"batchNotFoundDesc": "Return to the review list and confirm the batch ID.",
|
"batchNotFoundDesc": "Return to the review list and confirm the batch ID.",
|
||||||
@@ -164,8 +164,8 @@
|
|||||||
"publishTitle": "Publish",
|
"publishTitle": "Publish",
|
||||||
"cannotPublish": "Cannot publish",
|
"cannotPublish": "Cannot publish",
|
||||||
"cannotPublishDesc": "Current batch status is '{{status}}'.",
|
"cannotPublishDesc": "Current batch status is '{{status}}'.",
|
||||||
"checkBeforePublish": "Check the numbers before publishing",
|
"checkBeforePublish": "",
|
||||||
"checkBeforePublishDesc": "Publish only after confirming the numbers.",
|
"checkBeforePublishDesc": "",
|
||||||
"publishedView": "View published result",
|
"publishedView": "View published result",
|
||||||
"confirmPublish": "Confirm publish",
|
"confirmPublish": "Confirm publish",
|
||||||
"submitting": "Submitting…",
|
"submitting": "Submitting…",
|
||||||
@@ -173,10 +173,10 @@
|
|||||||
"publishFailed": "Publish failed",
|
"publishFailed": "Publish failed",
|
||||||
"sourceTypeFull": "Source: {{source}} · Items: {{count}}/23 · RNG hash: {{hash}}",
|
"sourceTypeFull": "Source: {{source}} · Items: {{count}}/23 · RNG hash: {{hash}}",
|
||||||
"subnav": {
|
"subnav": {
|
||||||
"status": "Draw status",
|
"status": "Overview",
|
||||||
"results": "Results",
|
"results": "Results",
|
||||||
"finance": "Draw finance",
|
"finance": "Finance",
|
||||||
"review": "Review & publish",
|
"review": "Publish",
|
||||||
"riskOccupancy": "Risk occupancy",
|
"riskOccupancy": "Risk occupancy",
|
||||||
"riskLockLogs": "Lock logs",
|
"riskLockLogs": "Lock logs",
|
||||||
"riskHot": "Hot numbers",
|
"riskHot": "Hot numbers",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "Reconcile",
|
"title": "Reconcile",
|
||||||
"createTitle": "Run reconcile scan",
|
"workflowHint": "Run dated reconcile scans here. For live unresolved exceptions, use the link above (opens Wallet → Transfer orders with abnormal-only filter).",
|
||||||
"createHint": "Scans transfer orders in the selected period, compares lottery wallet ledgers, and checks main-site idempotent records when the wallet API is configured.",
|
"shortcutAbnormalTransfers": "Live abnormal transfers",
|
||||||
|
"viewOnlyHint": "View-only: historical reconcile jobs.",
|
||||||
|
"createTitle": "Run scan",
|
||||||
|
"createHint": "",
|
||||||
"reconcileType": "Reconcile type",
|
"reconcileType": "Reconcile type",
|
||||||
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
|
"reconcileTypeFixed": "Wallet transfer (main site ⇄ lottery)",
|
||||||
"dateRange": "Reconcile date range",
|
"dateRange": "Reconcile date range",
|
||||||
@@ -12,13 +15,18 @@
|
|||||||
"periodRequired": "Enter both reconcile start and end dates",
|
"periodRequired": "Enter both reconcile start and end dates",
|
||||||
"periodOrderInvalid": "End time must be later than or equal to start time",
|
"periodOrderInvalid": "End time must be later than or equal to start time",
|
||||||
"confirmCreateTitle": "Start reconcile scan?",
|
"confirmCreateTitle": "Start reconcile scan?",
|
||||||
"confirmCreateDescription": "Scan transfer orders in the selected date range{{playerHint}} and generate discrepancy items.",
|
"confirmCreateDescription": "Scan transfer orders in the selected period{{playerHint}}.",
|
||||||
"confirmCreatePlayer": " for the selected player",
|
"confirmCreatePlayer": " for the selected player",
|
||||||
"confirmCreateAllPlayers": " for all players",
|
"confirmCreateAllPlayers": " for all players",
|
||||||
"createSuccess": "Scan finished: {{count}} issue(s) found",
|
"createSuccess": "Scan finished: {{count}} issue(s) found",
|
||||||
"createSuccessEmpty": "Scan finished: no issues found",
|
"createSuccessEmpty": "Scan finished: no issues found",
|
||||||
"createFailed": "Scan failed",
|
"createFailed": "Scan failed",
|
||||||
"noCreatePermission": "Current account cannot start reconcile scans.",
|
"noCreatePermission": "Current account cannot start reconcile scans.",
|
||||||
|
"filterOpen": "Open only",
|
||||||
|
"filterAll": "All items",
|
||||||
|
"filterCount": "{{shown}} on page / {{total}} total",
|
||||||
|
"filterOpenEmpty": "No open items on this page. Try All items or another page.",
|
||||||
|
"playerEmpty": "No matching players",
|
||||||
"jobsTitle": "Reconcile jobs",
|
"jobsTitle": "Reconcile jobs",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"jobNo": "Job no.",
|
"jobNo": "Job no.",
|
||||||
@@ -31,13 +39,22 @@
|
|||||||
"finishedAt": "Finished at",
|
"finishedAt": "Finished at",
|
||||||
"createdAt": "Created at",
|
"createdAt": "Created at",
|
||||||
"operate": "Action",
|
"operate": "Action",
|
||||||
"viewDetails": "View discrepancy details",
|
"viewDetails": "View details",
|
||||||
|
"hideDetails": "Hide",
|
||||||
|
"closeDetails": "Close",
|
||||||
"detailsTitle": "Discrepancy details",
|
"detailsTitle": "Discrepancy details",
|
||||||
|
"detailsEmpty": "No items",
|
||||||
|
"itemStatusColumn": "Status",
|
||||||
|
"itemIssueColumn": "Issue type",
|
||||||
|
"itemHandlingColumn": "Handling",
|
||||||
|
"itemScanFindingColumn": "Scan finding",
|
||||||
|
"itemCurrentStatusColumn": "Current status",
|
||||||
|
"itemScanFindingResolvedHint": "Recorded at scan; now handled",
|
||||||
"transferNo": "Transfer no.",
|
"transferNo": "Transfer no.",
|
||||||
"walletTxnNo": "Lottery wallet txn",
|
"walletTxnNo": "Lottery wallet txn",
|
||||||
"mainSiteRef": "Main-site ref",
|
"mainSiteRef": "Main-site ref",
|
||||||
"mainSiteCheck": "Main-site check",
|
"mainSiteCheck": "Main-site check",
|
||||||
"differenceAmount": "Difference (minor)",
|
"differenceAmount": "Difference",
|
||||||
"itemResult": "Check result",
|
"itemResult": "Check result",
|
||||||
"processingStatus": "Processing status",
|
"processingStatus": "Processing status",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"title": "Reports",
|
"title": "Reports",
|
||||||
"subtitle": "Centralized operational, finance, risk, and audit reports with unified export filters.",
|
"subtitle": "",
|
||||||
|
"profitScopeHint": "Ticket-level totals; not credit settlement periods",
|
||||||
|
"shortcuts": {
|
||||||
|
"wallet": "Transfer orders",
|
||||||
|
"risk": "Risk center",
|
||||||
|
"audit": "Audit logs"
|
||||||
|
},
|
||||||
"pageGuide": "P&L and risk by draw, player, and play type; export permission enables async jobs.",
|
"pageGuide": "P&L and risk by draw, player, and play type; export permission enables async jobs.",
|
||||||
"exportPanel": "Export setup",
|
"exportPanel": "Export setup",
|
||||||
"chooseReport": "Choose a report to export",
|
"chooseReport": "Choose a report to export",
|
||||||
@@ -86,8 +92,12 @@
|
|||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"title": "Preview",
|
"title": "Preview",
|
||||||
"subtitle": "Results appear below. Export as CSV or Excel.",
|
"subtitle": "",
|
||||||
"empty": "No data. Adjust filters and try again.",
|
"empty": "No data",
|
||||||
|
"sections": {
|
||||||
|
"settlementBatches": "Settlement batches",
|
||||||
|
"lockLogs": "Lock logs"
|
||||||
|
},
|
||||||
"exportableRows": "rows exportable",
|
"exportableRows": "rows exportable",
|
||||||
"summaryScopeHint": "Except for the total record count, the stat cards above summarize the current preview page. Use full CSV/Excel export for full-range numbers.",
|
"summaryScopeHint": "Except for the total record count, the stat cards above summarize the current preview page. Use full CSV/Excel export for full-range numbers.",
|
||||||
"scope": {
|
"scope": {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"unsettledTickets": "Unsettled tickets",
|
"unsettledTickets": "Unsettled tickets",
|
||||||
"openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.",
|
"openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.",
|
||||||
"viewDetail": "View details",
|
"viewDetail": "View details",
|
||||||
|
"processBills": "Process bills",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"closeNow": "Close now",
|
"closeNow": "Close now",
|
||||||
"hasOpen": "Period {{range}} is open. Close it before opening a new one.",
|
"hasOpen": "Period {{range}} is open. Close it before opening a new one.",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"periods": "Periods",
|
"periods": "Periods",
|
||||||
"bills": "Bills",
|
"bills": "Bills",
|
||||||
"operations": "Payments & adjustments",
|
"operations": "Payments & adjustments",
|
||||||
|
"aria": "Period views",
|
||||||
"ledger": "Account ledger",
|
"ledger": "Account ledger",
|
||||||
"creditLedger": "Credit ledger",
|
"creditLedger": "Credit ledger",
|
||||||
"playerBills": "Player bills",
|
"playerBills": "Player bills",
|
||||||
@@ -213,7 +215,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"detail": "Detail",
|
"detail": "Detail",
|
||||||
"viewBill": "View bill",
|
"viewBill": "View bill",
|
||||||
"billDetail": "Bill detail"
|
"billDetail": "Bill detail",
|
||||||
|
"billDetailWithId": "Bill #{{id}}"
|
||||||
},
|
},
|
||||||
"billDisplay": {
|
"billDisplay": {
|
||||||
"settlementFlow": "Who pays whom",
|
"settlementFlow": "Who pays whom",
|
||||||
@@ -250,6 +253,7 @@
|
|||||||
"unpaidAwaitingPayment": "Record offline payment",
|
"unpaidAwaitingPayment": "Record offline payment",
|
||||||
"fullySettled": "Fully settled this period",
|
"fullySettled": "Fully settled this period",
|
||||||
"confirmHint": "Confirm the bill before recording payment.",
|
"confirmHint": "Confirm the bill before recording payment.",
|
||||||
|
"advancedActions": "Adjust / bad debt",
|
||||||
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
|
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
|
||||||
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
|
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
|
||||||
"rebateAllocationsHint": "How rebate is allocated across agent tiers.",
|
"rebateAllocationsHint": "How rebate is allocated across agent tiers.",
|
||||||
@@ -282,6 +286,12 @@
|
|||||||
},
|
},
|
||||||
"billsPanel": {
|
"billsPanel": {
|
||||||
"intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.",
|
"intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.",
|
||||||
|
"confirmOneTitle": "Confirm bill #{{id}}?",
|
||||||
|
"confirmOneDesc": "After confirm, record offline payment.",
|
||||||
|
"confirmOneBtn": "Confirm",
|
||||||
|
"confirmedOne": "Confirmed",
|
||||||
|
"confirmFailed": "Confirm failed",
|
||||||
|
"payBtn": "Pay",
|
||||||
"hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down.",
|
"hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down.",
|
||||||
"quickFilter": {
|
"quickFilter": {
|
||||||
"title": "Which settlement layer do you want to review",
|
"title": "Which settlement layer do you want to review",
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
"subnavLabel": "Wallet sub pages",
|
"subnavLabel": "Wallet sub pages",
|
||||||
"subnavTransactions": "Main-site wallet txns",
|
"subnavTransactions": "Main-site wallet txns",
|
||||||
"subnavTransferOrders": "Main-site transfers",
|
"subnavTransferOrders": "Main-site transfers",
|
||||||
"scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line period settlement, see",
|
|
||||||
"scopeHintSettlementLink": "Settlement center",
|
|
||||||
"scopeHintSettlement": "Settlement center",
|
|
||||||
"ledgerChannel": "Ledger",
|
"ledgerChannel": "Ledger",
|
||||||
"ledgerCredit": "Credit ledger",
|
"ledgerCredit": "Credit ledger",
|
||||||
"ledgerWallet": "Wallet txn",
|
"ledgerWallet": "Wallet txn",
|
||||||
@@ -43,6 +41,9 @@
|
|||||||
"options": "Options",
|
"options": "Options",
|
||||||
"abnormalOnly": "Abnormal only",
|
"abnormalOnly": "Abnormal only",
|
||||||
"abnormalOnlyPending": "Abnormal only (pending reconcile)",
|
"abnormalOnlyPending": "Abnormal only (pending reconcile)",
|
||||||
|
"abnormalFilterActive": "Showing transfer orders flagged by reconciliation (not wallet transactions). Uncheck “Abnormal only” below to see all.",
|
||||||
|
"txnAbnormalFilterActive": "Showing only pending-reconcile transactions. Status filter is ignored while this is on (same as abnormal=1).",
|
||||||
|
"txnDeepLinkActive": "Filters were applied from the link. Reset clears URL parameters.",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"resetFilters": "Reset filters",
|
"resetFilters": "Reset filters",
|
||||||
"refreshCurrentPage": "Refresh current page",
|
"refreshCurrentPage": "Refresh current page",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"noRoles": "अहिले भूमिका डाटा छैन। सूची लोड भएपछि फेरि प्रयास गर्नुहोस्।",
|
"noRoles": "अहिले भूमिका डाटा छैन। सूची लोड भएपछि फेरि प्रयास गर्नुहोस्।",
|
||||||
"password": "पासवर्ड",
|
"password": "पासवर्ड",
|
||||||
"passwordOptional": "पासवर्ड (वैकल्पिक)",
|
"passwordOptional": "पासवर्ड (वैकल्पिक)",
|
||||||
"passwordPlaceholderCreate": "कम्तीमा 8 वर्ण",
|
"passwordPlaceholderCreate": "कम्तीमा 6 वर्ण",
|
||||||
"passwordPlaceholderEdit": "परिवर्तन नगर्न खाली छोड्नुहोस्",
|
"passwordPlaceholderEdit": "परिवर्तन नगर्न खाली छोड्नुहोस्",
|
||||||
"rolesDescription": "सिर्जना भएपछि \"भूमिका तोक्नुहोस्\" मा गएर भूमिका बाइन्डिङ थप समायोजन गर्न सकिन्छ।",
|
"rolesDescription": "सिर्जना भएपछि \"भूमिका तोक्नुहोस्\" मा गएर भूमिका बाइन्डिङ थप समायोजन गर्न सकिन्छ।",
|
||||||
"rolesRequired": "भूमिका (पूर्वनिर्धारित साइट, कम्तीमा एक)",
|
"rolesRequired": "भूमिका (पूर्वनिर्धारित साइट, कम्तीमा एक)",
|
||||||
@@ -46,9 +46,9 @@
|
|||||||
"listTitle": "प्रशासक सूची",
|
"listTitle": "प्रशासक सूची",
|
||||||
"loadFailed": "प्रशासक सूची लोड असफल भयो",
|
"loadFailed": "प्रशासक सूची लोड असफल भयो",
|
||||||
"modelGuide": "खाता तहमा भूमिका मात्र बाँधिन्छ; कार्य अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
|
"modelGuide": "खाता तहमा भूमिका मात्र बाँधिन्छ; कार्य अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
|
||||||
"newPasswordMin": "नयाँ पासवर्ड कम्तीमा 8 वर्ण हुनुपर्छ",
|
"newPasswordMin": "नयाँ पासवर्ड कम्तीमा 6 वर्ण हुनुपर्छ",
|
||||||
"nicknameRequired": "उपनाम लेख्नुहोस्",
|
"nicknameRequired": "उपनाम लेख्नुहोस्",
|
||||||
"passwordMin": "पासवर्ड कम्तीमा 8 वर्ण हुनुपर्छ",
|
"passwordMin": "पासवर्ड कम्तीमा 6 वर्ण हुनुपर्छ",
|
||||||
"permissionDialog": {
|
"permissionDialog": {
|
||||||
"rolePermissionCount": "{{count}} वटा कार्य अनुमति समावेश",
|
"rolePermissionCount": "{{count}} वटा कार्य अनुमति समावेश",
|
||||||
"rolesDescription": "यहाँ प्रशासकलाई भूमिका मात्र जोडिन्छ। विस्तृत अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
|
"rolesDescription": "यहाँ प्रशासकलाई भूमिका मात्र जोडिन्छ। विस्तृत अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
|
||||||
@@ -168,8 +168,18 @@
|
|||||||
"roleListTitle": "भूमिका व्यवस्थापन",
|
"roleListTitle": "भूमिका व्यवस्थापन",
|
||||||
"roleLoadFailed": "भूमिका सूची लोड असफल भयो",
|
"roleLoadFailed": "भूमिका सूची लोड असफल भयो",
|
||||||
"rolePermissionDialog": {
|
"rolePermissionDialog": {
|
||||||
"title": "भूमिका अनुमति"
|
"title": "भूमिका अनुमति",
|
||||||
|
"titleWithName": "{{name}}"
|
||||||
},
|
},
|
||||||
|
"permissionAccess": {
|
||||||
|
"none": "चाहिँदैन",
|
||||||
|
"view": "हेर्न मात्र",
|
||||||
|
"review": "समीक्षा",
|
||||||
|
"export": "निर्यात",
|
||||||
|
"manage": "व्यवस्थापन",
|
||||||
|
"special": "विशेष अधिकार"
|
||||||
|
},
|
||||||
|
|
||||||
"rolePermissionSaveFailed": "भूमिका अनुमति सुरक्षित गर्न असफल",
|
"rolePermissionSaveFailed": "भूमिका अनुमति सुरक्षित गर्न असफल",
|
||||||
"rolePermissionSaveSuccess": "भूमिका अनुमति अद्यावधिक भयो",
|
"rolePermissionSaveSuccess": "भूमिका अनुमति अद्यावधिक भयो",
|
||||||
"roleRequired": "कम्तीमा एउटा भूमिका छान्नुहोस्",
|
"roleRequired": "कम्तीमा एउटा भूमिका छान्नुहोस्",
|
||||||
@@ -178,7 +188,8 @@
|
|||||||
"actions": "कार्य",
|
"actions": "कार्य",
|
||||||
"name": "भूमिका",
|
"name": "भूमिका",
|
||||||
"permissions": "अनुमति संख्या",
|
"permissions": "अनुमति संख्या",
|
||||||
"slug": "角色 कोड",
|
"enabledAreas": "खुला मोड्युल",
|
||||||
|
"slug": "भूमिका कोड",
|
||||||
"status": "स्थिति",
|
"status": "स्थिति",
|
||||||
"type": "प्रकार",
|
"type": "प्रकार",
|
||||||
"users": "सम्बन्धित प्रयोगकर्ता"
|
"users": "सम्बन्धित प्रयोगकर्ता"
|
||||||
|
|||||||
@@ -100,6 +100,11 @@
|
|||||||
},
|
},
|
||||||
"listFlatHint": "सबै सञ्चालन एजेन्ट सूचीमा; अधीनस्थ थप्न पङ्क्ति मेनु प्रयोग गर्नुहोस्।",
|
"listFlatHint": "सबै सञ्चालन एजेन्ट सूचीमा; अधीनस्थ थप्न पङ्क्ति मेनु प्रयोग गर्नुहोस्।",
|
||||||
"listSearch": "नाम / कोड / लगइन खोज्नुहोस्",
|
"listSearch": "नाम / कोड / लगइन खोज्नुहोस्",
|
||||||
|
"viewModes": {
|
||||||
|
"label": "दृश्य",
|
||||||
|
"tree": "रूख",
|
||||||
|
"list": "सूची"
|
||||||
|
},
|
||||||
"listTitle": "एजेन्ट सूची",
|
"listTitle": "एजेन्ट सूची",
|
||||||
"loadFailed": "Failed to load agent tree",
|
"loadFailed": "Failed to load agent tree",
|
||||||
"modelGuide": "एजेन्ट तहले डाटा स्कोप र delegation ceiling नियन्त्रण गर्छ; खाताको अनुमति भूमिका मार्फत बाँडिन्छ।",
|
"modelGuide": "एजेन्ट तहले डाटा स्कोप र delegation ceiling नियन्त्रण गर्छ; खाताको अनुमति भूमिका मार्फत बाँडिन्छ।",
|
||||||
@@ -109,9 +114,9 @@
|
|||||||
"noAccess": "एजेन्ट सञ्चालन अनुमति छैन। प्रशासकलाई सम्पर्क गर्नुहोस्।",
|
"noAccess": "एजेन्ट सञ्चालन अनुमति छैन। प्रशासकलाई सम्पर्क गर्नुहोस्।",
|
||||||
"pageGuide": "यहाँ एजेन्ट ट्री, एजेन्ट भूमिका, एजेन्ट खाता र delegation ceiling व्यवस्थापन गरिन्छ। प्लेटफर्म खाता र प्लेटफर्म भूमिका अलग पृष्ठमा राखिन्छ।",
|
"pageGuide": "यहाँ एजेन्ट ट्री, एजेन्ट भूमिका, एजेन्ट खाता र delegation ceiling व्यवस्थापन गरिन्छ। प्लेटफर्म खाता र प्लेटफर्म भूमिका अलग पृष्ठमा राखिन्छ।",
|
||||||
"parentAgent": "माथिल्लो",
|
"parentAgent": "माथिल्लो",
|
||||||
"passwordMinLength": "Password must be at least 8 characters",
|
"passwordMinLength": "Password must be at least 6 characters",
|
||||||
"passwordOptionalHint": "परिवर्तन नगर्ने भए खाली छोड्नुहोस्, परिवर्तन गर्न ८-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
"passwordOptionalHint": "परिवर्तन नगर्ने भए खाली छोड्नुहोस्, परिवर्तन गर्न कम्तीमा ६-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
||||||
"passwordPlaceholder": "८-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
"passwordPlaceholder": "कम्तीमा ६-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
||||||
"passwordRequired": "Password is required",
|
"passwordRequired": "Password is required",
|
||||||
"path": "Path",
|
"path": "Path",
|
||||||
"playersPanel": {
|
"playersPanel": {
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
{
|
{
|
||||||
"title": "अडिट लग",
|
"title": "सञ्चालन रेकर्ड",
|
||||||
"moduleCode": "मोड्युल कोड",
|
"filterModule": "व्यवसाय प्रकार",
|
||||||
"actionCode": "कार्य कोड",
|
"filterModuleAll": "सबै",
|
||||||
"operatorType": "अपरेटर प्रकार",
|
"filterOperatorType": "अपरेटर प्रकार",
|
||||||
"operatorIdPlaceholder": "अपरेटर ID प्रविष्ट गर्नुहोस्",
|
"filterOperatorTypeAll": "सबै",
|
||||||
"exactMatch": "ठ्याक्कै मिलान",
|
"operatorIdPlaceholder": "अपरेटर ID थाहा भए मात्र",
|
||||||
"operatorTypePlaceholder": "जस्तै admin / system",
|
|
||||||
"operator": "अपरेटर",
|
"operator": "अपरेटर",
|
||||||
"module": "मोड्युल",
|
"summary": "के भयो",
|
||||||
"action": "कार्य",
|
"target": "सम्बन्धित",
|
||||||
"target": "लक्ष्य",
|
|
||||||
"time": "समय",
|
"time": "समय",
|
||||||
"empty": "डाटा छैन",
|
"empty": "रेकर्ड छैन",
|
||||||
|
"moduleOptions": {
|
||||||
|
"agent": "एजेन्ट",
|
||||||
|
"system": "प्रणाली",
|
||||||
|
"settings": "सेटिङ",
|
||||||
|
"integration": "इन्टिग्रेसन",
|
||||||
|
"player_manage": "खेलाडी व्यवस्थापन",
|
||||||
|
"player_service": "खेलाडी सेवा",
|
||||||
|
"risk_cap": "जोखिम सीमा",
|
||||||
|
"odds": "बाधा",
|
||||||
|
"play_config": "खेल कन्फिग",
|
||||||
|
"settlement": "बन्दोबस्त",
|
||||||
|
"report_jobs": "रिपोर्ट",
|
||||||
|
"reconcile_jobs": "मिलान",
|
||||||
|
"draw": "ड्र",
|
||||||
|
"wallet": "वालेट",
|
||||||
|
"reconcile": "मिलान",
|
||||||
|
"jackpot": "ज्याकपट",
|
||||||
|
"dashboard": "ड्यासबोर्ड"
|
||||||
|
},
|
||||||
"operatorTypes": {
|
"operatorTypes": {
|
||||||
"admin": "प्रशासक",
|
"admin": "प्रशासक",
|
||||||
"player": "खेलाडी",
|
"player": "खेलाडी",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"captchaLoadFailed": "क्याप्चा लोड गर्न सकिएन। API वा नेटवर्क जाँच गर्नुहोस्।",
|
"captchaLoadFailed": "क्याप्चा लोड गर्न सकिएन। API वा नेटवर्क जाँच गर्नुहोस्।",
|
||||||
"apiBaseMissingToast": "API proxy सक्षम छैन: LOTTERY_API_UPSTREAM Laravel तर्फ छ कि छैन जाँच गर्नुहोस्",
|
"apiBaseMissingToast": "API proxy सक्षम छैन: LOTTERY_API_UPSTREAM Laravel तर्फ छ कि छैन जाँच गर्नुहोस्",
|
||||||
"captchaRequired": "पहिले क्याप्चा रिफ्रेस गर्नुहोस्",
|
"captchaRequired": "पहिले क्याप्चा रिफ्रेस गर्नुहोस्",
|
||||||
|
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||||
"welcome": "स्वागत छ, {{name}}",
|
"welcome": "स्वागत छ, {{name}}",
|
||||||
"networkFailed": "नेटवर्क अनुरोध असफल भयो",
|
"networkFailed": "नेटवर्क अनुरोध असफल भयो",
|
||||||
"loginFailed": "लगइन असफल भयो"
|
"loginFailed": "लगइन असफल भयो"
|
||||||
|
|||||||
@@ -165,9 +165,8 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"account": "खाता सेटिङ",
|
"account": "खाता सेटिङ",
|
||||||
"admin_roles": "भूमिका व्यवस्थापन",
|
"admin_roles": "भूमिका व्यवस्थापन",
|
||||||
"admin_users": "प्रशासक सूची",
|
"admin_users": "प्लेटफर्म खाता",
|
||||||
"agent_list": "एजेन्ट सूची",
|
"agents": "एजेन्ट व्यवस्थापन",
|
||||||
"agents": "एजेन्ट लाइन",
|
|
||||||
"audit": "अडिट लग",
|
"audit": "अडिट लग",
|
||||||
"config": "सञ्चालन कन्फिगरेसन",
|
"config": "सञ्चालन कन्फिगरेसन",
|
||||||
"currencies": "मुद्रा व्यवस्थापन",
|
"currencies": "मुद्रा व्यवस्थापन",
|
||||||
@@ -186,8 +185,8 @@
|
|||||||
"rules_plays": "खेल नियम",
|
"rules_plays": "खेल नियम",
|
||||||
"settings": "सेटिङ",
|
"settings": "सेटिङ",
|
||||||
"settlement": "सेटलमेन्ट",
|
"settlement": "सेटलमेन्ट",
|
||||||
"settlement_center": "सेटलमेन्ट केन्द्र",
|
"settlement_center": "क्रेडिट सेटलमेन्ट",
|
||||||
"tickets": "टिकट सूची",
|
"tickets": "टिकट",
|
||||||
"wallet": "वालेट"
|
"wallet": "वालेट"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
@@ -217,9 +216,9 @@
|
|||||||
"securitySettingsDesc": "लगइन पासवर्ड परिवर्तन गर्नुहोस्। नपरिवर्तन गर्दा खाली छोड्नुहोस्।",
|
"securitySettingsDesc": "लगइन पासवर्ड परिवर्तन गर्नुहोस्। नपरिवर्तन गर्दा खाली छोड्नुहोस्।",
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"group": {
|
"group": {
|
||||||
"agent": "एजेन्ट संगठन",
|
"agent": "एजेन्ट",
|
||||||
"finance": "वित्त र रिपोर्ट",
|
"finance": "वित्त र रिपोर्ट",
|
||||||
"operations": "दैनिक सञ्चालन",
|
"operations": "सञ्चालन",
|
||||||
"overview": "सारांश",
|
"overview": "सारांश",
|
||||||
"platform": "प्लेटफर्म",
|
"platform": "प्लेटफर्म",
|
||||||
"rules": "नियम र प्यारामिटर"
|
"rules": "नियम र प्यारामिटर"
|
||||||
@@ -257,6 +256,7 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"passwordMismatch": "पासवर्ड मिलेन",
|
"passwordMismatch": "पासवर्ड मिलेन",
|
||||||
|
"passwordMinLength": "पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
|
||||||
"required": "{{field}} अनिवार्य छ"
|
"required": "{{field}} अनिवार्य छ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -518,6 +518,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
|
"pageTitle": "प्रणाली सेटिङ",
|
||||||
"confirmSaveCurrencyFormatDescription": "यसले दशमलव स्थान र विभाजक अद्यावधिक गर्छ।",
|
"confirmSaveCurrencyFormatDescription": "यसले दशमलव स्थान र विभाजक अद्यावधिक गर्छ।",
|
||||||
"confirmSaveCurrencyFormatTitle": "मुद्रा प्रदर्शन ढाँचा बचत गर्ने?",
|
"confirmSaveCurrencyFormatTitle": "मुद्रा प्रदर्शन ढाँचा बचत गर्ने?",
|
||||||
"confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।",
|
"confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।",
|
||||||
@@ -582,12 +583,33 @@
|
|||||||
"saveSettlementSuccess": "सेटलमेन्ट स्वचालन बचत भयो",
|
"saveSettlementSuccess": "सेटलमेन्ट स्वचालन बचत भयो",
|
||||||
"saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो",
|
"saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो",
|
||||||
"sections": {
|
"sections": {
|
||||||
|
"deployment": "डिप्लोयमेन्ट (पढ्न मात्र)",
|
||||||
|
"deploymentDescription": "पूर्वनिर्धारित मुद्रा र ड्र तालिका सर्भर .env मा सेट हुन्छ; यहाँ प्रभावी मान देखिन्छ।",
|
||||||
"currencyFormat": "मुद्रा प्रदर्शन ढाँचा",
|
"currencyFormat": "मुद्रा प्रदर्शन ढाँचा",
|
||||||
"currencyFormatDescription": "साइटभरि रकमका दशमलव र विभाजक (मुद्रा मास्टर डाटाबाट अलग)।",
|
"currencyFormatDescription": "साइटभरि रकमका दशमलव र विभाजक (मुद्रा मास्टर डाटाबाट अलग)।",
|
||||||
"draw": "ड्र तालिका र समीक्षा",
|
"draw": "ड्रअ समीक्षा",
|
||||||
"drawDescription": "ड्र समय, बन्द सञ्झ्याल, म्यानुअल समीक्षा र कूलडाउन नियन्त्रण। यो ब्लकमा परिवर्तित फिल्ड मात्र पेश हुन्छ।",
|
"drawDescription": "RNG पछि म्यानुअल समीक्षा र प्रकाशनपछि कूलडाउन।",
|
||||||
"settlement": "सेटलमेन्ट स्वचालन",
|
"settlement": "सेटलमेन्ट स्वचालन",
|
||||||
"settlementDescription": "टिक स्वतः सेटलमेन्ट, अनुमोदन र भुक्तानी चलाउने नियन्त्रण। यो ब्लकमा परिवर्तित फिल्ड मात्र पेश हुन्छ।"
|
"settlementDescription": "क्रम: स्वतः सेटलमेन्ट → स्वतः अनुमोदन → स्वतः भुक्तानी। बन्द हुँदा सम्बन्धित मोड्युलमा म्यानुअल गर्नुहोस्।"
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"footer": "परिवर्तनपछि PHP पुनः सुरु वा config cache refresh गर्नुहोस्।",
|
||||||
|
"fields": {
|
||||||
|
"default_currency": "पूर्वनिर्धारित मुद्रा",
|
||||||
|
"draw_timezone": "ड्रअ समय क्षेत्र",
|
||||||
|
"draw_interval_minutes": "ड्रअ अन्तराल (मिनेट)",
|
||||||
|
"draw_betting_window_seconds": "बेटिङ विन्डो (सेकेन्ड)",
|
||||||
|
"draw_close_before_draw_seconds": "ड्रअ अघि बन्द (सेकेन्ड)",
|
||||||
|
"draw_buffer_draws_ahead": "अग्रिम सिर्जना गरिने ड्रअ"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"default_currency": "नयाँ खेलाडीको पूर्वनिर्धारित मुद्रा",
|
||||||
|
"draw_timezone": "ड्रअ तालिका र प्रदर्शनको समय क्षेत्र",
|
||||||
|
"draw_interval_minutes": "लगातार ड्रअ बीचको मिनेट",
|
||||||
|
"draw_betting_window_seconds": "प्रति ड्रअ बेटिङ खुला रहने समय",
|
||||||
|
"draw_close_before_draw_seconds": "ड्रअ अघि बेट बन्द हुने सेकेन्ड",
|
||||||
|
"draw_buffer_draws_ahead": "अग्रिम सिर्जना गरिने भविष्य ड्रअ संख्या"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"states": {
|
"states": {
|
||||||
"disabled": "बन्द",
|
"disabled": "बन्द",
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
"loadFailed": "加载管理员列表失败",
|
"loadFailed": "加载管理员列表失败",
|
||||||
"roleLoadFailed": "加载角色列表失败",
|
"roleLoadFailed": "加载角色列表失败",
|
||||||
"nicknameRequired": "请填写昵称",
|
"nicknameRequired": "请填写昵称",
|
||||||
"newPasswordMin": "新密码至少 8 位",
|
"newPasswordMin": "新密码至少 6 位",
|
||||||
"roleRequired": "请至少选择一个角色",
|
"roleRequired": "请至少选择一个角色",
|
||||||
"usernameRequired": "请填写登录账号",
|
"usernameRequired": "请填写登录账号",
|
||||||
"passwordMin": "密码至少 8 位",
|
"passwordMin": "密码至少 6 位",
|
||||||
"createSuccess": "已创建管理员 {{name}}",
|
"createSuccess": "已创建管理员 {{name}}",
|
||||||
"updateSuccess": "已更新 {{name}}",
|
"updateSuccess": "已更新 {{name}}",
|
||||||
"saveAccountFailed": "保存账号失败",
|
"saveAccountFailed": "保存账号失败",
|
||||||
@@ -69,6 +69,7 @@
|
|||||||
"status": "状态",
|
"status": "状态",
|
||||||
"users": "关联用户",
|
"users": "关联用户",
|
||||||
"permissions": "权限数",
|
"permissions": "权限数",
|
||||||
|
"enabledAreas": "开放模块",
|
||||||
"actions": "操作"
|
"actions": "操作"
|
||||||
},
|
},
|
||||||
"roleActions": {
|
"roleActions": {
|
||||||
@@ -101,8 +102,17 @@
|
|||||||
},
|
},
|
||||||
"rolePermissionDialog": {
|
"rolePermissionDialog": {
|
||||||
"title": "角色权限",
|
"title": "角色权限",
|
||||||
"packageHint": "勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。"
|
"titleWithName": "{{name}}"
|
||||||
},
|
},
|
||||||
|
"permissionAccess": {
|
||||||
|
"none": "不需要",
|
||||||
|
"view": "仅查看",
|
||||||
|
"review": "可审核",
|
||||||
|
"export": "可导出",
|
||||||
|
"manage": "可管理",
|
||||||
|
"special": "含特权操作"
|
||||||
|
},
|
||||||
|
|
||||||
"roleDialog": {
|
"roleDialog": {
|
||||||
"createTitle": "新增角色",
|
"createTitle": "新增角色",
|
||||||
"editTitle": "编辑角色",
|
"editTitle": "编辑角色",
|
||||||
@@ -128,7 +138,7 @@
|
|||||||
"emailPlaceholder": "留空则不填",
|
"emailPlaceholder": "留空则不填",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"passwordOptional": "密码(可选)",
|
"passwordOptional": "密码(可选)",
|
||||||
"passwordPlaceholderCreate": "至少 8 位",
|
"passwordPlaceholderCreate": "至少 6 位",
|
||||||
"passwordPlaceholderEdit": "不修改请留空",
|
"passwordPlaceholderEdit": "不修改请留空",
|
||||||
"site": "绑定站点",
|
"site": "绑定站点",
|
||||||
"sitePlaceholder": "选择该账号可访问的数据站点",
|
"sitePlaceholder": "选择该账号可访问的数据站点",
|
||||||
@@ -159,7 +169,7 @@
|
|||||||
"permissionGroups": {
|
"permissionGroups": {
|
||||||
"all": "全部权限",
|
"all": "全部权限",
|
||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表盘",
|
||||||
"admin_users": "管理列表",
|
"admin_users": "平台账号",
|
||||||
"admin_roles": "角色管理",
|
"admin_roles": "角色管理",
|
||||||
"agents": "代理管理",
|
"agents": "代理管理",
|
||||||
"players": "玩家列表",
|
"players": "玩家列表",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"overviewDownlineCount": "{{count}} 个",
|
"overviewDownlineCount": "{{count}} 个",
|
||||||
"downlineEmptyTitle": "暂无直属下级",
|
"downlineEmptyTitle": "暂无直属下级",
|
||||||
"tabProfile": "占成与授信",
|
"tabProfile": "占成与授信",
|
||||||
|
"tabProfileShort": "占成授信",
|
||||||
"tabProfileReadOnly": "占成与授信(只读)",
|
"tabProfileReadOnly": "占成与授信(只读)",
|
||||||
"profileReadOnlyHint": "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
"profileReadOnlyHint": "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
||||||
"selfAgentOverviewHint": "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
|
"selfAgentOverviewHint": "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
|
||||||
@@ -42,7 +43,8 @@
|
|||||||
"downlineCount": "下级数"
|
"downlineCount": "下级数"
|
||||||
},
|
},
|
||||||
"editAccount": "账号与状态",
|
"editAccount": "账号与状态",
|
||||||
"editAgent": "编辑代理",
|
"editAgent": "编辑",
|
||||||
|
"parentMeta": "上级 {{name}}",
|
||||||
"editCurrent": "编辑本代理",
|
"editCurrent": "编辑本代理",
|
||||||
"saveProfile": "保存占成与授信",
|
"saveProfile": "保存占成与授信",
|
||||||
"profileTabHint": "占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
|
"profileTabHint": "占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
|
||||||
@@ -56,7 +58,13 @@
|
|||||||
"sidebarShareRate": "占成 {{rate}}%",
|
"sidebarShareRate": "占成 {{rate}}%",
|
||||||
"sidebarAvailableCredit": "可下发 {{amount}}",
|
"sidebarAvailableCredit": "可下发 {{amount}}",
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"collapse": "收起"
|
"collapse": "收起",
|
||||||
|
"openInTree": "在树中打开"
|
||||||
|
},
|
||||||
|
"viewModes": {
|
||||||
|
"label": "视图",
|
||||||
|
"tree": "树形",
|
||||||
|
"list": "列表"
|
||||||
},
|
},
|
||||||
"listTitle": "代理列表",
|
"listTitle": "代理列表",
|
||||||
"listSearch": "搜索代理名称 / 编码 / 登录名",
|
"listSearch": "搜索代理名称 / 编码 / 登录名",
|
||||||
@@ -132,7 +140,7 @@
|
|||||||
"usernameRequired": "请填写登录名",
|
"usernameRequired": "请填写登录名",
|
||||||
"usernameInvalidCharset": "登录名只能使用字母、数字、点(.)、下划线和连字符",
|
"usernameInvalidCharset": "登录名只能使用字母、数字、点(.)、下划线和连字符",
|
||||||
"passwordRequired": "请填写密码",
|
"passwordRequired": "请填写密码",
|
||||||
"passwordMinLength": "密码至少 8 位",
|
"passwordMinLength": "密码至少 6 位",
|
||||||
"bindAccountPasswordRequired": "该代理尚未绑定登录账号,请填写初始密码以创建账号",
|
"bindAccountPasswordRequired": "该代理尚未绑定登录账号,请填写初始密码以创建账号",
|
||||||
"bindAccountHint": "该代理尚无登录账号,保存时将自动创建并绑定。",
|
"bindAccountHint": "该代理尚无登录账号,保存时将自动创建并绑定。",
|
||||||
"modelGuide": "代理层负责数据范围(Scope)与授权上限(Ceiling),账号权限请通过角色分配。",
|
"modelGuide": "代理层负责数据范围(Scope)与授权上限(Ceiling),账号权限请通过角色分配。",
|
||||||
@@ -328,7 +336,7 @@
|
|||||||
"name": "一级代理名称",
|
"name": "一级代理名称",
|
||||||
"username": "后台登录账号",
|
"username": "后台登录账号",
|
||||||
"password": "初始密码",
|
"password": "初始密码",
|
||||||
"passwordHint": "至少 8 位",
|
"passwordHint": "至少 6 位",
|
||||||
"submit": "创建一级代理",
|
"submit": "创建一级代理",
|
||||||
"success": "一级代理已创建",
|
"success": "一级代理已创建",
|
||||||
"link": "创建一级代理"
|
"link": "创建一级代理"
|
||||||
@@ -343,8 +351,8 @@
|
|||||||
"loginRequired": "请填写登录账号与初始密码",
|
"loginRequired": "请填写登录账号与初始密码",
|
||||||
"loginUsername": "登录账号",
|
"loginUsername": "登录账号",
|
||||||
"initialPassword": "初始密码",
|
"initialPassword": "初始密码",
|
||||||
"passwordHint": "至少 8 位",
|
"passwordHint": "至少 6 位",
|
||||||
"passwordMinLength": "初始密码至少 8 位",
|
"passwordMinLength": "初始密码至少 6 位",
|
||||||
"externalIdOptional": "外部 ID(可选)",
|
"externalIdOptional": "外部 ID(可选)",
|
||||||
"externalIdHint": "留空则系统自动生成",
|
"externalIdHint": "留空则系统自动生成",
|
||||||
"creditLimit": "授信额度",
|
"creditLimit": "授信额度",
|
||||||
@@ -363,7 +371,11 @@
|
|||||||
"playerRef": "玩家标识",
|
"playerRef": "玩家标识",
|
||||||
"usernameNickname": "用户名 / 昵称",
|
"usernameNickname": "用户名 / 昵称",
|
||||||
"creditLimitAvailable": "授信 / 可用",
|
"creditLimitAvailable": "授信 / 可用",
|
||||||
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端 /login 登录"
|
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端 /login 登录",
|
||||||
|
"searchPh": "搜索玩家账号 / 标识",
|
||||||
|
"manageSettlement": "处理账单",
|
||||||
|
"noPendingBillsGoCenter": "该玩家暂无待处理账单,已打开结算中心。",
|
||||||
|
"billingLoadFailed": "加载账单失败"
|
||||||
},
|
},
|
||||||
"delegation": {
|
"delegation": {
|
||||||
"title": "下放权限上限",
|
"title": "下放权限上限",
|
||||||
@@ -406,6 +418,6 @@
|
|||||||
"deleteSuccess": "已删除账号 {{name}}"
|
"deleteSuccess": "已删除账号 {{name}}"
|
||||||
},
|
},
|
||||||
"usernamePlaceholder": "请输入登录名",
|
"usernamePlaceholder": "请输入登录名",
|
||||||
"passwordPlaceholder": "请输入8位数密码",
|
"passwordPlaceholder": "请输入至少6位密码",
|
||||||
"passwordOptionalHint": "留空则不修改,修改请输入8位数密码"
|
"passwordOptionalHint": "留空则不修改,修改请输入至少6位密码"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
{
|
{
|
||||||
"title": "审计日志",
|
"title": "操作记录",
|
||||||
"moduleCode": "模块",
|
"filterModule": "业务类型",
|
||||||
"actionCode": "动作",
|
"filterModuleAll": "全部业务",
|
||||||
"operatorType": "操作者类型",
|
"filterOperatorType": "操作人类型",
|
||||||
"operatorIdPlaceholder": "请输入操作人 ID",
|
"filterOperatorTypeAll": "全部",
|
||||||
"exactMatch": "请输入完整名称",
|
"operatorIdPlaceholder": "知道操作人编号时可填写",
|
||||||
"operatorTypePlaceholder": "如管理员、系统",
|
"operator": "操作人",
|
||||||
"operator": "操作者",
|
"summary": "操作说明",
|
||||||
"module": "模块",
|
"target": "涉及对象",
|
||||||
"action": "动作",
|
|
||||||
"target": "目标",
|
|
||||||
"time": "时间",
|
"time": "时间",
|
||||||
"empty": "无数据",
|
"empty": "暂无记录",
|
||||||
|
"moduleOptions": {
|
||||||
|
"agent": "代理管理",
|
||||||
|
"system": "系统管理",
|
||||||
|
"settings": "系统设置",
|
||||||
|
"integration": "接入站点",
|
||||||
|
"player_manage": "玩家管理",
|
||||||
|
"player_service": "玩家服务",
|
||||||
|
"risk_cap": "限额规则",
|
||||||
|
"odds": "赔率配置",
|
||||||
|
"play_config": "玩法配置",
|
||||||
|
"settlement": "派彩结算",
|
||||||
|
"report_jobs": "报表导出",
|
||||||
|
"reconcile_jobs": "对账任务",
|
||||||
|
"draw": "开奖期号",
|
||||||
|
"wallet": "钱包资金",
|
||||||
|
"reconcile": "对账核对",
|
||||||
|
"jackpot": "奖池",
|
||||||
|
"dashboard": "工作台"
|
||||||
|
},
|
||||||
"operatorTypes": {
|
"operatorTypes": {
|
||||||
"admin": "管理员",
|
"admin": "后台管理员",
|
||||||
"player": "玩家",
|
"player": "玩家",
|
||||||
"system": "系统"
|
"system": "系统自动"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"captchaLoadFailed": "无法获取验证码,请检查接口或网络",
|
"captchaLoadFailed": "无法获取验证码,请检查接口或网络",
|
||||||
"apiBaseMissingToast": "API 代理未启用:请检查 LOTTERY_API_UPSTREAM 是否指向 Laravel",
|
"apiBaseMissingToast": "API 代理未启用:请检查 LOTTERY_API_UPSTREAM 是否指向 Laravel",
|
||||||
"captchaRequired": "请先刷新验证码",
|
"captchaRequired": "请先刷新验证码",
|
||||||
|
"passwordMinLength": "密码至少需要 6 个字符",
|
||||||
"welcome": "欢迎,{{name}}",
|
"welcome": "欢迎,{{name}}",
|
||||||
"networkFailed": "网络请求失败",
|
"networkFailed": "网络请求失败",
|
||||||
"loginFailed": "登录失败"
|
"loginFailed": "登录失败"
|
||||||
|
|||||||
@@ -51,7 +51,8 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "请填写{{field}}",
|
"required": "请填写{{field}}",
|
||||||
"passwordMismatch": "两次输入的密码不一致"
|
"passwordMismatch": "两次输入的密码不一致",
|
||||||
|
"passwordMinLength": "密码至少需要 6 个字符"
|
||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
@@ -156,7 +157,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
"dashboard": "仪表盘",
|
"dashboard": "仪表盘",
|
||||||
"admin_users": "管理列表",
|
"admin_users": "平台账号",
|
||||||
"admin_roles": "角色管理",
|
"admin_roles": "角色管理",
|
||||||
"players": "玩家列表",
|
"players": "玩家列表",
|
||||||
"currencies": "币种管理",
|
"currencies": "币种管理",
|
||||||
@@ -171,25 +172,24 @@
|
|||||||
"settlement": "结算",
|
"settlement": "结算",
|
||||||
"jackpot": "奖池",
|
"jackpot": "奖池",
|
||||||
"reconcile": "对账",
|
"reconcile": "对账",
|
||||||
"tickets": "注单列表",
|
"tickets": "注单",
|
||||||
"audit": "审计日志",
|
"audit": "审计日志",
|
||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
"account": "账号设置",
|
"account": "账号设置",
|
||||||
"integration": "接入配置",
|
"integration": "接入配置",
|
||||||
"agents": "代理线路",
|
"agents": "代理管理",
|
||||||
"agent_list": "代理列表",
|
"settlement_center": "信用结算",
|
||||||
"settlement_center": "结算中心",
|
|
||||||
"config": "运营配置"
|
"config": "运营配置"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "工作台",
|
"workspace": "工作台",
|
||||||
"group": {
|
"group": {
|
||||||
"overview": "总览",
|
"overview": "总览",
|
||||||
"agent": "代理组织",
|
"agent": "代理",
|
||||||
"operations": "日常运营",
|
"operations": "运营",
|
||||||
"finance": "资金与报表",
|
"finance": "资金与报表",
|
||||||
"rules": "规则与参数",
|
"rules": "规则与参数",
|
||||||
"platform": "平台管理"
|
"platform": "平台"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"rulesPlaysTitle": "投注规则",
|
"rulesPlaysTitle": "投注规则",
|
||||||
"rulesOddsTitle": "赔率与基础回水",
|
"rulesOddsTitle": "赔率与基础回水",
|
||||||
"rulesOddsDescription": "赔率矩阵与基础回水在同一页维护,共用赔率版本线。",
|
"rulesOddsDescription": "赔率矩阵与基础回水在同一页维护,共用赔率版本线。",
|
||||||
"rulesOddsDescriptionShort": "左侧选玩法,右侧修改赔率与基础回水;代理/玩家回水需在此基础上叠加,修改后记得保存草稿并发布。",
|
"rulesOddsDescriptionShort": "",
|
||||||
"riskCapTitle": "限额版本"
|
"riskCapTitle": "限额版本"
|
||||||
},
|
},
|
||||||
"hub": {
|
"hub": {
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"codeRequired": "请填写 site_code",
|
"codeRequired": "请填写 site_code",
|
||||||
"adminUsernameRequired": "请填写站点后台登录名",
|
"adminUsernameRequired": "请填写站点后台登录名",
|
||||||
"adminNicknameRequired": "请填写站点后台账号昵称",
|
"adminNicknameRequired": "请填写站点后台账号昵称",
|
||||||
"adminPasswordRequired": "请填写至少 8 位的站点后台初始密码"
|
"adminPasswordRequired": "请填写至少 6 位的站点后台初始密码"
|
||||||
},
|
},
|
||||||
"columns": {
|
"columns": {
|
||||||
"code": "site_code",
|
"code": "site_code",
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
"name": "请输入站点名称",
|
"name": "请输入站点名称",
|
||||||
"adminUsername": "请输入后台登录名",
|
"adminUsername": "请输入后台登录名",
|
||||||
"adminNickname": "请输入账号昵称",
|
"adminNickname": "请输入账号昵称",
|
||||||
"adminPassword": "至少 8 位",
|
"adminPassword": "至少 6 位",
|
||||||
"adminEmail": "请输入邮箱",
|
"adminEmail": "请输入邮箱",
|
||||||
"currency": "请输入币种代码,如 NPR",
|
"currency": "请输入币种代码,如 NPR",
|
||||||
"walletApiUrl": "请输入钱包接口地址",
|
"walletApiUrl": "请输入钱包接口地址",
|
||||||
@@ -153,11 +153,9 @@
|
|||||||
"archived": "已归档"
|
"archived": "已归档"
|
||||||
},
|
},
|
||||||
"versionSwitcher": {
|
"versionSwitcher": {
|
||||||
"sheetTitle": "切换配置版本",
|
"sheetTitle": "版本",
|
||||||
"sheetDescription": "选择一条版本在本页查看;草稿可编辑,生效中与已归档为只读。",
|
|
||||||
"loading": "加载中…",
|
"loading": "加载中…",
|
||||||
"noneSelected": "未选择版本",
|
"noneSelected": "选择版本",
|
||||||
"switch": "切换版本",
|
|
||||||
"empty": "暂无版本记录。",
|
"empty": "暂无版本记录。",
|
||||||
"count": "{{count}} 条",
|
"count": "{{count}} 条",
|
||||||
"effectiveAt": "生效时间:{{value}}",
|
"effectiveAt": "生效时间:{{value}}",
|
||||||
@@ -169,7 +167,7 @@
|
|||||||
"rollback": "回滚",
|
"rollback": "回滚",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleteConfirmTitle": "确认删除版本?",
|
"deleteConfirmTitle": "确认删除版本?",
|
||||||
"deleteConfirmDescription": "将永久删除版本 ID {{id}}(version_no {{version}})。生效中的版本不可删除。"
|
"deleteConfirmDescription": "将删除版本 v{{version}},此操作不可恢复。"
|
||||||
},
|
},
|
||||||
"versionToolbar": {
|
"versionToolbar": {
|
||||||
"draftEditing": "正在编辑草稿,保存并发布后生效"
|
"draftEditing": "正在编辑草稿,保存并发布后生效"
|
||||||
@@ -221,6 +219,7 @@
|
|||||||
"confirmSaveDescription": "将更新转入/转出单笔限额,立即影响玩家钱包转账。"
|
"confirmSaveDescription": "将更新转入/转出单笔限额,立即影响玩家钱包转账。"
|
||||||
},
|
},
|
||||||
"system": {
|
"system": {
|
||||||
|
"pageTitle": "系统设置",
|
||||||
"title": "开奖与结算运行参数",
|
"title": "开奖与结算运行参数",
|
||||||
"runtimeTitle": "全局运行参数",
|
"runtimeTitle": "全局运行参数",
|
||||||
"runtimeIntro1": "这里放不属于玩法版本、赔率版本、风控版本的全局系统参数。它们会直接影响钱包转账、任务开关或系统运行策略。",
|
"runtimeIntro1": "这里放不属于玩法版本、赔率版本、风控版本的全局系统参数。它们会直接影响钱包转账、任务开关或系统运行策略。",
|
||||||
@@ -234,12 +233,33 @@
|
|||||||
"saveSettlementSuccess": "结算自动化参数已保存",
|
"saveSettlementSuccess": "结算自动化参数已保存",
|
||||||
"saveFrontendSuccess": "前端展示配置已保存",
|
"saveFrontendSuccess": "前端展示配置已保存",
|
||||||
"sections": {
|
"sections": {
|
||||||
"draw": "开奖节奏与审核",
|
"deployment": "部署参数(只读)",
|
||||||
"drawDescription": "控制期号节奏、封盘与开奖后人工审核、冷静期。仅保存本区块内修改过的项。",
|
"deploymentDescription": "默认币种、期号节奏等由运维在服务器 .env 配置,此处展示当前生效值。",
|
||||||
|
"draw": "开奖审核",
|
||||||
|
"drawDescription": "控制开奖后是否必须人工审核,以及结果发布后的冷静期。",
|
||||||
"currencyFormat": "金额显示格式",
|
"currencyFormat": "金额显示格式",
|
||||||
"currencyFormatDescription": "全站金额展示的小数位与分隔符,与币种主数据无关。",
|
"currencyFormatDescription": "全站金额展示的小数位与分隔符,与币种主数据无关。",
|
||||||
"settlement": "结算自动化",
|
"settlement": "结算自动化",
|
||||||
"settlementDescription": "控制 tick 是否自动结算、审核与派彩。修改后只提交本区块变更项。"
|
"settlementDescription": "按顺序:自动结算 → 自动审核批次 → 自动派彩。关闭后需到对应模块手工处理。"
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"footer": "修改后需重启 PHP 应用或刷新配置缓存方生效;如有疑问请联系运维。",
|
||||||
|
"fields": {
|
||||||
|
"default_currency": "默认币种",
|
||||||
|
"draw_timezone": "期号时区",
|
||||||
|
"draw_interval_minutes": "开奖间隔(分钟)",
|
||||||
|
"draw_betting_window_seconds": "下注窗口(秒)",
|
||||||
|
"draw_close_before_draw_seconds": "封盘提前(秒)",
|
||||||
|
"draw_buffer_draws_ahead": "预生成未来期数"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"default_currency": "新玩家建档、未指定币种时的默认值",
|
||||||
|
"draw_timezone": "期号计划与展示使用的时区",
|
||||||
|
"draw_interval_minutes": "相邻两期开奖时间的间隔",
|
||||||
|
"draw_betting_window_seconds": "每期允许下注的时长",
|
||||||
|
"draw_close_before_draw_seconds": "开奖前停止收单的提前秒数",
|
||||||
|
"draw_buffer_draws_ahead": "调度提前生成的未开奖期数"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"saveFailed": "系统设置保存失败",
|
"saveFailed": "系统设置保存失败",
|
||||||
"unsavedChanges": "有未保存的更改",
|
"unsavedChanges": "有未保存的更改",
|
||||||
@@ -291,7 +311,7 @@
|
|||||||
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
|
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
|
||||||
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
|
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
|
||||||
"confirmSaveDrawTitle": "确认保存开奖参数?",
|
"confirmSaveDrawTitle": "确认保存开奖参数?",
|
||||||
"confirmSaveDrawDescription": "将更新开奖审核、期号节奏与冷静期等本区块字段。",
|
"confirmSaveDrawDescription": "将更新开奖人工审核开关与冷静期时长。",
|
||||||
"confirmSaveCurrencyFormatTitle": "确认保存金额显示格式?",
|
"confirmSaveCurrencyFormatTitle": "确认保存金额显示格式?",
|
||||||
"confirmSaveCurrencyFormatDescription": "将更新小数位与千分位/小数分隔符。",
|
"confirmSaveCurrencyFormatDescription": "将更新小数位与千分位/小数分隔符。",
|
||||||
"confirmSaveSettlementTitle": "确认保存结算自动化?",
|
"confirmSaveSettlementTitle": "确认保存结算自动化?",
|
||||||
@@ -367,15 +387,15 @@
|
|||||||
"publishFailed": "发布失败",
|
"publishFailed": "发布失败",
|
||||||
"publishDialog": {
|
"publishDialog": {
|
||||||
"title": "确认发布玩法配置?",
|
"title": "确认发布玩法配置?",
|
||||||
"description": "新配置将影响后续下注;已下注注单仍按各自快照结算。",
|
"description": "发布后对新注单生效。",
|
||||||
"confirm": "确认发布"
|
"confirm": "确认发布"
|
||||||
},
|
},
|
||||||
"batchSwitchConfirmTitle": "确认批量{{action}}?",
|
"batchSwitchConfirmTitle": "确认批量{{action}}?",
|
||||||
"batchSwitchConfirmDescription": "将{{action}}「{{group}}」下 {{count}} 个玩法,并写入当前草稿。",
|
"batchSwitchConfirmDescription": "将{{action}}「{{group}}」下 {{count}} 个玩法。",
|
||||||
"batchSwitchEnable": "开启",
|
"batchSwitchEnable": "开启",
|
||||||
"batchSwitchDisable": "关闭",
|
"batchSwitchDisable": "关闭",
|
||||||
"toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}?",
|
"toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}?",
|
||||||
"toggleConfirmDescription": "将写入当前草稿;保存并发布后才会影响玩家端。",
|
"toggleConfirmDescription": "将写入当前草稿。",
|
||||||
"batchPartialEnabled": "{{enabledCount}}/{{total}} 已开启(未全开,打开开关将全部开启)",
|
"batchPartialEnabled": "{{enabledCount}}/{{total}} 已开启(未全开,打开开关将全部开启)",
|
||||||
"toggleEnable": "开启",
|
"toggleEnable": "开启",
|
||||||
"toggleDisable": "关闭",
|
"toggleDisable": "关闭",
|
||||||
@@ -513,8 +533,8 @@
|
|||||||
"loadingDetails": "正在加载详情…",
|
"loadingDetails": "正在加载详情…",
|
||||||
"multiplier": "倍数 x{{value}} · {{currency}}",
|
"multiplier": "倍数 x{{value}} · {{currency}}",
|
||||||
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
|
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
|
||||||
"rebateRate": "基础回水比例 (%)",
|
"rebateRate": "基础回水 (%)",
|
||||||
"rebateRateHint": "这里维护的是平台基础回水,会把 rebate_rate 写入该玩法下所有奖级范围;代理/玩家回水需在此基础上叠加。",
|
"rebateRateHint": "",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"multiplier": "请输入赔率倍数",
|
"multiplier": "请输入赔率倍数",
|
||||||
"rebateRate": "请输入基础回水比例"
|
"rebateRate": "请输入基础回水比例"
|
||||||
@@ -532,7 +552,7 @@
|
|||||||
},
|
},
|
||||||
"publishDialog": {
|
"publishDialog": {
|
||||||
"title": "确认发布赔率版本?",
|
"title": "确认发布赔率版本?",
|
||||||
"description": "新赔率会立即影响后续新注单;已成功下注的历史注单仍按各自保存的赔率快照结算。",
|
"description": "发布后对新注单生效。",
|
||||||
"confirm": "确认发布",
|
"confirm": "确认发布",
|
||||||
"columns": {
|
"columns": {
|
||||||
"prizeScope": "奖级范围",
|
"prizeScope": "奖级范围",
|
||||||
@@ -608,14 +628,13 @@
|
|||||||
"readOnlyHint": "当前为只读,请先创建草稿。",
|
"readOnlyHint": "当前为只读,请先创建草稿。",
|
||||||
"readOnly": "只读",
|
"readOnly": "只读",
|
||||||
"defaultCap": {
|
"defaultCap": {
|
||||||
"title": "默认封顶",
|
"title": "默认限额",
|
||||||
"description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。",
|
"fieldLabel": "限额金额"
|
||||||
"fieldLabel": "封顶金额(主币单位)"
|
|
||||||
},
|
},
|
||||||
"specialCaps": {
|
"specialCaps": {
|
||||||
"title": "特殊封顶",
|
"title": "号码限额"
|
||||||
"description": "不选期号时表示全局号码限额;选择期号后表示该期单独限额。"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"scope": {
|
"scope": {
|
||||||
"global": "全局号码",
|
"global": "全局号码",
|
||||||
"drawId": "期号 ID:{{id}}"
|
"drawId": "期号 ID:{{id}}"
|
||||||
@@ -641,6 +660,7 @@
|
|||||||
"noDetailRows": "暂无明细行。",
|
"noDetailRows": "暂无明细行。",
|
||||||
"table": {
|
"table": {
|
||||||
"scope": "作用范围",
|
"scope": "作用范围",
|
||||||
|
"draw": "期号",
|
||||||
"number": "号码",
|
"number": "号码",
|
||||||
"capAmount": "封顶金额",
|
"capAmount": "封顶金额",
|
||||||
"used": "已占用",
|
"used": "已占用",
|
||||||
@@ -668,7 +688,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"addSpecialCap": "+ 新增特殊封顶",
|
"addNumberCap": "添加号码限额",
|
||||||
|
"addSpecialCap": "新增",
|
||||||
"filterPresets": "筛选预设…",
|
"filterPresets": "筛选预设…",
|
||||||
"exportCsv": "导出 CSV",
|
"exportCsv": "导出 CSV",
|
||||||
"close": "关闭"
|
"close": "关闭"
|
||||||
|
|||||||
@@ -67,11 +67,11 @@
|
|||||||
"scheduleTitle": "时间安排",
|
"scheduleTitle": "时间安排",
|
||||||
"resultBatchesTitle": "开奖批次",
|
"resultBatchesTitle": "开奖批次",
|
||||||
"batchSummaryTotal": "共 {{count}} 批",
|
"batchSummaryTotal": "共 {{count}} 批",
|
||||||
"batchSummaryPending": "待审 {{count}}",
|
"batchSummaryPending": "待发布 {{count}}",
|
||||||
"batchSummaryPublished": "已发 {{count}}",
|
"batchSummaryPublished": "已发 {{count}}",
|
||||||
"noResultBatchesYet": "尚无开奖批次。",
|
"noResultBatchesYet": "尚无开奖批次。",
|
||||||
"reviewQueueHint": "结果生成后,可前往审核与发布处理。",
|
"reviewQueueHint": "",
|
||||||
"goToReviewTab": "去审核与发布",
|
"goToReviewTab": "开奖发布",
|
||||||
"businessDate": "业务日",
|
"businessDate": "业务日",
|
||||||
"sequenceNo": "流水序号",
|
"sequenceNo": "流水序号",
|
||||||
"plannedDraw": "计划开奖",
|
"plannedDraw": "计划开奖",
|
||||||
@@ -96,12 +96,12 @@
|
|||||||
"published": "已发布",
|
"published": "已发布",
|
||||||
"viewFinance": "查看期号收支",
|
"viewFinance": "查看期号收支",
|
||||||
"drawActions": "期号操作",
|
"drawActions": "期号操作",
|
||||||
"drawActionsDesc": "手动封盘 / 取消 / RNG / 重开 / 触发结算均直接调用后台接口。",
|
"drawActionsDesc": "",
|
||||||
"manualClose": "手动封盘",
|
"manualClose": "手动封盘",
|
||||||
"cancelDraw": "取消期号",
|
"cancelDraw": "取消期号",
|
||||||
"cancelBeforeDraw": "未开奖前取消",
|
"cancelBeforeDraw": "未开奖前取消",
|
||||||
"rngDraw": "RNG开奖",
|
"rngDraw": "RNG开奖",
|
||||||
"rngAutoGenerate": "RNG 自动生成",
|
"rngAutoGenerate": "自动开奖",
|
||||||
"reopen": "重开",
|
"reopen": "重开",
|
||||||
"cooldownReopen": "冷静期重开",
|
"cooldownReopen": "冷静期重开",
|
||||||
"runSettlement": "触发结算",
|
"runSettlement": "触发结算",
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
"actualBet": "当期实扣投注",
|
"actualBet": "当期实扣投注",
|
||||||
"currentPayout": "当期派彩合计",
|
"currentPayout": "当期派彩合计",
|
||||||
"grossProfit": "近似毛损益",
|
"grossProfit": "近似毛损益",
|
||||||
"settlementBatchList": "结算批次列表(按期号筛选)",
|
"settlementBatchList": "结算记录",
|
||||||
"relatedSettlementBatches": "本关联期结算批次",
|
"relatedSettlementBatches": "结算批次",
|
||||||
"noSettlementBatches": "暂无结算批次记录。",
|
"noSettlementBatches": "暂无结算批次记录。",
|
||||||
"ticketCount": "票数",
|
"ticketCount": "票数",
|
||||||
"winCount": "中奖数",
|
"winCount": "中奖数",
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
"resultsTitle": "开奖结果",
|
"resultsTitle": "开奖结果",
|
||||||
"reviewAndPublish": "去审核 / 发布",
|
"reviewAndPublish": "去审核 / 发布",
|
||||||
"viewReviewQueue": "查看审核队列",
|
"viewReviewQueue": "查看审核队列",
|
||||||
"noPublishedBatch": "暂无已发布批次。",
|
"noPublishedBatch": "暂无",
|
||||||
"version": "版本 v{{version}}",
|
"version": "版本 v{{version}}",
|
||||||
"sourceType": "生成方式 {{source}}",
|
"sourceType": "生成方式 {{source}}",
|
||||||
"manualEntry": "人工录入",
|
"manualEntry": "人工录入",
|
||||||
@@ -135,28 +135,28 @@
|
|||||||
"tail3": "尾3",
|
"tail3": "尾3",
|
||||||
"tail2": "尾2",
|
"tail2": "尾2",
|
||||||
"headTail": "头/尾",
|
"headTail": "头/尾",
|
||||||
"manualResultEntry": "人工录入开奖结果",
|
"manualResultEntry": "录入号码",
|
||||||
"currentStatusAndDraft": "当前状态 {{status}} · 保存后生成待确认批次,不会直接发布",
|
"currentStatusAndDraft": "",
|
||||||
"currentStatusLabel": "当前状态",
|
"currentStatusLabel": "状态",
|
||||||
"currentStatusDraftHint": "保存后生成待确认批次,不会直接发布",
|
"currentStatusDraftHint": "",
|
||||||
"hallPreviewStatusLabel": "大厅预览",
|
"hallPreviewStatusLabel": "大厅预览",
|
||||||
"enter23Numbers": "请完整输入 23 组 4 位数字",
|
"enter23Numbers": "请完整输入 23 组 4 位数字",
|
||||||
"draftSaved": "已保存草稿 v{{version}},等待确认发布",
|
"draftSaved": "已保存 v{{version}}",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
"fillRandomNumbers": "随机填满",
|
"fillRandomNumbers": "随机填满",
|
||||||
"clear": "清空",
|
"clear": "清空",
|
||||||
"saveDraft": "保存草稿",
|
"saveDraft": "保存草稿",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"pendingBatches": "待确认批次",
|
"pendingBatches": "待发布",
|
||||||
"noPendingBatches": "当前没有待审核批次。",
|
"noPendingBatches": "暂无",
|
||||||
"batchId": "批次 ID",
|
"batchId": "批次",
|
||||||
"numberCount": "号码条数",
|
"numberCount": "号码条数",
|
||||||
"reviewAndPublishAction": "核对并发布",
|
"reviewAndPublishAction": "发布",
|
||||||
"discardPendingBatch": "删除草稿",
|
"discardPendingBatch": "删除",
|
||||||
"discardingPendingBatch": "删除中…",
|
"discardingPendingBatch": "删除中…",
|
||||||
"discardPendingBatchSuccess": "已删除待确认批次,可重新录入或 RNG",
|
"discardPendingBatchSuccess": "已删除待确认批次,可重新录入或 RNG",
|
||||||
"discardPendingBatchFailed": "删除失败",
|
"discardPendingBatchFailed": "删除失败",
|
||||||
"publishReadOnlyHint": "发布页仅用于核对;若要改号请先删除本批次,回到上方「人工录入」重新保存。",
|
"publishReadOnlyHint": "",
|
||||||
"noPublishPermission": "无发布权限",
|
"noPublishPermission": "无发布权限",
|
||||||
"batchNotFound": "未找到批次",
|
"batchNotFound": "未找到批次",
|
||||||
"batchNotFoundDesc": "请返回审核列表确认 batch id。",
|
"batchNotFoundDesc": "请返回审核列表确认 batch id。",
|
||||||
@@ -173,10 +173,10 @@
|
|||||||
"publishFailed": "发布失败",
|
"publishFailed": "发布失败",
|
||||||
"sourceTypeFull": "生成方式:{{source}} · 号码条数:{{count}}/23 · RNG 摘要:{{hash}}",
|
"sourceTypeFull": "生成方式:{{source}} · 号码条数:{{count}}/23 · RNG 摘要:{{hash}}",
|
||||||
"subnav": {
|
"subnav": {
|
||||||
"status": "期号状态",
|
"status": "概览",
|
||||||
"results": "开奖结果",
|
"results": "开奖结果",
|
||||||
"finance": "期号收支",
|
"finance": "收支",
|
||||||
"review": "审核与发布",
|
"review": "开奖发布",
|
||||||
"riskOccupancy": "风控占用",
|
"riskOccupancy": "风控占用",
|
||||||
"riskLockLogs": "占用流水",
|
"riskLockLogs": "占用流水",
|
||||||
"riskHot": "热门号码",
|
"riskHot": "热门号码",
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
{
|
{
|
||||||
"title": "奖池",
|
"title": "奖池",
|
||||||
"configTitle": "奖池配置",
|
"configTitle": "奖池",
|
||||||
"pageDescription": "维护各币种奖池参数,下方可查询蓄水与派彩流水。",
|
"pageTabs": "奖池",
|
||||||
"poolsSectionDescription": "蓄水比例、爆池阈值、开关与手动爆池。",
|
"tabConfig": "配置",
|
||||||
"rulesTitle": "规则说明",
|
"tabRecords": "流水",
|
||||||
"rulesJoin": "只有提交成功且满足最低参与下注额的注项,才会按蓄水比例进入奖池。",
|
|
||||||
"rulesBurst": "奖池会在达到爆池阈值、达到强制爆池间隔,或命中指定组合触发玩法时释放。",
|
|
||||||
"rulesManual": "手动爆池仅限超管兜底使用,可填写后台期号数字 ID 或期号编码。",
|
|
||||||
"recordsSectionTitle": "蓄水与派彩流水",
|
|
||||||
"recordsSectionDescription": "按条件筛选派彩记录与蓄水明细,只读查询。",
|
|
||||||
"loadFailed": "加载失败",
|
"loadFailed": "加载失败",
|
||||||
"saveSuccess": "已保存",
|
"saveSuccess": "已保存",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
@@ -19,7 +14,6 @@
|
|||||||
"displayBalance": "展示余额 {{amount}}",
|
"displayBalance": "展示余额 {{amount}}",
|
||||||
"currentAmount": "当前池余额(主币单位)",
|
"currentAmount": "当前池余额(主币单位)",
|
||||||
"balanceAdjustmentTitle": "余额调整",
|
"balanceAdjustmentTitle": "余额调整",
|
||||||
"balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。",
|
|
||||||
"adjustmentDirection": "方向",
|
"adjustmentDirection": "方向",
|
||||||
"adjustmentIncrease": "增加",
|
"adjustmentIncrease": "增加",
|
||||||
"adjustmentDecrease": "减少",
|
"adjustmentDecrease": "减少",
|
||||||
@@ -35,31 +29,25 @@
|
|||||||
"confirmAdjustmentTitle": "确认提交奖池余额调整?",
|
"confirmAdjustmentTitle": "确认提交奖池余额调整?",
|
||||||
"confirmAdjustmentDescription": "将写入调整流水并更新当前池余额,请确认金额与原因无误。",
|
"confirmAdjustmentDescription": "将写入调整流水并更新当前池余额,请确认金额与原因无误。",
|
||||||
"recentAdjustments": "最近调整记录",
|
"recentAdjustments": "最近调整记录",
|
||||||
"contributionRate": "蓄水比例 (%)",
|
"contributionRate": "蓄水比例 %",
|
||||||
"contributionRatePlaceholder": "如 2 表示 2%",
|
"triggerThreshold": "爆池阈值",
|
||||||
"triggerThreshold": "爆池阈值(主币单位)",
|
"payoutRate": "派彩比例 %",
|
||||||
"triggerThresholdPlaceholder": "请输入触发阈值",
|
"forceTriggerGap": "强制爆池间隔(期)",
|
||||||
"payoutRate": "爆池派彩比例 (%)",
|
"minBetAmount": "最低下注额",
|
||||||
"payoutRatePlaceholder": "如 5 表示 5%",
|
"comboTriggerPlays": "组合触发玩法",
|
||||||
"forceTriggerGap": "强制爆池间隔(已结算期数)",
|
"comboTriggerPlaysPlaceholder": "straight,ibox",
|
||||||
"forceTriggerGapPlaceholder": "请输入强制触发间隔期数",
|
|
||||||
"minBetAmount": "最低参与下注额(主币单位)",
|
|
||||||
"minBetAmountPlaceholder": "请输入最低下注金额",
|
|
||||||
"comboTriggerPlays": "组合触发玩法(逗号分隔)",
|
|
||||||
"comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox",
|
|
||||||
"status": "开关",
|
"status": "开关",
|
||||||
"disabled": "关闭",
|
"disabled": "关闭",
|
||||||
"enabled": "开启",
|
"enabled": "开启",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"confirmSavePoolTitle": "确认保存奖池配置?",
|
"confirmSavePoolTitle": "确认保存奖池配置?",
|
||||||
"confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含池余额);余额请使用「余额调整」。",
|
"confirmSavePoolDescription": "将更新奖池参数(不含余额)。",
|
||||||
"manualBurstDrawId": "手动爆池期号(ID 或编码)",
|
"manualBurstDrawId": "爆池期号",
|
||||||
"manualBurstHint": "仅超级管理员可在紧急情况下触发;可填写后台期号数字 ID 或期号编码。须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。",
|
|
||||||
"manualBurstConfirmTitle": "确认手动爆池?",
|
"manualBurstConfirmTitle": "确认手动爆池?",
|
||||||
"manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot,并扣减奖池余额。此操作不可自动撤销。",
|
"manualBurstConfirmDescription": "确认对期号 {{drawId}} 执行手动爆池?",
|
||||||
"processing": "处理中…",
|
"processing": "处理中…",
|
||||||
"manualBurst": "手动触发爆池(仅超管)",
|
"manualBurst": "手动爆池",
|
||||||
"manualBurstConfirm": "确认爆池",
|
"manualBurstConfirm": "确认爆池",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
@@ -72,7 +60,7 @@
|
|||||||
"title": "奖池记录",
|
"title": "奖池记录",
|
||||||
"description": "派彩记录与奖池蓄水流水"
|
"description": "派彩记录与奖池蓄水流水"
|
||||||
},
|
},
|
||||||
"poolsSectionTitle": "各币种奖池参数",
|
|
||||||
"payoutLoadFailed": "派彩记录加载失败",
|
"payoutLoadFailed": "派彩记录加载失败",
|
||||||
"contributionLoadFailed": "蓄水记录加载失败",
|
"contributionLoadFailed": "蓄水记录加载失败",
|
||||||
"trigger": "触发",
|
"trigger": "触发",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "对账",
|
"title": "对账",
|
||||||
"createTitle": "发起对账扫描",
|
"workflowHint": "本页按日期扫描并留存对账任务。处理当前仍未解决的异常转账单,请点右上角入口(打开钱包 → 转账单,并自动勾选「仅异常单」)。",
|
||||||
"createHint": "将扫描所选日期内的转账单,比对彩票钱包流水,并在已配置主站 API 时核对主站幂等记录。",
|
"shortcutAbnormalTransfers": "当前异常转账单",
|
||||||
|
"viewOnlyHint": "当前账号仅可查看历史对账任务。",
|
||||||
|
"createTitle": "发起扫描",
|
||||||
|
"createHint": "",
|
||||||
"reconcileType": "对账类型",
|
"reconcileType": "对账类型",
|
||||||
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
|
"reconcileTypeFixed": "钱包划转(主站 ⇄ 彩票)",
|
||||||
"dateRange": "对账日期范围",
|
"dateRange": "对账日期范围",
|
||||||
@@ -12,13 +15,18 @@
|
|||||||
"periodRequired": "请填写对账日期范围(开始与结束)",
|
"periodRequired": "请填写对账日期范围(开始与结束)",
|
||||||
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
|
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
|
||||||
"confirmCreateTitle": "确认发起对账扫描?",
|
"confirmCreateTitle": "确认发起对账扫描?",
|
||||||
"confirmCreateDescription": "将扫描所选日期范围{{playerHint}}内的转账单,并自动生成差异明细。",
|
"confirmCreateDescription": "扫描所选日期{{playerHint}}内的转账单。",
|
||||||
"confirmCreatePlayer": "内指定玩家",
|
"confirmCreatePlayer": "内指定玩家",
|
||||||
"confirmCreateAllPlayers": "内全部玩家",
|
"confirmCreateAllPlayers": "内全部玩家",
|
||||||
"createSuccess": "扫描完成,发现 {{count}} 条异常",
|
"createSuccess": "扫描完成,发现 {{count}} 条异常",
|
||||||
"createSuccessEmpty": "扫描完成,未发现异常",
|
"createSuccessEmpty": "扫描完成,未发现异常",
|
||||||
"createFailed": "扫描失败",
|
"createFailed": "扫描失败",
|
||||||
"noCreatePermission": "当前账号无发起对账扫描权限。",
|
"noCreatePermission": "当前账号无发起对账扫描权限。",
|
||||||
|
"filterOpen": "仅待处理",
|
||||||
|
"filterAll": "全部明细",
|
||||||
|
"filterCount": "本页 {{shown}} / 共 {{total}} 条",
|
||||||
|
"filterOpenEmpty": "本页无待处理明细,可切换「全部明细」或翻页。",
|
||||||
|
"playerEmpty": "无匹配玩家",
|
||||||
"jobsTitle": "对账任务",
|
"jobsTitle": "对账任务",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"jobNo": "任务号",
|
"jobNo": "任务号",
|
||||||
@@ -31,16 +39,25 @@
|
|||||||
"finishedAt": "完成时间",
|
"finishedAt": "完成时间",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"operate": "操作",
|
"operate": "操作",
|
||||||
"viewDetails": "查看差异明细",
|
"viewDetails": "查看明细",
|
||||||
|
"hideDetails": "收起",
|
||||||
|
"closeDetails": "关闭明细",
|
||||||
"detailsTitle": "差异明细",
|
"detailsTitle": "差异明细",
|
||||||
|
"detailsEmpty": "暂无明细",
|
||||||
|
"itemStatusColumn": "状态",
|
||||||
|
"itemIssueColumn": "异常类型",
|
||||||
|
"itemHandlingColumn": "处理进度",
|
||||||
|
"itemScanFindingColumn": "扫描发现",
|
||||||
|
"itemCurrentStatusColumn": "当前状态",
|
||||||
|
"itemScanFindingResolvedHint": "扫描时记录,现已处理",
|
||||||
"transferNo": "转账单号",
|
"transferNo": "转账单号",
|
||||||
"walletTxnNo": "彩票钱包流水号",
|
"walletTxnNo": "彩票钱包流水号",
|
||||||
"mainSiteRef": "主站流水号",
|
"mainSiteRef": "主站流水号",
|
||||||
"mainSiteCheck": "主站核对",
|
"mainSiteCheck": "主站核对",
|
||||||
"differenceAmount": "差额(分)",
|
"differenceAmount": "差额",
|
||||||
"itemResult": "检查结果",
|
"itemResult": "检查结果",
|
||||||
"processingStatus": "处理状态",
|
"processingStatus": "处理状态",
|
||||||
"actions": "处理",
|
"actions": "操作",
|
||||||
"openTransferOrder": "查看转账单",
|
"openTransferOrder": "查看转账单",
|
||||||
"openWalletTxn": "查看钱包流水",
|
"openWalletTxn": "查看钱包流水",
|
||||||
"detectedAt": "发现时间",
|
"detectedAt": "发现时间",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"title": "报表中心",
|
"title": "报表中心",
|
||||||
"subtitle": "集中查看运营、资金、风控与审计报表,统一按维度筛选后导出。",
|
"subtitle": "",
|
||||||
|
"profitScopeHint": "注单口径,非信用账期结算",
|
||||||
|
"shortcuts": {
|
||||||
|
"wallet": "转账单列表",
|
||||||
|
"risk": "风控中心",
|
||||||
|
"audit": "审计日志"
|
||||||
|
},
|
||||||
"pageGuide": "",
|
"pageGuide": "",
|
||||||
"exportPanel": "导出设置",
|
"exportPanel": "导出设置",
|
||||||
"chooseReport": "选择要导出的报表",
|
"chooseReport": "选择要导出的报表",
|
||||||
@@ -86,8 +92,12 @@
|
|||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"title": "数据预览",
|
"title": "数据预览",
|
||||||
"subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。",
|
"subtitle": "",
|
||||||
"empty": "暂无数据,请调整筛选条件后重试。",
|
"empty": "暂无数据",
|
||||||
|
"sections": {
|
||||||
|
"settlementBatches": "结算批次",
|
||||||
|
"lockLogs": "占用日志"
|
||||||
|
},
|
||||||
"exportableRows": "行可导出",
|
"exportableRows": "行可导出",
|
||||||
"summaryScopeHint": "上方统计卡除“记录数”外,默认按当前预览页汇总;需要全量口径请使用“导出 CSV/Excel(全量)”。",
|
"summaryScopeHint": "上方统计卡除“记录数”外,默认按当前预览页汇总;需要全量口径请使用“导出 CSV/Excel(全量)”。",
|
||||||
"scope": {
|
"scope": {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"unsettledTickets": "未结算注单",
|
"unsettledTickets": "未结算注单",
|
||||||
"openReportHint": "进行中账期:占成/输赢来自账期内流水;账单数在关账后更新。",
|
"openReportHint": "进行中账期:占成/输赢来自账期内流水;账单数在关账后更新。",
|
||||||
"viewDetail": "查看详情",
|
"viewDetail": "查看详情",
|
||||||
|
"processBills": "处理账单",
|
||||||
"close": "关账",
|
"close": "关账",
|
||||||
"closeNow": "立即关账",
|
"closeNow": "立即关账",
|
||||||
"hasOpen": "已有进行中账期 {{range}},须先关账才能开新期。",
|
"hasOpen": "已有进行中账期 {{range}},须先关账才能开新期。",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"periods": "账期",
|
"periods": "账期",
|
||||||
"bills": "账单",
|
"bills": "账单",
|
||||||
"operations": "收付与调账",
|
"operations": "收付与调账",
|
||||||
|
"aria": "账期视图",
|
||||||
"ledger": "账务流水",
|
"ledger": "账务流水",
|
||||||
"creditLedger": "信用流水",
|
"creditLedger": "信用流水",
|
||||||
"playerBills": "玩家账单",
|
"playerBills": "玩家账单",
|
||||||
@@ -198,7 +200,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"detail": "详情",
|
"detail": "详情",
|
||||||
"viewBill": "查看账单",
|
"viewBill": "查看账单",
|
||||||
"billDetail": "账单详情"
|
"billDetail": "账单详情",
|
||||||
|
"billDetailWithId": "账单 #{{id}}"
|
||||||
},
|
},
|
||||||
"billDisplay": {
|
"billDisplay": {
|
||||||
"settlementFlow": "谁付谁",
|
"settlementFlow": "谁付谁",
|
||||||
@@ -235,6 +238,7 @@
|
|||||||
"unpaidAwaitingPayment": "请登记线下收付",
|
"unpaidAwaitingPayment": "请登记线下收付",
|
||||||
"fullySettled": "本期已结清",
|
"fullySettled": "本期已结清",
|
||||||
"confirmHint": "确认后才可以登记收款或付款。",
|
"confirmHint": "确认后才可以登记收款或付款。",
|
||||||
|
"advancedActions": "调账 / 坏账",
|
||||||
"recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}})",
|
"recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}})",
|
||||||
"recordPayoutTo": "登记付款({{payer}} 付给 {{payee}})",
|
"recordPayoutTo": "登记付款({{payer}} 付给 {{payee}})",
|
||||||
"rebateAllocationsHint": "各层级代理对回水的承担明细。",
|
"rebateAllocationsHint": "各层级代理对回水的承担明细。",
|
||||||
@@ -321,7 +325,13 @@
|
|||||||
"hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。",
|
"hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。",
|
||||||
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
|
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
|
||||||
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。",
|
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。",
|
||||||
"intro": "关账后生成的占成账单。可按类型或状态筛选,详情内确认或登记收付。"
|
"intro": "关账后生成的占成账单。可按类型或状态筛选,详情内确认或登记收付。",
|
||||||
|
"confirmOneTitle": "确认账单 #{{id}}?",
|
||||||
|
"confirmOneDesc": "确认后进入待收付,可登记线下收付。",
|
||||||
|
"confirmOneBtn": "确认",
|
||||||
|
"confirmedOne": "已确认",
|
||||||
|
"confirmFailed": "确认失败",
|
||||||
|
"payBtn": "收付"
|
||||||
},
|
},
|
||||||
"panels": {
|
"panels": {
|
||||||
"workbench": {
|
"workbench": {
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
"subnavLabel": "钱包子页",
|
"subnavLabel": "钱包子页",
|
||||||
"subnavTransactions": "主站钱包流水",
|
"subnavTransactions": "主站钱包流水",
|
||||||
"subnavTransferOrders": "主站转账单",
|
"subnavTransferOrders": "主站转账单",
|
||||||
"scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的账期结账请查看",
|
|
||||||
"scopeHintSettlementLink": "结算中心",
|
|
||||||
"scopeHintSettlement": "结算中心",
|
|
||||||
"ledgerChannel": "账本",
|
"ledgerChannel": "账本",
|
||||||
"ledgerCredit": "信用流水",
|
"ledgerCredit": "信用流水",
|
||||||
"ledgerWallet": "钱包流水",
|
"ledgerWallet": "钱包流水",
|
||||||
@@ -43,6 +41,9 @@
|
|||||||
"options": "选项",
|
"options": "选项",
|
||||||
"abnormalOnly": "仅异常单",
|
"abnormalOnly": "仅异常单",
|
||||||
"abnormalOnlyPending": "仅异常(待对账)",
|
"abnormalOnlyPending": "仅异常(待对账)",
|
||||||
|
"abnormalFilterActive": "当前仅显示对账标记的异常转账单(非钱包流水)。可在下方取消勾选「仅异常单」查看全部。",
|
||||||
|
"txnAbnormalFilterActive": "当前仅显示状态为「待对账」的流水。勾选后状态下拉无效,与接口 abnormal=1 一致。",
|
||||||
|
"txnDeepLinkActive": "已按链接带入筛选条件并自动查询。重置可清空 URL 参数。",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"resetFilters": "重置筛选",
|
"resetFilters": "重置筛选",
|
||||||
"refreshCurrentPage": "刷新当前页",
|
"refreshCurrentPage": "刷新当前页",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/** 后台/彩票端登录账号:字母、数字、点、下划线、连字符。 */
|
/** 后台/彩票端登录账号:字母、数字、点、下划线、连字符。 */
|
||||||
export const ADMIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
export const ADMIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
||||||
|
|
||||||
export const ADMIN_PASSWORD_MIN_LENGTH = 8;
|
/** 全项目统一:后台账号、代理账号、彩票端玩家密码最短长度 */
|
||||||
export const NATIVE_PLAYER_PASSWORD_MIN_LENGTH = 6;
|
export const PASSWORD_MIN_LENGTH = 6;
|
||||||
|
export const ADMIN_PASSWORD_MIN_LENGTH = PASSWORD_MIN_LENGTH;
|
||||||
|
export const NATIVE_PLAYER_PASSWORD_MIN_LENGTH = PASSWORD_MIN_LENGTH;
|
||||||
|
|
||||||
export type AccountValidationIssue = "empty" | "invalid_charset";
|
export type AccountValidationIssue = "empty" | "invalid_charset";
|
||||||
export type PasswordValidationIssue = "empty" | "too_short";
|
export type PasswordValidationIssue = "empty" | "too_short";
|
||||||
|
|||||||
@@ -23,16 +23,19 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
|||||||
settings: "settings",
|
settings: "settings",
|
||||||
integration: "integration",
|
integration: "integration",
|
||||||
agents: "agents",
|
agents: "agents",
|
||||||
agent_list: "agent_list",
|
|
||||||
config: "config",
|
config: "config",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LEGACY_NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
||||||
|
agent_list: "agents",
|
||||||
|
};
|
||||||
|
|
||||||
export function adminNavLabel(
|
export function adminNavLabel(
|
||||||
segment: string,
|
segment: string,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
apiLabel?: string | null,
|
apiLabel?: string | null,
|
||||||
): string {
|
): string {
|
||||||
const key = NAV_SEGMENT_I18N_KEYS[segment];
|
const key = NAV_SEGMENT_I18N_KEYS[segment] ?? LEGACY_NAV_SEGMENT_I18N_KEYS[segment];
|
||||||
if (key) {
|
if (key) {
|
||||||
return t(`nav.${key}`, { ns: "common" });
|
return t(`nav.${key}`, { ns: "common" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
|||||||
"/admin/settlement-batches": { ns: "settlement", key: "batchList" },
|
"/admin/settlement-batches": { ns: "settlement", key: "batchList" },
|
||||||
"/admin/reconcile": { ns: "reconcile", key: "title" },
|
"/admin/reconcile": { ns: "reconcile", key: "title" },
|
||||||
"/admin/reports": { ns: "reports", key: "title" },
|
"/admin/reports": { ns: "reports", key: "title" },
|
||||||
|
"/admin/reports/profit": { ns: "reports", key: "title" },
|
||||||
|
"/admin/reports/wallet": { ns: "reports", key: "title" },
|
||||||
|
"/admin/reports/risk": { ns: "reports", key: "title" },
|
||||||
|
"/admin/reports/audit": { ns: "reports", key: "title" },
|
||||||
"/admin/audit-logs": { ns: "audit", key: "title" },
|
"/admin/audit-logs": { ns: "audit", key: "title" },
|
||||||
"/admin/admin-users": { ns: "adminUsers", key: "title" },
|
"/admin/admin-users": { ns: "adminUsers", key: "title" },
|
||||||
"/admin/admin-roles": { ns: "adminRoles", key: "title" },
|
"/admin/admin-roles": { ns: "adminRoles", key: "title" },
|
||||||
"/admin/agents": { ns: "agents", key: "title" },
|
"/admin/agents": { ns: "agents", key: "title" },
|
||||||
"/admin/agents/list": { ns: "agents", key: "listTitle" },
|
|
||||||
"/admin/agents/provision": { ns: "agents", key: "subnav.provision" },
|
"/admin/agents/provision": { ns: "agents", key: "subnav.provision" },
|
||||||
"/admin/agents/sites": { ns: "config", key: "integrationSites.title" },
|
"/admin/agents/sites": { ns: "config", key: "integrationSites.title" },
|
||||||
"/admin/settlement-center": { ns: "settlementCenter", key: "title" },
|
"/admin/settlement-center": { ns: "settlementCenter", key: "title" },
|
||||||
|
|||||||
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,
|
PRD_WALLET_ADJUST_MANAGE,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** 报表中心页(含资金/风控/审计类导出报表入口) */
|
||||||
|
export const PRD_REPORTS_PAGE_ACCESS_ANY = [
|
||||||
|
PRD_REPORT_VIEW,
|
||||||
|
PRD_REPORT_EXPORT,
|
||||||
|
...PRD_WALLET_TRANSFER_ACCESS_ANY,
|
||||||
|
...PRD_RISK_ACCESS_ANY,
|
||||||
|
PRD_AUDIT_VIEW,
|
||||||
|
] as const;
|
||||||
|
|
||||||
/** 单玩家钱包查询 */
|
/** 单玩家钱包查询 */
|
||||||
export const PRD_WALLET_PLAYER_ACCESS_ANY = [
|
export const PRD_WALLET_PLAYER_ACCESS_ANY = [
|
||||||
PRD_USERS_VIEW_FINANCE,
|
PRD_USERS_VIEW_FINANCE,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
|||||||
{
|
{
|
||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
agents: Network,
|
agents: Network,
|
||||||
agent_list: Users,
|
|
||||||
players: Users,
|
players: Users,
|
||||||
draws: CalendarClock,
|
draws: CalendarClock,
|
||||||
rules_plays: ClipboardList,
|
rules_plays: ClipboardList,
|
||||||
@@ -56,6 +55,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
|||||||
|
|
||||||
/** 旧版 localStorage / 接口缓存中的 segment,避免首屏侧栏崩溃 */
|
/** 旧版 localStorage / 接口缓存中的 segment,避免首屏侧栏崩溃 */
|
||||||
const legacyAdminNavIconBySegment: Record<string, LucideIcon> = {
|
const legacyAdminNavIconBySegment: Record<string, LucideIcon> = {
|
||||||
|
agent_list: Users,
|
||||||
config: SlidersHorizontal,
|
config: SlidersHorizontal,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export type AdminNavGroup =
|
|||||||
export type AdminNavSegment =
|
export type AdminNavSegment =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "agents"
|
| "agents"
|
||||||
| "agent_list"
|
|
||||||
| "players"
|
| "players"
|
||||||
| "draws"
|
| "draws"
|
||||||
| "rules_plays"
|
| "rules_plays"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import { putAdminUser } from "@/api/admin-users";
|
import { putAdminUser } from "@/api/admin-users";
|
||||||
|
import { validateAdminPassword } from "@/lib/admin-input-validation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -62,6 +63,10 @@ export function AccountSettingsConsole() {
|
|||||||
toast.error(t("validation.passwordMismatch"));
|
toast.error(t("validation.passwordMismatch"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (validateAdminPassword(password) === "too_short") {
|
||||||
|
toast.error(t("validation.passwordMinLength", { defaultValue: "密码至少需要 6 个字符" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!adminProfile) {
|
if (!adminProfile) {
|
||||||
toast.error(t("actions.updateFailed"));
|
toast.error(t("actions.updateFailed"));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
|||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
|
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
|
||||||
|
import { permissionProfileForGroup, resolveProfileLevel } from "@/lib/admin-permission-profiles";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
@@ -57,8 +58,26 @@ function permissionGroupLabel(key: string, fallback: string, t: (key: string) =>
|
|||||||
return translated === `permissionGroups.${key}` ? fallback : translated;
|
return translated === `permissionGroups.${key}` ? fallback : translated;
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionPackageLabel(key: string, fallback: string, t: (key: string, options?: { defaultValue?: string }) => string): string {
|
function countEnabledAreas(
|
||||||
return t(`permissionLevels.${key}`, { defaultValue: fallback });
|
permissionSlugs: string[],
|
||||||
|
catalog: AdminPermissionCatalogData | null,
|
||||||
|
isSuperAdmin: boolean,
|
||||||
|
): number {
|
||||||
|
if (!catalog) {
|
||||||
|
return permissionSlugs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const group of catalog.permission_menu_groups ?? []) {
|
||||||
|
const profile = permissionProfileForGroup(group.key);
|
||||||
|
if (profile === undefined || (profile.platformOnly && !isSuperAdmin)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (resolveProfileLevel(profile, permissionSlugs) !== "none") {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminRolesConsole(): React.ReactElement {
|
export function AdminRolesConsole(): React.ReactElement {
|
||||||
@@ -67,6 +86,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
|
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
|
||||||
|
const isSuperAdmin = profile?.is_super_admin === true;
|
||||||
const exportLabels = useExportLabels("adminRoles");
|
const exportLabels = useExportLabels("adminRoles");
|
||||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||||
@@ -270,11 +290,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("roleListHint", {
|
|
||||||
defaultValue: "可新增自定义角色并配置权限;内置角色(超级管理员、站点管理员、代理)不可删除。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table id="admin-roles-table">
|
<Table id="admin-roles-table">
|
||||||
@@ -282,19 +298,19 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||||
<TableHead>{t("roleTable.name")}</TableHead>
|
<TableHead>{t("roleTable.name")}</TableHead>
|
||||||
<TableHead>{t("roleTable.slug")}</TableHead>
|
{isSuperAdmin ? <TableHead>{t("roleTable.slug")}</TableHead> : null}
|
||||||
<TableHead>{t("roleTable.type")}</TableHead>
|
<TableHead>{t("roleTable.type")}</TableHead>
|
||||||
<TableHead>{t("roleTable.status")}</TableHead>
|
<TableHead>{t("roleTable.status")}</TableHead>
|
||||||
<TableHead>{t("roleTable.users")}</TableHead>
|
<TableHead>{t("roleTable.users")}</TableHead>
|
||||||
<TableHead>{t("roleTable.permissions")}</TableHead>
|
<TableHead>{t("roleTable.enabledAreas")}</TableHead>
|
||||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("roleTable.actions")}</TableHead>
|
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("roleTable.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading && roles.length === 0 ? (
|
{loading && roles.length === 0 ? (
|
||||||
<AdminTableLoadingRow colSpan={8} />
|
<AdminTableLoadingRow colSpan={isSuperAdmin ? 8 : 7} />
|
||||||
) : roles.length === 0 ? (
|
) : roles.length === 0 ? (
|
||||||
<AdminTableNoResourceRow colSpan={8} className="text-muted-foreground" />
|
<AdminTableNoResourceRow colSpan={isSuperAdmin ? 8 : 7} className="text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
roles.map((role) => {
|
roles.map((role) => {
|
||||||
const fixedRole = isPlatformFixedRole(role);
|
const fixedRole = isPlatformFixedRole(role);
|
||||||
@@ -306,7 +322,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="font-medium">{role.name}</span>
|
<span className="font-medium">{role.name}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{role.slug}</TableCell>
|
{isSuperAdmin ? <TableCell className="font-mono text-xs text-muted-foreground">{role.slug}</TableCell> : null}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{role.is_system ? (
|
{role.is_system ? (
|
||||||
<Badge variant="secondary">{t("roleType.system")}</Badge>
|
<Badge variant="secondary">{t("roleType.system")}</Badge>
|
||||||
@@ -320,7 +336,9 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
||||||
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
<TableCell className="tabular-nums">
|
||||||
|
{countEnabledAreas(role.permission_slugs, catalog, isSuperAdmin)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
{canManageRoles ? (
|
{canManageRoles ? (
|
||||||
<AdminRowActionsMenu
|
<AdminRowActionsMenu
|
||||||
@@ -370,11 +388,10 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
||||||
<DialogTitle className="text-[15px] font-semibold tracking-tight text-foreground">
|
<DialogTitle className="text-[15px] font-semibold tracking-tight text-foreground">
|
||||||
{t("rolePermissionDialog.title")}
|
{selectedRole
|
||||||
|
? t("rolePermissionDialog.titleWithName", { name: selectedRole.name })
|
||||||
|
: t("rolePermissionDialog.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-muted-foreground">
|
|
||||||
{selectedRole ? selectedRole.name : null}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
||||||
<AdminPermissionPackageSelector
|
<AdminPermissionPackageSelector
|
||||||
@@ -382,11 +399,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
selectedSlugs={draftRolePermissions}
|
selectedSlugs={draftRolePermissions}
|
||||||
onChange={setDraftRolePermissions}
|
onChange={setDraftRolePermissions}
|
||||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
isSuperAdmin={isSuperAdmin}
|
||||||
helperText={t("rolePermissionDialog.packageHint", {
|
|
||||||
defaultValue:
|
|
||||||
"勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。",
|
|
||||||
})}
|
|
||||||
emptyText={t("states.noData", { ns: "common" })}
|
emptyText={t("states.noData", { ns: "common" })}
|
||||||
heightClassName="h-[min(56vh,520px)]"
|
heightClassName="h-[min(56vh,520px)]"
|
||||||
/>
|
/>
|
||||||
@@ -404,7 +417,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
title: t("confirmSaveRolePermissionsTitle"),
|
title: t("confirmSaveRolePermissionsTitle"),
|
||||||
description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }),
|
description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }),
|
||||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
confirmVariant: "destructive",
|
|
||||||
onConfirm: () => saveRolePermissions(),
|
onConfirm: () => saveRolePermissions(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -421,7 +433,6 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{editingRoleId === null ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
|
{editingRoleId === null ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
|||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { validateAdminPassword } from "@/lib/admin-input-validation";
|
||||||
import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone";
|
import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -266,7 +267,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
toast.error(t("nicknameRequired"));
|
toast.error(t("nicknameRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (accountMode === "edit" && formPassword !== "" && formPassword.length < 8) {
|
if (accountMode === "edit" && formPassword !== "" && validateAdminPassword(formPassword) === "too_short") {
|
||||||
toast.error(t("newPasswordMin"));
|
toast.error(t("newPasswordMin"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -287,7 +288,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
toast.error(t("usernameRequired"));
|
toast.error(t("usernameRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (formPassword.length < 8) {
|
if (validateAdminPassword(formPassword) === "too_short") {
|
||||||
toast.error(t("passwordMin"));
|
toast.error(t("passwordMin"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -404,12 +405,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
{t("modelGuidePlatform", {
|
|
||||||
defaultValue:
|
|
||||||
"这里只管理平台账号与平台角色。代理账号请到「代理经营」中创建和维护;账号层只绑定角色,不直接分配功能权限。",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="admin-list-toolbar">
|
<div className="admin-list-toolbar">
|
||||||
<div className="admin-list-field xl:min-w-0">
|
<div className="admin-list-field xl:min-w-0">
|
||||||
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">
|
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">
|
||||||
|
|||||||
@@ -16,136 +16,101 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
|
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
|
||||||
import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields";
|
import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields";
|
||||||
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { percentValueToUi } from "@/lib/admin-rate-percent";
|
import { percentValueToUi } from "@/lib/admin-rate-percent";
|
||||||
import { isLineRootAgentNode } from "@/lib/agent-profile-caps";
|
|
||||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
|
|
||||||
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
|
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
|
import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
|
||||||
|
|
||||||
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null {
|
export type AgentDetailTab = "profile" | "downline" | "players";
|
||||||
if (
|
|
||||||
totalShareRate == null ||
|
|
||||||
parentShareRate == null ||
|
|
||||||
parentShareRate <= 0
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return percentValueToUi((totalShareRate / parentShareRate) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
|
|
||||||
|
|
||||||
export type AgentLineDetailPanelProps = {
|
export type AgentLineDetailPanelProps = {
|
||||||
node: AgentNodeRow | null;
|
node: AgentNodeRow | null;
|
||||||
profile: AgentProfileRow | null;
|
profile: AgentProfileRow | null;
|
||||||
profileLoading: boolean;
|
profileLoading: boolean;
|
||||||
|
profileError?: string | null;
|
||||||
childAgents: AgentNodeRow[];
|
childAgents: AgentNodeRow[];
|
||||||
childCountById: Map<number, number>;
|
childCountById: Map<number, number>;
|
||||||
siteCode: string;
|
siteCode: string;
|
||||||
siteLabel: string | null;
|
|
||||||
parentName: string | null;
|
parentName: string | null;
|
||||||
detailTab: AgentDetailTab;
|
detailTab: AgentDetailTab;
|
||||||
onDetailTabChange: (tab: AgentDetailTab) => void;
|
onDetailTabChange: (tab: AgentDetailTab) => void;
|
||||||
canViewProfileTab: boolean;
|
canViewProfileTab: boolean;
|
||||||
canEditProfileTab: boolean;
|
canEditProfileTab: boolean;
|
||||||
profileReadOnly: boolean;
|
canSaveProfileTab: boolean;
|
||||||
canViewDownlineTab: boolean;
|
canViewDownlineTab: boolean;
|
||||||
canViewPlayersTab: boolean;
|
canViewPlayersTab: boolean;
|
||||||
playersTabHint?: string | null;
|
|
||||||
canManageNode: boolean;
|
canManageNode: boolean;
|
||||||
canCreateChild: boolean;
|
canCreateChild: boolean;
|
||||||
canCreateChildAgent: boolean;
|
|
||||||
canCreatePlayerAction: boolean;
|
canCreatePlayerAction: boolean;
|
||||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||||
onEditChild: (node: AgentNodeRow) => void;
|
onEditChild: (node: AgentNodeRow) => void;
|
||||||
onDeleteChild: (node: AgentNodeRow) => void;
|
onDeleteChild: (node: AgentNodeRow) => void;
|
||||||
onAddChild: () => void;
|
onAddChild: () => void;
|
||||||
onAddPlayer: () => void;
|
|
||||||
onEditCurrent: () => void;
|
onEditCurrent: () => void;
|
||||||
onSelectChild: (node: AgentNodeRow) => void;
|
onSelectChild: (node: AgentNodeRow) => void;
|
||||||
profileFields: AgentProfileFieldsProps | null;
|
profileFields: AgentProfileFieldsProps | null;
|
||||||
profileSaving: boolean;
|
profileSaving: boolean;
|
||||||
onSaveProfile: () => void;
|
onSaveProfile: () => void;
|
||||||
playerCreateRequestKey?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AgentLineDetailPanel({
|
export function AgentLineDetailPanel({
|
||||||
node,
|
node,
|
||||||
profile,
|
profile,
|
||||||
profileLoading,
|
profileLoading,
|
||||||
|
profileError = null,
|
||||||
childAgents,
|
childAgents,
|
||||||
childCountById,
|
childCountById,
|
||||||
siteCode,
|
siteCode,
|
||||||
siteLabel,
|
|
||||||
parentName,
|
parentName,
|
||||||
detailTab,
|
detailTab,
|
||||||
onDetailTabChange,
|
onDetailTabChange,
|
||||||
canViewProfileTab,
|
canViewProfileTab,
|
||||||
canEditProfileTab,
|
canEditProfileTab,
|
||||||
profileReadOnly,
|
canSaveProfileTab,
|
||||||
canViewDownlineTab,
|
canViewDownlineTab,
|
||||||
canViewPlayersTab,
|
canViewPlayersTab,
|
||||||
playersTabHint,
|
|
||||||
canManageNode,
|
canManageNode,
|
||||||
canCreateChild,
|
canCreateChild,
|
||||||
canCreateChildAgent,
|
|
||||||
canCreatePlayerAction,
|
canCreatePlayerAction,
|
||||||
canDeleteChild,
|
canDeleteChild,
|
||||||
onEditChild,
|
onEditChild,
|
||||||
onDeleteChild,
|
onDeleteChild,
|
||||||
onAddChild,
|
onAddChild,
|
||||||
onAddPlayer,
|
|
||||||
onEditCurrent,
|
onEditCurrent,
|
||||||
onSelectChild,
|
onSelectChild,
|
||||||
profileFields,
|
profileFields,
|
||||||
profileSaving,
|
profileSaving,
|
||||||
onSaveProfile,
|
onSaveProfile,
|
||||||
playerCreateRequestKey = 0,
|
|
||||||
}: AgentLineDetailPanelProps): React.ReactElement {
|
}: AgentLineDetailPanelProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
|
|
||||||
if (node === null) {
|
if (node === null) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center bg-muted/20 px-6 py-20 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center px-6 py-20 text-center">
|
||||||
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80 bg-background">
|
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80">
|
||||||
<Network className="size-6 text-muted-foreground/70" aria-hidden />
|
<Network className="size-6 text-muted-foreground/70" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-medium text-foreground">
|
<p className="mt-4 text-sm font-medium text-foreground">
|
||||||
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理查看占成与授信" })}
|
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理" })}
|
||||||
</p>
|
|
||||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
|
||||||
{t("lineUi.selectAgentHint", {
|
|
||||||
defaultValue: "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
|
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
|
||||||
{
|
|
||||||
key: "overview",
|
|
||||||
label: t("lineUi.tabOverview", { defaultValue: "概览" }),
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "profile",
|
key: "profile",
|
||||||
label: profileReadOnly
|
label: t("lineUi.tabProfileShort", { defaultValue: "占成授信" }),
|
||||||
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
|
|
||||||
: t("lineUi.tabProfile", { defaultValue: "占成与授信" }),
|
|
||||||
visible: canViewProfileTab,
|
visible: canViewProfileTab,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "downline",
|
key: "downline",
|
||||||
label: t("lineUi.tabDownline", { defaultValue: "直属下级" }),
|
label: t("lineUi.tabDownline", { defaultValue: "直属下级" }),
|
||||||
|
count: childAgents.length,
|
||||||
visible: canViewDownlineTab,
|
visible: canViewDownlineTab,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -155,165 +120,94 @@ export function AgentLineDetailPanel({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const siteDisplay =
|
const metaParts = [
|
||||||
siteLabel && siteCode.trim() !== ""
|
parentName
|
||||||
? `${siteLabel} (${siteCode})`
|
? t("lineUi.parentMeta", { defaultValue: "上级 {{name}}", name: parentName })
|
||||||
: siteLabel ?? siteCode;
|
: null,
|
||||||
const childActionHint = canCreateChild
|
node.username?.trim() ? node.username : node.code,
|
||||||
? null
|
].filter(Boolean);
|
||||||
: canCreateChildAgent
|
|
||||||
? t("lineUi.addChildUnavailableHint", {
|
|
||||||
defaultValue: "当前代理未开启“允许创建下级代理”,如需新增请先调整该代理配置。",
|
|
||||||
})
|
|
||||||
: t("lineUi.addChildNoPermissionHint", {
|
|
||||||
defaultValue: "当前账号没有为该节点创建下级代理的权限。",
|
|
||||||
});
|
|
||||||
const playerActionHint =
|
|
||||||
canViewPlayersTab && !canCreatePlayerAction ? playersTabHint ?? null : null;
|
|
||||||
const showPrimaryAction = detailTab === "downline" || detailTab === "players";
|
|
||||||
const primaryActionEnabled =
|
|
||||||
detailTab === "players" ? canCreatePlayerAction : canCreateChild;
|
|
||||||
const primaryActionLabel =
|
|
||||||
detailTab === "players"
|
|
||||||
? t("lineUi.createDirectPlayer", { defaultValue: "创建直属玩家" })
|
|
||||||
: t("createChild", { defaultValue: "添加下级代理" });
|
|
||||||
const primaryActionHint =
|
|
||||||
detailTab === "players" ? playerActionHint : childActionHint;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="shrink-0 bg-card shadow-[0_1px_0_rgb(216_230_251_/_35%)]">
|
<div className="shrink-0 border-b border-border/60">
|
||||||
<header className="border-b border-border/60 px-5 py-4 sm:px-6">
|
<header className="px-5 py-4 sm:px-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground">
|
<h2 className="truncate text-lg font-semibold tracking-tight">{node.name}</h2>
|
||||||
{node.name}
|
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
|
||||||
</h2>
|
{node.status === 1
|
||||||
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
|
? t("common:status.enabled", { defaultValue: "启用" })
|
||||||
{node.status === 1
|
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||||
? t("common:status.enabled", { defaultValue: "启用" })
|
</AdminStatusBadge>
|
||||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
</div>
|
||||||
</AdminStatusBadge>
|
{metaParts.length > 0 ? (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{metaParts.join(" · ")}</p>
|
||||||
|
) : null}
|
||||||
|
<AgentHeaderMetrics profile={profile} loading={profileLoading} />
|
||||||
</div>
|
</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 ? (
|
{canManageNode ? (
|
||||||
<>
|
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<Pencil className="mr-1.5 size-3.5" />
|
||||||
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
{t("lineUi.editAgent", { defaultValue: "编辑" })}
|
||||||
<Pencil className="mr-1.5 size-3.5" />
|
</Button>
|
||||||
{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}
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<AdminSubnav
|
<AdminSubnav
|
||||||
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
|
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
|
||||||
className="min-h-11 overflow-x-auto border-b border-border/60 px-4 sm:px-5"
|
className="min-h-11 overflow-x-auto px-4 sm:px-5"
|
||||||
>
|
>
|
||||||
{tabs
|
{tabs
|
||||||
.filter((tab) => tab.visible)
|
.filter((tab) => tab.visible)
|
||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
<AdminSubnavButton
|
<AdminSubnavButton
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
active={detailTab === tab.key}
|
active={detailTab === tab.key}
|
||||||
onClick={() => onDetailTabChange(tab.key)}
|
onClick={() => onDetailTabChange(tab.key)}
|
||||||
count={tab.count}
|
count={tab.count}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</AdminSubnavButton>
|
</AdminSubnavButton>
|
||||||
))}
|
))}
|
||||||
</AdminSubnav>
|
</AdminSubnav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-5 sm:px-6 sm:py-6">
|
||||||
{profileLoading && detailTab !== "overview" ? (
|
{profileError ? (
|
||||||
|
<p className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{profileError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{profileLoading && detailTab === "profile" ? (
|
||||||
<AdminLoadingInline className="py-16" />
|
<AdminLoadingInline className="py-16" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{detailTab === "overview" ? (
|
|
||||||
<OverviewTab
|
|
||||||
profile={profile}
|
|
||||||
profileLoading={profileLoading}
|
|
||||||
profileReadOnly={profileReadOnly}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{detailTab === "profile" && canViewProfileTab && profileFields && !profileLoading ? (
|
{detailTab === "profile" && canViewProfileTab && profileFields && !profileLoading ? (
|
||||||
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm">
|
<div className="mx-auto max-w-3xl space-y-5">
|
||||||
<CardHeader className="border-b border-border/60 pb-4">
|
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
|
||||||
<CardTitle className="text-base">
|
{canSaveProfileTab ? (
|
||||||
{profileReadOnly
|
<div className="flex justify-end">
|
||||||
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
|
<Button
|
||||||
: t("lineUi.tabProfile", { defaultValue: "占成与授信" })}
|
type="button"
|
||||||
</CardTitle>
|
disabled={profileSaving || profileFields.loading}
|
||||||
<p className="text-sm font-normal text-muted-foreground">
|
onClick={onSaveProfile}
|
||||||
{profileReadOnly
|
>
|
||||||
? isLineRootAgentNode(node)
|
{profileSaving
|
||||||
? t("lineUi.lineRootProfileReadOnlyHint", {
|
? t("common:actions.saving", { defaultValue: "保存中…" })
|
||||||
defaultValue:
|
: t("lineUi.saveProfile", { defaultValue: "保存" })}
|
||||||
"一级代理的占成、站点授信总额与回水由平台超管配置;您可向直属下级代理与玩家下放额度。",
|
</Button>
|
||||||
})
|
</div>
|
||||||
: t("lineUi.profileReadOnlyHint", {
|
) : null}
|
||||||
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
</div>
|
||||||
})
|
|
||||||
: t("lineUi.profileTabHint", {
|
|
||||||
defaultValue:
|
|
||||||
"占成、授信、回水与风控标签在此维护;登录名、密码与启停状态请用「编辑代理」。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-5">
|
|
||||||
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
|
|
||||||
{canManageNode && canEditProfileTab ? (
|
|
||||||
<div className="mt-6 flex justify-end border-t border-border/60 pt-5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="min-w-[10rem]"
|
|
||||||
disabled={profileSaving || profileFields.loading}
|
|
||||||
onClick={onSaveProfile}
|
|
||||||
>
|
|
||||||
{profileSaving
|
|
||||||
? t("common:actions.saving", { defaultValue: "保存中…" })
|
|
||||||
: t("lineUi.saveProfile", { defaultValue: "保存占成与授信" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{detailTab === "downline" && canViewDownlineTab && !profileLoading ? (
|
{detailTab === "downline" && canViewDownlineTab ? (
|
||||||
<DownlineTable
|
<DownlineSection
|
||||||
childAgents={childAgents}
|
childAgents={childAgents}
|
||||||
childCountById={childCountById}
|
childCountById={childCountById}
|
||||||
parentTotalShareRate={profile?.total_share_rate}
|
|
||||||
canManageNode={canManageNode}
|
canManageNode={canManageNode}
|
||||||
canCreateChild={canCreateChild}
|
canCreateChild={canCreateChild}
|
||||||
canDeleteChild={canDeleteChild}
|
canDeleteChild={canDeleteChild}
|
||||||
@@ -324,13 +218,12 @@ export function AgentLineDetailPanel({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{detailTab === "players" && canViewPlayersTab && !profileLoading ? (
|
{detailTab === "players" && canViewPlayersTab ? (
|
||||||
<AgentsPlayersPanel
|
<AgentsPlayersPanel
|
||||||
siteCode={siteCode}
|
siteCode={siteCode}
|
||||||
agentNodeId={node.id}
|
agentNodeId={node.id}
|
||||||
allowCreatePlayer={canCreatePlayerAction}
|
allowCreatePlayer={canCreatePlayerAction}
|
||||||
embedded
|
embedded
|
||||||
createRequestKey={playerCreateRequestKey}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,150 +231,36 @@ export function AgentLineDetailPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewTab({
|
function AgentHeaderMetrics({
|
||||||
profile,
|
profile,
|
||||||
profileLoading,
|
loading,
|
||||||
profileReadOnly,
|
|
||||||
}: {
|
}: {
|
||||||
profile: AgentProfileRow | null;
|
profile: AgentProfileRow | null;
|
||||||
profileLoading: boolean;
|
loading: boolean;
|
||||||
profileReadOnly: boolean;
|
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation("agents");
|
||||||
|
|
||||||
const rebateCap =
|
if (loading) {
|
||||||
profile && !profileLoading ? percentValueToUi(profile.rebate_limit ?? 0) : null;
|
return <p className="text-sm text-muted-foreground">…</p>;
|
||||||
const parentRelativeShare = relativeShareRate(
|
}
|
||||||
profile?.total_share_rate,
|
|
||||||
profile?.parent_caps?.total_share_rate,
|
|
||||||
);
|
|
||||||
|
|
||||||
const yesLabel = t("common:states.yes", { defaultValue: "是" });
|
const items = [
|
||||||
const noLabel = t("common:states.no", { defaultValue: "否" });
|
`${t("profile.totalShareRate", { defaultValue: "占成" })} ${profile?.total_share_rate ?? 0}%`,
|
||||||
|
`${t("profile.creditLimit", { defaultValue: "授信" })} ${formatCredit(profile?.credit_limit ?? 0)}`,
|
||||||
|
`${t("lineUi.availableCredit", { defaultValue: "可下发" })} ${formatCredit(profile?.available_credit ?? 0)}`,
|
||||||
|
`${t("profile.rebateLimit", { defaultValue: "回水上限" })} ${percentValueToUi(profile?.rebate_limit ?? 0)}%`,
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<p className="text-sm text-muted-foreground">
|
||||||
{profileReadOnly ? (
|
{items.join(" · ")}
|
||||||
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
|
</p>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CapabilityMetric({
|
function DownlineSection({
|
||||||
label,
|
|
||||||
enabled,
|
|
||||||
loading = false,
|
|
||||||
yesLabel,
|
|
||||||
noLabel,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
enabled: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
yesLabel: string;
|
|
||||||
noLabel: string;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border/70 bg-card px-4 py-4 shadow-sm">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-1.5 text-2xl font-semibold tracking-tight",
|
|
||||||
loading ? "text-muted-foreground" : enabled ? "text-foreground" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{loading ? "…" : enabled ? yesLabel : noLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DownlineTable({
|
|
||||||
childAgents,
|
childAgents,
|
||||||
childCountById,
|
childCountById,
|
||||||
parentTotalShareRate,
|
|
||||||
canManageNode,
|
canManageNode,
|
||||||
canCreateChild,
|
canCreateChild,
|
||||||
canDeleteChild,
|
canDeleteChild,
|
||||||
@@ -492,7 +271,6 @@ function DownlineTable({
|
|||||||
}: {
|
}: {
|
||||||
childAgents: AgentNodeRow[];
|
childAgents: AgentNodeRow[];
|
||||||
childCountById: Map<number, number>;
|
childCountById: Map<number, number>;
|
||||||
parentTotalShareRate?: number;
|
|
||||||
canManageNode: boolean;
|
canManageNode: boolean;
|
||||||
canCreateChild: boolean;
|
canCreateChild: boolean;
|
||||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||||
@@ -502,174 +280,158 @@ function DownlineTable({
|
|||||||
onAddChild: () => void;
|
onAddChild: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
const createChildLabel = t("lineUi.createDownline", { defaultValue: "创建下级代理" });
|
|
||||||
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" });
|
if (childAgents.length === 0) {
|
||||||
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" });
|
return (
|
||||||
|
<AdminNoResourceState message={t("lineUi.downlineEmptyShort", { defaultValue: "暂无直属下级" })}>
|
||||||
|
{canManageNode && canCreateChild ? (
|
||||||
|
<Button type="button" size="sm" onClick={onAddChild}>
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
{t("createChild", { defaultValue: "添加下级" })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</AdminNoResourceState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
<div className="space-y-3">
|
||||||
<div className="overflow-x-auto">
|
{canManageNode && canCreateChild ? (
|
||||||
<Table>
|
<div className="flex justify-end">
|
||||||
<TableHeader>
|
<Button type="button" size="sm" onClick={onAddChild}>
|
||||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead>
|
{t("createChild", { defaultValue: "添加下级" })}
|
||||||
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
|
</Button>
|
||||||
<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>
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DownlineTable
|
||||||
|
childAgents={childAgents}
|
||||||
|
childCountById={childCountById}
|
||||||
|
canManageNode={canManageNode}
|
||||||
|
onEditChild={onEditChild}
|
||||||
|
onDeleteChild={onDeleteChild}
|
||||||
|
onSelectChild={onSelectChild}
|
||||||
|
canDeleteChild={canDeleteChild}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({
|
function DownlineTable({
|
||||||
label,
|
childAgents,
|
||||||
value,
|
childCountById,
|
||||||
subtitle,
|
canManageNode,
|
||||||
accent = false,
|
canDeleteChild,
|
||||||
highlight = false,
|
onEditChild,
|
||||||
money = true,
|
onDeleteChild,
|
||||||
|
onSelectChild,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
childAgents: AgentNodeRow[];
|
||||||
value: string;
|
childCountById: Map<number, number>;
|
||||||
subtitle?: string;
|
canManageNode: boolean;
|
||||||
accent?: boolean;
|
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||||
highlight?: boolean;
|
onEditChild: (node: AgentNodeRow) => void;
|
||||||
/** 金额类指标:自适应字号 + 换行 */
|
onDeleteChild: (node: AgentNodeRow) => void;
|
||||||
money?: boolean;
|
onSelectChild: (node: AgentNodeRow) => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="admin-table-inset overflow-hidden">
|
||||||
className={cn(
|
<div className="overflow-x-auto">
|
||||||
"min-w-0 overflow-visible rounded-xl border bg-card px-4 py-4 shadow-sm transition-colors",
|
<Table>
|
||||||
highlight && "border-primary/25 bg-primary/[0.04]",
|
<TableHeader>
|
||||||
accent && !highlight && "border-border/70",
|
<TableRow className="hover:bg-transparent">
|
||||||
!accent && !highlight && "border-border/70",
|
<TableHead>{t("agentName", { defaultValue: "名称" })}</TableHead>
|
||||||
)}
|
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
|
||||||
>
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
{t("profile.totalShareRate", { defaultValue: "占成" })}
|
||||||
{money ? (
|
</TableHead>
|
||||||
<AdminMoneyDisplay
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
as="p"
|
{t("profile.creditLimit", { defaultValue: "授信" })}
|
||||||
value={value}
|
</TableHead>
|
||||||
size="lg"
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
className={cn("mt-1.5", highlight ? "text-primary" : "text-foreground")}
|
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||||
>
|
</TableHead>
|
||||||
{value}
|
<TableHead className="text-center whitespace-nowrap">
|
||||||
</AdminMoneyDisplay>
|
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级" })}
|
||||||
) : (
|
</TableHead>
|
||||||
<p
|
<TableHead>{t("common:status.label", { defaultValue: "状态" })}</TableHead>
|
||||||
className={cn(
|
{canManageNode ? (
|
||||||
"mt-1.5 text-2xl font-semibold tabular-nums tracking-tight",
|
<TableHead className="sticky right-0 z-20 w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
highlight ? "text-primary" : "text-foreground",
|
{t("common:table.actions", { defaultValue: "操作" })}
|
||||||
)}
|
</TableHead>
|
||||||
>
|
) : null}
|
||||||
{value}
|
</TableRow>
|
||||||
</p>
|
</TableHeader>
|
||||||
)}
|
<TableBody>
|
||||||
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
||||||
import {
|
import {
|
||||||
|
ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
validateAdminLoginAccount,
|
validateAdminLoginAccount,
|
||||||
validateAdminPassword,
|
validateAdminPassword,
|
||||||
} from "@/lib/admin-input-validation";
|
} from "@/lib/admin-input-validation";
|
||||||
@@ -75,8 +76,9 @@ export function AgentLineProvisionWizard({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = defaultSiteCode.trim().toLowerCase();
|
const normalized = defaultSiteCode.trim().toLowerCase();
|
||||||
if (unboundSites.some((row) => row.code.toLowerCase() === normalized)) {
|
const matched = unboundSites.find((row) => row.code.toLowerCase() === normalized);
|
||||||
setForm((f) => ({ ...f, site_code: normalized }));
|
if (matched) {
|
||||||
|
setForm((f) => ({ ...f, site_code: matched.code }));
|
||||||
}
|
}
|
||||||
}, [defaultSiteCode, form.site_code, sitesLoading, unboundSites]);
|
}, [defaultSiteCode, form.site_code, sitesLoading, unboundSites]);
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ export function AgentLineProvisionWizard({
|
|||||||
}
|
}
|
||||||
const passwordIssue = validateAdminPassword(form.password);
|
const passwordIssue = validateAdminPassword(form.password);
|
||||||
if (passwordIssue === "too_short") {
|
if (passwordIssue === "too_short") {
|
||||||
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 6 位" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,10 +284,13 @@ export function AgentLineProvisionWizard({
|
|||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={ADMIN_PASSWORD_MIN_LENGTH}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("agents:lineProvision.passwordHint", { defaultValue: "至少 8 位" })}
|
{t("agents:lineProvision.passwordHint", {
|
||||||
|
defaultValue: "至少 {{min}} 位",
|
||||||
|
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
||||||
@@ -56,6 +57,36 @@ function pruneTreeForSearch(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectSearchExpandIds(
|
||||||
|
nodes: AgentNodeRow[],
|
||||||
|
normalized: string,
|
||||||
|
parentNameMap: Map<number, string>,
|
||||||
|
): Set<number> {
|
||||||
|
const ids = new Set<number>();
|
||||||
|
|
||||||
|
const walk = (list: AgentNodeRow[], ancestors: number[]): void => {
|
||||||
|
for (const node of list) {
|
||||||
|
const children = node.children ?? [];
|
||||||
|
const prunedChildren = pruneTreeForSearch(children, normalized, parentNameMap);
|
||||||
|
const selfMatch = nodeMatchesKeyword(node, normalized, parentNameMap);
|
||||||
|
|
||||||
|
if (selfMatch || prunedChildren.length > 0) {
|
||||||
|
for (const id of ancestors) {
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
if (prunedChildren.length > 0) {
|
||||||
|
ids.add(node.id);
|
||||||
|
}
|
||||||
|
walk(prunedChildren, [...ancestors, node.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(nodes, []);
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentLineSidebarProps = {
|
export type AgentLineSidebarProps = {
|
||||||
siteLabel: string | null;
|
siteLabel: string | null;
|
||||||
/** API 返回的嵌套树(含 children) */
|
/** API 返回的嵌套树(含 children) */
|
||||||
@@ -67,6 +98,8 @@ export type AgentLineSidebarProps = {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onKeywordChange: (value: string) => void;
|
onKeywordChange: (value: string) => void;
|
||||||
onSelect: (node: AgentNodeRow) => void;
|
onSelect: (node: AgentNodeRow) => void;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
onRetry?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TreeRowProps = {
|
type TreeRowProps = {
|
||||||
@@ -98,7 +131,7 @@ function TreeRow({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-start gap-0.5 rounded-md py-1 pr-2 transition-colors",
|
"flex w-full items-start gap-0.5 rounded-md py-1 pr-2 transition-colors",
|
||||||
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-background/80",
|
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-muted/15",
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${6 + indent}px` }}
|
style={{ paddingLeft: `${6 + indent}px` }}
|
||||||
>
|
>
|
||||||
@@ -163,6 +196,8 @@ export function AgentLineSidebar({
|
|||||||
loading = false,
|
loading = false,
|
||||||
onKeywordChange,
|
onKeywordChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
errorMessage = null,
|
||||||
|
onRetry,
|
||||||
}: AgentLineSidebarProps): React.ReactElement {
|
}: AgentLineSidebarProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||||
@@ -220,6 +255,19 @@ export function AgentLineSidebar({
|
|||||||
});
|
});
|
||||||
}, [selectedId, tree]);
|
}, [selectedId, tree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (normalizedKeyword === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandIds = collectSearchExpandIds(tree, normalizedKeyword, parentNameMap);
|
||||||
|
if (expandIds.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedIds((prev) => new Set([...prev, ...expandIds]));
|
||||||
|
}, [normalizedKeyword, parentNameMap, tree]);
|
||||||
|
|
||||||
const toggleExpand = useCallback((id: number) => {
|
const toggleExpand = useCallback((id: number) => {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -236,19 +284,8 @@ export function AgentLineSidebar({
|
|||||||
const hasAnyAgent = displayForest.length > 0;
|
const hasAnyAgent = displayForest.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex min-h-0 h-full w-full flex-col bg-muted/10 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
|
<aside className="flex min-h-0 h-full w-full flex-col lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
|
||||||
<div className="space-y-3 border-b border-border/60 bg-card px-4 py-4">
|
<div className="space-y-3 border-b border-border/60 px-4 py-4">
|
||||||
{siteLabel ? (
|
|
||||||
<p className="truncate text-xs font-medium text-foreground/80" title={siteLabel}>
|
|
||||||
{siteLabel}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("lineUi.agentCount", {
|
|
||||||
defaultValue: "本组 {{count}} 个代理",
|
|
||||||
count: agentCount,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -265,6 +302,15 @@ export function AgentLineSidebar({
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<AdminLoadingInline className="py-10" />
|
<AdminLoadingInline className="py-10" />
|
||||||
|
) : !hasAnyAgent && errorMessage ? (
|
||||||
|
<div className="space-y-3 px-2 py-8 text-center">
|
||||||
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={onRetry}>
|
||||||
|
{t("common:actions.retry", { defaultValue: "重试" })}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : !hasAnyAgent ? (
|
) : !hasAnyAgent ? (
|
||||||
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
|
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -111,14 +111,29 @@ export function AgentProfileFields({
|
|||||||
max: maxCreditLimit,
|
max: maxCreditLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showFieldHints = !isCard;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{(parentCaps || availableCredit !== null) && !loading ? (
|
{(parentCaps || availableCredit !== null) && !loading ? (
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
|
isCard ? (
|
||||||
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="flex flex-col gap-1.5 min-w-0">
|
{parentCaps
|
||||||
{parentCaps ? (
|
? t("profile.parentCaps", {
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
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">
|
<p className="text-sm font-medium leading-snug">
|
||||||
{t("profile.parentCaps", {
|
{t("profile.parentCaps", {
|
||||||
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
|
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
|
||||||
@@ -126,18 +141,18 @@ export function AgentProfileFields({
|
|||||||
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
{availableCredit !== null ? (
|
||||||
{availableCredit !== null ? (
|
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
|
||||||
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
|
{t("profile.availableCredit", {
|
||||||
{t("profile.availableCredit", {
|
defaultValue: "可下发额度 {{amount}}",
|
||||||
defaultValue: "可下发额度 {{amount}}",
|
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
|
||||||
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
|
})}
|
||||||
})}
|
</p>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -146,7 +161,7 @@ export function AgentProfileFields({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!profileScalarsEditable && !loading ? (
|
{!profileScalarsEditable && !loading && !isCard ? (
|
||||||
<p className="rounded-lg border border-amber-200/80 bg-amber-50 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
|
<p className="rounded-lg border border-amber-200/80 bg-amber-50 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
|
||||||
{t("profile.lineRootScalarsReadOnlyHint", {
|
{t("profile.lineRootScalarsReadOnlyHint", {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
@@ -166,7 +181,7 @@ export function AgentProfileFields({
|
|||||||
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
{showReadOnlyDisplay ? (
|
{showReadOnlyDisplay ? (
|
||||||
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" />
|
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" flat={isCard} />
|
||||||
) : (
|
) : (
|
||||||
<AdminNumericStepper
|
<AdminNumericStepper
|
||||||
id={`${idPrefix}-share-rate`}
|
id={`${idPrefix}-share-rate`}
|
||||||
@@ -179,19 +194,20 @@ export function AgentProfileFields({
|
|||||||
preserveOverMaxOnBlur
|
preserveOverMaxOnBlur
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{parentCaps ? (
|
{showFieldHints && parentCaps ? (
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("profile.relativeShareCapHint", {
|
{t("profile.relativeShareCapHint", {
|
||||||
defaultValue: "占上级比例最高 100%(上级总占成 {{parent}}%)",
|
defaultValue: "占上级比例最高 100%(上级总占成 {{parent}}%)",
|
||||||
parent: parentCaps.total_share_rate,
|
parent: parentCaps.total_share_rate,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : null}
|
||||||
|
{showFieldHints && !parentCaps ? (
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
|
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
{parentCaps && shareRate && actualShare !== null ? (
|
{showFieldHints && parentCaps && shareRate && actualShare !== null ? (
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("profile.actualShareRate", {
|
{t("profile.actualShareRate", {
|
||||||
defaultValue: "实际占成 {{rate}}%",
|
defaultValue: "实际占成 {{rate}}%",
|
||||||
@@ -208,7 +224,7 @@ export function AgentProfileFields({
|
|||||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||||
</Label>
|
</Label>
|
||||||
{showReadOnlyDisplay ? (
|
{showReadOnlyDisplay ? (
|
||||||
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} />
|
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} flat={isCard} />
|
||||||
) : (
|
) : (
|
||||||
<AdminNumericStepper
|
<AdminNumericStepper
|
||||||
id={`${idPrefix}-credit-limit`}
|
id={`${idPrefix}-credit-limit`}
|
||||||
@@ -221,7 +237,7 @@ export function AgentProfileFields({
|
|||||||
preserveOverMaxOnBlur
|
preserveOverMaxOnBlur
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
|
{showFieldHints && parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("profile.creditParentCapHint", {
|
{t("profile.creditParentCapHint", {
|
||||||
defaultValue: "最高 {{max}}(上级可再下发 {{available}})",
|
defaultValue: "最高 {{max}}(上级可再下发 {{available}})",
|
||||||
@@ -246,7 +262,7 @@ export function AgentProfileFields({
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{minCreditLimit > 0 ? (
|
{showFieldHints && minCreditLimit > 0 ? (
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("profile.creditAllocatedFloorHint", {
|
{t("profile.creditAllocatedFloorHint", {
|
||||||
defaultValue: "不可低于已下发 {{amount}}",
|
defaultValue: "不可低于已下发 {{amount}}",
|
||||||
@@ -263,7 +279,7 @@ export function AgentProfileFields({
|
|||||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
{showReadOnlyDisplay ? (
|
{showReadOnlyDisplay ? (
|
||||||
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" />
|
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" flat={isCard} />
|
||||||
) : (
|
) : (
|
||||||
<AdminNumericStepper
|
<AdminNumericStepper
|
||||||
id={`${idPrefix}-rebate-limit`}
|
id={`${idPrefix}-rebate-limit`}
|
||||||
@@ -297,7 +313,7 @@ export function AgentProfileFields({
|
|||||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
{showReadOnlyDisplay ? (
|
{showReadOnlyDisplay ? (
|
||||||
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" />
|
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" flat={isCard} />
|
||||||
) : (
|
) : (
|
||||||
<AdminNumericStepper
|
<AdminNumericStepper
|
||||||
id={`${idPrefix}-default-rebate`}
|
id={`${idPrefix}-default-rebate`}
|
||||||
@@ -329,11 +345,15 @@ export function AgentProfileFields({
|
|||||||
id={`${idPrefix}-risk-tags`}
|
id={`${idPrefix}-risk-tags`}
|
||||||
value={riskTags.trim() === "" ? "—" : riskTags}
|
value={riskTags.trim() === "" ? "—" : riskTags}
|
||||||
className="justify-start font-normal"
|
className="justify-start font-normal"
|
||||||
|
flat={isCard}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id={`${idPrefix}-risk-tags`}
|
id={`${idPrefix}-risk-tags`}
|
||||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
className={cn(
|
||||||
|
"h-10 transition-colors",
|
||||||
|
isCard ? "bg-card focus:bg-card" : "bg-background/50 focus:bg-background",
|
||||||
|
)}
|
||||||
value={riskTags}
|
value={riskTags}
|
||||||
onChange={(e) => onRiskTagsChange(e.target.value)}
|
onChange={(e) => onRiskTagsChange(e.target.value)}
|
||||||
placeholder={t("profile.riskTagsPlaceholder", {
|
placeholder={t("profile.riskTagsPlaceholder", {
|
||||||
@@ -345,21 +365,29 @@ export function AgentProfileFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="rounded-xl border border-border/80 bg-muted/20 overflow-hidden shadow-sm">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border border-border/80",
|
||||||
|
isCard ? undefined : "bg-muted/20 shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{showReadOnlyDisplay ? (
|
{showReadOnlyDisplay ? (
|
||||||
<>
|
<>
|
||||||
<ReadOnlySwitchRow
|
<ReadOnlySwitchRow
|
||||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||||
checked={extraRebate}
|
checked={extraRebate}
|
||||||
|
flat={isCard}
|
||||||
/>
|
/>
|
||||||
<ReadOnlySwitchRow
|
<ReadOnlySwitchRow
|
||||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||||
checked={canCreatePlayer}
|
checked={canCreatePlayer}
|
||||||
|
flat={isCard}
|
||||||
/>
|
/>
|
||||||
<ReadOnlySwitchRow
|
<ReadOnlySwitchRow
|
||||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||||
checked={canCreateChild}
|
checked={canCreateChild}
|
||||||
isLast
|
isLast
|
||||||
|
flat={isCard}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -368,11 +396,13 @@ export function AgentProfileFields({
|
|||||||
checked={extraRebate}
|
checked={extraRebate}
|
||||||
onCheckedChange={onExtraRebateChange}
|
onCheckedChange={onExtraRebateChange}
|
||||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||||
|
flat={isCard}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
checked={canCreatePlayer}
|
checked={canCreatePlayer}
|
||||||
onCheckedChange={onCanCreatePlayerChange}
|
onCheckedChange={onCanCreatePlayerChange}
|
||||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||||
|
flat={isCard}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
checked={canCreateChild}
|
checked={canCreateChild}
|
||||||
@@ -380,6 +410,7 @@ export function AgentProfileFields({
|
|||||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||||
isLast
|
isLast
|
||||||
|
flat={isCard}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -402,17 +433,20 @@ function ReadOnlyScalar({
|
|||||||
value,
|
value,
|
||||||
suffix,
|
suffix,
|
||||||
className,
|
className,
|
||||||
|
flat = false,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
value: string;
|
value: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
flat?: boolean;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-10 min-w-0 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 py-2 text-center shadow-xs",
|
"flex min-h-10 min-w-0 items-center justify-center rounded-md border border-border/80 px-3 py-2 text-center",
|
||||||
|
flat ? "bg-card" : "bg-muted/35 shadow-xs",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -428,17 +462,20 @@ function ReadOnlySwitchRow({
|
|||||||
label,
|
label,
|
||||||
checked,
|
checked,
|
||||||
isLast = false,
|
isLast = false,
|
||||||
|
flat = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
|
flat?: boolean;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-4 bg-background/60 px-4 py-3.5",
|
"flex items-center justify-between gap-4 px-4 py-3.5",
|
||||||
|
!flat && "bg-background/60",
|
||||||
!isLast && "border-b border-border/60",
|
!isLast && "border-b border-border/60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -458,18 +495,23 @@ function SwitchRow({
|
|||||||
label,
|
label,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isLast = false,
|
isLast = false,
|
||||||
|
flat = false,
|
||||||
}: {
|
}: {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onCheckedChange: (value: boolean) => void;
|
onCheckedChange: (value: boolean) => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
|
flat?: boolean;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
"flex items-center justify-between gap-4 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30",
|
className={cn(
|
||||||
!isLast && "border-b border-border/50"
|
"flex items-center justify-between gap-4 px-4 py-3.5 transition-colors",
|
||||||
)}>
|
flat ? "hover:bg-muted/15" : "bg-background/50 hover:bg-muted/30",
|
||||||
|
!isLast && "border-b border-border/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Label
|
<Label
|
||||||
className={cn("font-medium", disabled ? "cursor-default text-muted-foreground" : "cursor-pointer")}
|
className={cn("font-medium", disabled ? "cursor-default text-muted-foreground" : "cursor-pointer")}
|
||||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
putAgentNode,
|
putAgentNode,
|
||||||
putAgentNodeProfile,
|
putAgentNodeProfile,
|
||||||
} from "@/api/admin-agents";
|
} from "@/api/admin-agents";
|
||||||
|
import { getAdminPlayers } from "@/api/admin-player";
|
||||||
import {
|
import {
|
||||||
AgentLineDetailPanel,
|
AgentLineDetailPanel,
|
||||||
type AgentDetailTab,
|
type AgentDetailTab,
|
||||||
@@ -94,6 +95,8 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
|||||||
export function AgentsConsole(): React.ReactElement {
|
export function AgentsConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
const tRef = useTranslationRef(["agents", "common"]);
|
const tRef = useTranslationRef(["agents", "common"]);
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
@@ -123,12 +126,12 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
||||||
const [detailTab, setDetailTab] = useState<AgentDetailTab>("overview");
|
const [detailTab, setDetailTab] = useState<AgentDetailTab>("profile");
|
||||||
const [profileSaving, setProfileSaving] = useState(false);
|
const [profileSaving, setProfileSaving] = useState(false);
|
||||||
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
||||||
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
||||||
const [selectedProfileErr, setSelectedProfileErr] = useState<string | null>(null);
|
const [selectedProfileErr, setSelectedProfileErr] = useState<string | null>(null);
|
||||||
const [playerCreateRequestKey, setPlayerCreateRequestKey] = useState(0);
|
|
||||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||||
const [targetParentId, setTargetParentId] = useState<number | null>(null);
|
const [targetParentId, setTargetParentId] = useState<number | null>(null);
|
||||||
@@ -307,6 +310,10 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [adminSiteId, boundSite, siteOptions]);
|
}, [adminSiteId, boundSite, siteOptions]);
|
||||||
|
const rootNode = useMemo(
|
||||||
|
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
|
||||||
|
[flatNodes],
|
||||||
|
);
|
||||||
const activeSiteCode = useMemo(() => {
|
const activeSiteCode = useMemo(() => {
|
||||||
const fromAgent = boundAgent?.site_code?.trim();
|
const fromAgent = boundAgent?.site_code?.trim();
|
||||||
if (fromAgent) {
|
if (fromAgent) {
|
||||||
@@ -319,12 +326,8 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
if (boundSite != null && boundSite.id === adminSiteId) {
|
if (boundSite != null && boundSite.id === adminSiteId) {
|
||||||
return boundSite.code.trim();
|
return boundSite.code.trim();
|
||||||
}
|
}
|
||||||
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
|
return rootNode?.site_code?.trim() ?? "";
|
||||||
}, [adminSiteId, boundAgent?.site_code, boundSite, flatNodes, siteOptions]);
|
}, [adminSiteId, boundAgent?.site_code, boundSite, rootNode?.site_code, siteOptions]);
|
||||||
const rootNode = useMemo(
|
|
||||||
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
|
|
||||||
[flatNodes],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedNode = useMemo(
|
const selectedNode = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -342,8 +345,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
const isOwnAgentNode =
|
const isOwnAgentNode =
|
||||||
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
|
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
|
||||||
|
|
||||||
const canViewProfileTab =
|
const canViewProfileTab = canManageProfile && selectedNode !== null;
|
||||||
canManageProfile && selectedNode !== null && !isOwnAgentNode;
|
|
||||||
|
|
||||||
const canEditSelectedProfile =
|
const canEditSelectedProfile =
|
||||||
canViewProfileTab && isRootProfileEditableByActor(selectedNode, isSuperAdmin);
|
canViewProfileTab && isRootProfileEditableByActor(selectedNode, isSuperAdmin);
|
||||||
@@ -473,6 +475,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSelectedProfile(null);
|
setSelectedProfile(null);
|
||||||
|
resetProfileForm();
|
||||||
setSelectedProfileErr(
|
setSelectedProfileErr(
|
||||||
t("profile.loadFailed", { defaultValue: "代理档案加载失败,请稍后重试。" }),
|
t("profile.loadFailed", { defaultValue: "代理档案加载失败,请稍后重试。" }),
|
||||||
);
|
);
|
||||||
@@ -500,51 +503,46 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
.catch(() => setRootProfile(null));
|
.catch(() => setRootProfile(null));
|
||||||
}, [rootNode?.id]);
|
}, [rootNode?.id]);
|
||||||
|
|
||||||
/** 代理看自己,或站点方看一级代理:占成授信 Tab 只读 */
|
|
||||||
const profileReadOnly = isOwnAgentNode || lineRootProfileLocked;
|
|
||||||
|
|
||||||
const isSiteAdmin = isSiteAdminOperator(profile);
|
const isSiteAdmin = isSiteAdminOperator(profile);
|
||||||
|
|
||||||
const canShowDownlineTab = useMemo(
|
const canShowDownlineTab = selectedNode !== null;
|
||||||
() =>
|
|
||||||
selectedNode !== null &&
|
|
||||||
(selectedProfileLoading ||
|
|
||||||
isSiteAdmin ||
|
|
||||||
isSuperAdmin ||
|
|
||||||
selectedProfile?.can_create_child_agent === true),
|
|
||||||
[isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
|
|
||||||
);
|
|
||||||
|
|
||||||
const canShowPlayersTab = useMemo(
|
const canShowPlayersTab = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedNode !== null &&
|
selectedNode !== null &&
|
||||||
(selectedProfileLoading ||
|
(hasUsersManagePermission || isSiteAdmin || isSuperAdmin),
|
||||||
(hasUsersManagePermission &&
|
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode],
|
||||||
(isSiteAdmin ||
|
|
||||||
isSuperAdmin ||
|
|
||||||
selectedProfile?.can_create_player === true))),
|
|
||||||
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const playersTabHint = useMemo(() => {
|
const canCreatePlayerOnSelected = useMemo(() => {
|
||||||
if (selectedNode === null || selectedProfileLoading) {
|
if (selectedNode === null || selectedProfileLoading || !hasUsersManagePermission) {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (boundAgent !== null && selectedNode.id !== boundAgent.id) {
|
||||||
if (selectedProfile?.can_create_player !== true) {
|
return false;
|
||||||
return t("lineUi.playersUnavailableHint", {
|
|
||||||
defaultValue: "当前代理未开启“允许创建玩家”,如需新增请先调整该代理配置。",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (isSuperAdmin || isSiteAdmin) {
|
||||||
if (!hasUsersManagePermission) {
|
return selectedProfile?.can_create_player !== false;
|
||||||
return t("lineUi.playersNoPermissionHint", {
|
|
||||||
defaultValue: "当前账号没有该节点的玩家管理权限。",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (boundAgent !== null) {
|
||||||
|
return selectedProfile?.can_create_player === true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [
|
||||||
|
boundAgent,
|
||||||
|
hasUsersManagePermission,
|
||||||
|
isSiteAdmin,
|
||||||
|
isSuperAdmin,
|
||||||
|
selectedNode,
|
||||||
|
selectedProfile?.can_create_player,
|
||||||
|
selectedProfileLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
return null;
|
const [directPlayerCountById, setDirectPlayerCountById] = useState<Map<number, number>>(
|
||||||
}, [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading, t]);
|
() => new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevSelectedNodeIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const canCreateChildOnSelected = useMemo(
|
const canCreateChildOnSelected = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -561,11 +559,11 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (detailTab === "profile" && !canViewProfileTab) {
|
if (detailTab === "profile" && !canViewProfileTab) {
|
||||||
setDetailTab("overview");
|
setDetailTab(canShowPlayersTab ? "players" : "downline");
|
||||||
} else if (detailTab === "downline" && !canShowDownlineTab) {
|
} else if (detailTab === "downline" && !canShowDownlineTab) {
|
||||||
setDetailTab(canShowPlayersTab ? "players" : "overview");
|
setDetailTab(canShowPlayersTab ? "players" : "profile");
|
||||||
} else if (detailTab === "players" && !canShowPlayersTab) {
|
} else if (detailTab === "players" && !canShowPlayersTab) {
|
||||||
setDetailTab(canShowDownlineTab ? "downline" : "overview");
|
setDetailTab(canViewProfileTab ? "profile" : "downline");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
canShowDownlineTab,
|
canShowDownlineTab,
|
||||||
@@ -577,14 +575,89 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetailTab("overview");
|
if (selectedNodeId === null) {
|
||||||
}, [selectedNodeId]);
|
prevSelectedNodeIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeChanged = prevSelectedNodeIdRef.current !== selectedNodeId;
|
||||||
|
prevSelectedNodeIdRef.current = selectedNodeId;
|
||||||
|
|
||||||
|
if (!nodeChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetailTab((current) => {
|
||||||
|
if (current === "profile" && canViewProfileTab) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if (current === "downline" && canShowDownlineTab) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if (current === "players" && canShowPlayersTab) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if (canViewProfileTab) {
|
||||||
|
return "profile";
|
||||||
|
}
|
||||||
|
if (canShowPlayersTab) {
|
||||||
|
return "players";
|
||||||
|
}
|
||||||
|
return "downline";
|
||||||
|
});
|
||||||
|
}, [canShowDownlineTab, canShowPlayersTab, canViewProfileTab, selectedNodeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOwnAgentNode && detailTab === "profile") {
|
if (detailTab !== "downline" || activeSiteCode.trim() === "") {
|
||||||
setDetailTab("overview");
|
return;
|
||||||
}
|
}
|
||||||
}, [detailTab, isOwnAgentNode]);
|
|
||||||
|
let cancelled = false;
|
||||||
|
for (const child of selectedChildAgents) {
|
||||||
|
void getAdminPlayers({
|
||||||
|
site_code: activeSiteCode.trim(),
|
||||||
|
agent_node_id: child.id,
|
||||||
|
page: 1,
|
||||||
|
per_page: 1,
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDirectPlayerCountById((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(child.id, data.meta.total);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 删除按钮降级为仅后端校验 */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeSiteCode, detailTab, selectedChildAgents]);
|
||||||
|
|
||||||
|
const syncSelectedNodeToUrl = useCallback(
|
||||||
|
(nodeId: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("agent_node_id", String(nodeId));
|
||||||
|
params.delete("node");
|
||||||
|
const qs = params.toString();
|
||||||
|
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||||
|
},
|
||||||
|
[pathname, router, searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAgentNode = useCallback(
|
||||||
|
(node: AgentNodeRow) => {
|
||||||
|
setSelectedNodeId(node.id);
|
||||||
|
syncSelectedNodeToUrl(node.id);
|
||||||
|
},
|
||||||
|
[syncSelectedNodeToUrl],
|
||||||
|
);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (!canViewAgents) {
|
if (!canViewAgents) {
|
||||||
@@ -674,8 +747,9 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
const canDeleteNode = (node: AgentNodeRow): boolean => {
|
const canDeleteNode = (node: AgentNodeRow): boolean => {
|
||||||
const blockedByChildren = (node.children?.length ?? 0) > 0;
|
const blockedByChildren = (node.children?.length ?? 0) > 0;
|
||||||
const blockedBySelf = profile?.agent?.id === node.id;
|
const blockedBySelf = profile?.agent?.id === node.id;
|
||||||
|
const blockedByPlayers = (directPlayerCountById.get(node.id) ?? 0) > 0;
|
||||||
|
|
||||||
return canManageNode && !blockedByChildren && !blockedBySelf;
|
return canManageNode && !blockedByChildren && !blockedBySelf && !blockedByPlayers;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteNode = (node: AgentNodeRow): void => {
|
const handleDeleteNode = (node: AgentNodeRow): void => {
|
||||||
@@ -732,7 +806,8 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disabled: profileReadOnly || !canEditSelectedProfile,
|
disabled:
|
||||||
|
isOwnAgentNode || lineRootProfileLocked || !canEditSelectedProfile,
|
||||||
loading: selectedProfileLoading,
|
loading: selectedProfileLoading,
|
||||||
parentCaps: profileParentCaps,
|
parentCaps: profileParentCaps,
|
||||||
availableCredit: profileAvailableCredit,
|
availableCredit: profileAvailableCredit,
|
||||||
@@ -763,7 +838,8 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
canEditSelectedProfile,
|
canEditSelectedProfile,
|
||||||
canViewProfileTab,
|
canViewProfileTab,
|
||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
profileReadOnly,
|
isOwnAgentNode,
|
||||||
|
lineRootProfileLocked,
|
||||||
selectedNode,
|
selectedNode,
|
||||||
profileAvailableCredit,
|
profileAvailableCredit,
|
||||||
profileCanCreateChild,
|
profileCanCreateChild,
|
||||||
@@ -780,7 +856,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
selectedProfileLoading,
|
selectedProfileLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const showAgentSidebar = loading || visibleAgentRows.length > 0;
|
const showAgentSidebar = loading || visibleAgentRows.length > 0 || err !== null;
|
||||||
|
|
||||||
const hasSiteContext =
|
const hasSiteContext =
|
||||||
siteOptions.length > 0 ||
|
siteOptions.length > 0 ||
|
||||||
@@ -832,13 +908,13 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
const passwordIssue = validateAdminPassword(nodePassword);
|
const passwordIssue = validateAdminPassword(nodePassword);
|
||||||
if (passwordIssue === "too_short") {
|
if (passwordIssue === "too_short") {
|
||||||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
toast.error(t("passwordMinLength", { defaultValue: "密码至少 6 位" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (nodePassword.trim()) {
|
} else if (nodePassword.trim()) {
|
||||||
const passwordIssue = validateAdminPassword(nodePassword);
|
const passwordIssue = validateAdminPassword(nodePassword);
|
||||||
if (passwordIssue === "too_short") {
|
if (passwordIssue === "too_short") {
|
||||||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
toast.error(t("passwordMinLength", { defaultValue: "密码至少 6 位" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) {
|
} else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) {
|
||||||
@@ -876,8 +952,9 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
|
|
||||||
setNodeSaving(true);
|
setNodeSaving(true);
|
||||||
try {
|
try {
|
||||||
|
let createdNodeId: number | null = null;
|
||||||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
if (nodeDialogMode === "create" && targetParentId !== null) {
|
||||||
await postAgentNode({
|
const created = await postAgentNode({
|
||||||
parent_id: targetParentId,
|
parent_id: targetParentId,
|
||||||
name: nodeName.trim(),
|
name: nodeName.trim(),
|
||||||
username: nodeUsername.trim(),
|
username: nodeUsername.trim(),
|
||||||
@@ -885,6 +962,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
status: nodeStatus,
|
status: nodeStatus,
|
||||||
...(canManageProfile ? profilePayload(null) : {}),
|
...(canManageProfile ? profilePayload(null) : {}),
|
||||||
});
|
});
|
||||||
|
createdNodeId = created.id;
|
||||||
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
||||||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||||||
await putAgentNode(editingNodeId, {
|
await putAgentNode(editingNodeId, {
|
||||||
@@ -903,16 +981,12 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
|
|
||||||
setNodeDialogOpen(false);
|
setNodeDialogOpen(false);
|
||||||
await loadTree(adminSiteId);
|
await loadTree(adminSiteId);
|
||||||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
if (nodeDialogMode === "create" && createdNodeId !== null) {
|
||||||
const refreshed = flattenTree((await getAgentTree(adminSiteId ?? undefined)).tree);
|
setSelectedNodeId(createdNodeId);
|
||||||
const created = refreshed.find(
|
syncSelectedNodeToUrl(createdNodeId);
|
||||||
(node) => node.parent_id === targetParentId && node.name === nodeName.trim(),
|
|
||||||
);
|
|
||||||
if (created) {
|
|
||||||
setSelectedNodeId(created.id);
|
|
||||||
}
|
|
||||||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||||||
setSelectedNodeId(editingNodeId);
|
setSelectedNodeId(editingNodeId);
|
||||||
|
syncSelectedNodeToUrl(editingNodeId);
|
||||||
if (canManageProfile) {
|
if (canManageProfile) {
|
||||||
void getAgentNodeProfile(editingNodeId)
|
void getAgentNodeProfile(editingNodeId)
|
||||||
.then((p) => {
|
.then((p) => {
|
||||||
@@ -996,8 +1070,13 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
<AgentLineProvisionWizard
|
<AgentLineProvisionWizard
|
||||||
embedded
|
embedded
|
||||||
defaultSiteCode={activeSiteCode}
|
defaultSiteCode={activeSiteCode}
|
||||||
onSuccess={async () => {
|
onSuccess={async (result) => {
|
||||||
await loadTree(adminSiteId);
|
await loadTree(adminSiteId);
|
||||||
|
const nodeId = result.line_root?.agent_node_id ?? result.agent_node?.id ?? null;
|
||||||
|
if (nodeId !== null) {
|
||||||
|
setSelectedNodeId(nodeId);
|
||||||
|
syncSelectedNodeToUrl(nodeId);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1024,24 +1103,20 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
onKeywordChange={(value) => {
|
onKeywordChange={(value) => {
|
||||||
setKeyword(value);
|
setKeyword(value);
|
||||||
}}
|
}}
|
||||||
onSelect={(node) => {
|
onSelect={selectAgentNode}
|
||||||
setSelectedNodeId(node.id);
|
errorMessage={!loading && visibleAgentRows.length === 0 ? err : null}
|
||||||
}}
|
onRetry={() => void loadTree(adminSiteId)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedProfileErr ? (
|
|
||||||
<p className="border-b border-border/70 px-4 py-2 text-sm text-destructive">{selectedProfileErr}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AgentLineDetailPanel
|
<AgentLineDetailPanel
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
profile={selectedProfile}
|
profile={selectedProfile}
|
||||||
profileLoading={selectedProfileLoading}
|
profileLoading={selectedProfileLoading}
|
||||||
|
profileError={selectedProfileErr}
|
||||||
childAgents={selectedChildAgents}
|
childAgents={selectedChildAgents}
|
||||||
childCountById={childCountById}
|
childCountById={childCountById}
|
||||||
siteCode={activeSiteCode}
|
siteCode={activeSiteCode}
|
||||||
siteLabel={selectedSiteLabel}
|
|
||||||
parentName={
|
parentName={
|
||||||
selectedNode?.parent_id !== null && selectedNode?.parent_id !== undefined
|
selectedNode?.parent_id !== null && selectedNode?.parent_id !== undefined
|
||||||
? (parentNameMap.get(selectedNode.parent_id) ?? null)
|
? (parentNameMap.get(selectedNode.parent_id) ?? null)
|
||||||
@@ -1050,33 +1125,22 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
detailTab={detailTab}
|
detailTab={detailTab}
|
||||||
onDetailTabChange={setDetailTab}
|
onDetailTabChange={setDetailTab}
|
||||||
canViewProfileTab={canViewProfileTab}
|
canViewProfileTab={canViewProfileTab}
|
||||||
canEditProfileTab={canEditSelectedProfile}
|
canEditProfileTab={canEditSelectedProfile && !isOwnAgentNode}
|
||||||
profileReadOnly={profileReadOnly}
|
canSaveProfileTab={canManageProfile && canEditSelectedProfile && !isOwnAgentNode}
|
||||||
canViewDownlineTab={canShowDownlineTab}
|
canViewDownlineTab={canShowDownlineTab}
|
||||||
canViewPlayersTab={canShowPlayersTab}
|
canViewPlayersTab={canShowPlayersTab}
|
||||||
playersTabHint={playersTabHint}
|
|
||||||
canManageNode={canManageNode}
|
canManageNode={canManageNode}
|
||||||
canCreateChild={canCreateChildOnSelected}
|
canCreateChild={canCreateChildOnSelected}
|
||||||
canCreateChildAgent={canCreateChildAgent}
|
canCreatePlayerAction={canCreatePlayerOnSelected}
|
||||||
canCreatePlayerAction={
|
|
||||||
isSuperAdmin ||
|
|
||||||
isSiteAdmin ||
|
|
||||||
(selectedProfile?.can_create_player === true &&
|
|
||||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]))
|
|
||||||
}
|
|
||||||
canDeleteChild={canDeleteNode}
|
canDeleteChild={canDeleteNode}
|
||||||
onEditChild={(node) => openEditForNode(node)}
|
onEditChild={(node) => openEditForNode(node)}
|
||||||
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
||||||
onAddPlayer={() => setPlayerCreateRequestKey((value) => value + 1)}
|
|
||||||
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
|
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
|
||||||
onDeleteChild={(node) => handleDeleteNode(node)}
|
onDeleteChild={(node) => handleDeleteNode(node)}
|
||||||
onSelectChild={(child) => {
|
onSelectChild={selectAgentNode}
|
||||||
setSelectedNodeId(child.id);
|
|
||||||
}}
|
|
||||||
profileFields={inlineProfileFields}
|
profileFields={inlineProfileFields}
|
||||||
profileSaving={profileSaving}
|
profileSaving={profileSaving}
|
||||||
onSaveProfile={() => void saveInlineProfile()}
|
onSaveProfile={() => void saveInlineProfile()}
|
||||||
playerCreateRequestKey={playerCreateRequestKey}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -127,38 +127,8 @@ export function AgentsDirectoryConsole(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
}, [items, keyword, parentNameMap, status]);
|
}, [items, keyword, parentNameMap, status]);
|
||||||
|
|
||||||
const totalOperatingAgents = useMemo(
|
|
||||||
() => items.filter((item) => !item.is_root).length,
|
|
||||||
[items],
|
|
||||||
);
|
|
||||||
const enabledOperatingAgents = useMemo(
|
|
||||||
() => items.filter((item) => !item.is_root && item.status === 1).length,
|
|
||||||
[items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalOperatingAgents}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("summary.enabledAgents", { defaultValue: "启用中的经营代理数" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{enabledOperatingAgents}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("summary.visibleList", { defaultValue: "当前平铺列表条数" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{filteredItems.length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AdminPageCard
|
<AdminPageCard
|
||||||
title={t("listTitle", { defaultValue: "代理列表" })}
|
title={t("listTitle", { defaultValue: "代理列表" })}
|
||||||
actions={
|
actions={
|
||||||
@@ -209,10 +179,10 @@ export function AgentsDirectoryConsole(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="admin-table-inset overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead>
|
<TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead>
|
||||||
<TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead>
|
<TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead>
|
||||||
<TableHead className="w-[90px]">{t("depth", { defaultValue: "层级" })}</TableHead>
|
<TableHead className="w-[90px]">{t("depth", { defaultValue: "层级" })}</TableHead>
|
||||||
@@ -231,7 +201,7 @@ export function AgentsDirectoryConsole(): React.ReactElement {
|
|||||||
<TableHead className="w-[130px] text-right">
|
<TableHead className="w-[130px] text-right">
|
||||||
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
<TableHead className="sticky right-0 z-20 w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
{t("common:table.actions", { defaultValue: "操作" })}
|
{t("common:table.actions", { defaultValue: "操作" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -289,12 +259,12 @@ export function AgentsDirectoryConsole(): React.ReactElement {
|
|||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
|
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
<TableCell className="sticky right-0 z-10 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
<AdminRowActionsMenu
|
<AdminRowActionsMenu
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
label: t("common:actions.viewDetails", { defaultValue: "查看详情" }),
|
label: t("lineUi.openInTree", { defaultValue: "在树中打开" }),
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
href: `/admin/agents?agent_node_id=${item.id}`,
|
href: `/admin/agents?agent_node_id=${item.id}`,
|
||||||
},
|
},
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
|
import { Eye, Pencil, Plus, ReceiptText, Search, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getAgentNodeProfile } from "@/api/admin-agents";
|
import { getAgentNodeProfile } from "@/api/admin-agents";
|
||||||
import {
|
|
||||||
getSettlementBills,
|
|
||||||
postSettlementBillBadDebtWriteOff,
|
|
||||||
postSettlementBillConfirm,
|
|
||||||
postSettlementBillPayment,
|
|
||||||
type SettlementBillRow,
|
|
||||||
} from "@/api/admin-agent-settlement";
|
|
||||||
import {
|
import {
|
||||||
deleteAdminPlayer,
|
deleteAdminPlayer,
|
||||||
getAdminPlayer,
|
getAdminPlayer,
|
||||||
@@ -57,18 +52,21 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
import { playerBalanceCells } from "@/lib/admin-player-display";
|
||||||
import { formatAdminMinorDecimal, formatAdminMinorUnits, parseAdminMajorToMinor } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
|
import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
|
||||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||||
import { AGENT_PERCENT_HARD_MAX } from "@/lib/agent-profile-caps";
|
import { AGENT_PERCENT_HARD_MAX } from "@/lib/agent-profile-caps";
|
||||||
import {
|
import {
|
||||||
|
NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
|
||||||
validateNativePlayerPassword,
|
validateNativePlayerPassword,
|
||||||
validateNativePlayerUsername,
|
validateNativePlayerUsername,
|
||||||
} from "@/lib/admin-input-validation";
|
} from "@/lib/admin-input-validation";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_SETTLEMENT_AGENT_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||||
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
|
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
|
||||||
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
|
import { resolvePlayerSettlementHref } from "@/modules/settlement/settlement-player-deeplink";
|
||||||
|
import { settlementCenterListHref } from "@/modules/settlement/settlement-center-nav";
|
||||||
|
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||||
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -148,8 +146,6 @@ type AgentsPlayersPanelProps = {
|
|||||||
allowCreatePlayer?: boolean;
|
allowCreatePlayer?: boolean;
|
||||||
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
/** 外部触发创建直属玩家的计数器 */
|
|
||||||
createRequestKey?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AgentsPlayersPanel({
|
export function AgentsPlayersPanel({
|
||||||
@@ -157,10 +153,11 @@ export function AgentsPlayersPanel({
|
|||||||
agentNodeId,
|
agentNodeId,
|
||||||
allowCreatePlayer,
|
allowCreatePlayer,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
createRequestKey = 0,
|
|
||||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "players", "common"]);
|
const { t } = useTranslation(["agents", "players", "common"]);
|
||||||
|
const router = useRouter();
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
|
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||||
const createPlayerLabel = embedded
|
const createPlayerLabel = embedded
|
||||||
? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" })
|
? t("playersPanel.createDirect", { defaultValue: "创建直属玩家" })
|
||||||
: t("playersPanel.create", { defaultValue: "创建玩家" });
|
: t("playersPanel.create", { defaultValue: "创建玩家" });
|
||||||
@@ -173,21 +170,6 @@ export function AgentsPlayersPanel({
|
|||||||
const isSiteAdmin = isSiteAdminOperator(profile);
|
const isSiteAdmin = isSiteAdminOperator(profile);
|
||||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||||
|
|
||||||
const profileAllowsCreate =
|
|
||||||
allowCreatePlayer === undefined
|
|
||||||
? boundAgent?.can_create_player !== false
|
|
||||||
: allowCreatePlayer === true;
|
|
||||||
|
|
||||||
const canCreatePlayer =
|
|
||||||
isSuperAdmin ||
|
|
||||||
(profileAllowsCreate &&
|
|
||||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
|
||||||
const canManagePlayerRows = canCreatePlayer;
|
|
||||||
const canOperateBills =
|
|
||||||
isSuperAdmin ||
|
|
||||||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
|
|
||||||
const canFinanceAdjustments = canOperateBills && boundAgent === null;
|
|
||||||
|
|
||||||
const effectiveAgentId = useMemo(() => {
|
const effectiveAgentId = useMemo(() => {
|
||||||
if (agentNodeId !== null) {
|
if (agentNodeId !== null) {
|
||||||
return agentNodeId;
|
return agentNodeId;
|
||||||
@@ -195,6 +177,26 @@ export function AgentsPlayersPanel({
|
|||||||
return boundAgent?.id ?? null;
|
return boundAgent?.id ?? null;
|
||||||
}, [agentNodeId, boundAgent?.id]);
|
}, [agentNodeId, boundAgent?.id]);
|
||||||
|
|
||||||
|
const profileAllowsCreate =
|
||||||
|
allowCreatePlayer === undefined
|
||||||
|
? boundAgent?.can_create_player !== false
|
||||||
|
: allowCreatePlayer === true;
|
||||||
|
|
||||||
|
const canAttachAgentNodeOnCreate =
|
||||||
|
effectiveAgentId !== null &&
|
||||||
|
(isSuperAdmin ||
|
||||||
|
isSiteAdmin ||
|
||||||
|
(boundAgent !== null && effectiveAgentId === boundAgent.id));
|
||||||
|
|
||||||
|
const canCreatePlayer =
|
||||||
|
canAttachAgentNodeOnCreate &&
|
||||||
|
profileAllowsCreate &&
|
||||||
|
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||||
|
const canManagePlayerRows = canCreatePlayer;
|
||||||
|
const [settlementNavBusy, setSettlementNavBusy] = useState(false);
|
||||||
|
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [appliedKeyword, setAppliedKeyword] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(20);
|
const [perPage, setPerPage] = useState(20);
|
||||||
const [items, setItems] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]);
|
const [items, setItems] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]);
|
||||||
@@ -231,18 +233,6 @@ export function AgentsPlayersPanel({
|
|||||||
const [editRebateRate, setEditRebateRate] = useState("");
|
const [editRebateRate, setEditRebateRate] = useState("");
|
||||||
const [editRiskTags, setEditRiskTags] = useState("");
|
const [editRiskTags, setEditRiskTags] = useState("");
|
||||||
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
||||||
const [billingDialogOpen, setBillingDialogOpen] = useState(false);
|
|
||||||
const [billingPlayer, setBillingPlayer] = useState<AdminPlayerRow | null>(null);
|
|
||||||
const [billingBills, setBillingBills] = useState<SettlementBillRow[]>([]);
|
|
||||||
const [billingLoading, setBillingLoading] = useState(false);
|
|
||||||
const [billingBusy, setBillingBusy] = useState(false);
|
|
||||||
const [selectedBillId, setSelectedBillId] = useState<number | null>(null);
|
|
||||||
const [payAmount, setPayAmount] = useState("");
|
|
||||||
const [payMethod, setPayMethod] = useState("");
|
|
||||||
const [payProof, setPayProof] = useState("");
|
|
||||||
const [badDebtReason, setBadDebtReason] = useState("");
|
|
||||||
const lastCreateRequestKeyRef = useRef(createRequestKey);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (siteCode.trim() === "") {
|
if (siteCode.trim() === "") {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
@@ -259,6 +249,7 @@ export function AgentsPlayersPanel({
|
|||||||
page,
|
page,
|
||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
site_code: siteCode.trim(),
|
site_code: siteCode.trim(),
|
||||||
|
...(appliedKeyword.trim() !== "" ? { keyword: appliedKeyword.trim() } : {}),
|
||||||
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
|
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
|
||||||
});
|
});
|
||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
@@ -272,7 +263,7 @@ export function AgentsPlayersPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [effectiveAgentId, page, perPage, siteCode]);
|
}, [appliedKeyword, effectiveAgentId, page, perPage, siteCode, t]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -355,9 +346,7 @@ export function AgentsPlayersPanel({
|
|||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password: password,
|
password: password,
|
||||||
nickname: nickname.trim() || null,
|
nickname: nickname.trim() || null,
|
||||||
...(effectiveAgentId != null && (isSuperAdmin || isSiteAdmin || boundAgent !== null)
|
...(canAttachAgentNodeOnCreate ? { agent_node_id: effectiveAgentId! } : {}),
|
||||||
? { agent_node_id: effectiveAgentId }
|
|
||||||
: {}),
|
|
||||||
credit_limit: parsedCreditLimit,
|
credit_limit: parsedCreditLimit,
|
||||||
...(parsedRebateRate !== null
|
...(parsedRebateRate !== null
|
||||||
? { rebate_rate: parsedRebateRate }
|
? { rebate_rate: parsedRebateRate }
|
||||||
@@ -407,16 +396,6 @@ export function AgentsPlayersPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (createRequestKey === 0 || createRequestKey === lastCreateRequestKeyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastCreateRequestKeyRef.current = createRequestKey;
|
|
||||||
if (canCreatePlayer) {
|
|
||||||
openCreateDialog();
|
|
||||||
}
|
|
||||||
}, [canCreatePlayer, createRequestKey]);
|
|
||||||
|
|
||||||
const applyEditForm = (row: AdminPlayerRow): void => {
|
const applyEditForm = (row: AdminPlayerRow): void => {
|
||||||
const form = fillEditFormFromPlayer(row);
|
const form = fillEditFormFromPlayer(row);
|
||||||
setEditUsername(form.username);
|
setEditUsername(form.username);
|
||||||
@@ -601,47 +580,19 @@ export function AgentsPlayersPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedBill = useMemo(
|
async function goToPlayerSettlement(row: AdminPlayerRow): Promise<void> {
|
||||||
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null,
|
setSettlementNavBusy(true);
|
||||||
[billingBills, selectedBillId],
|
|
||||||
);
|
|
||||||
const canOperateSelectedBill =
|
|
||||||
selectedBill !== null &&
|
|
||||||
canOperateBills &&
|
|
||||||
settlementBillOperableByBoundAgent(selectedBill, boundAgent);
|
|
||||||
const billingCurrency = billingPlayer?.default_currency ?? "NPR";
|
|
||||||
|
|
||||||
function resetBillingForm(): void {
|
|
||||||
setPayAmount("");
|
|
||||||
setPayMethod("");
|
|
||||||
setPayProof("");
|
|
||||||
setBadDebtReason("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openBillingDialog(row: AdminPlayerRow): Promise<void> {
|
|
||||||
setBillingDialogOpen(true);
|
|
||||||
setBillingPlayer(row);
|
|
||||||
setBillingBills([]);
|
|
||||||
setSelectedBillId(null);
|
|
||||||
resetBillingForm();
|
|
||||||
setBillingLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const data = await getSettlementBills({
|
const href = await resolvePlayerSettlementHref(row, adminSiteId);
|
||||||
bill_type: "player",
|
if (href) {
|
||||||
keyword: row.site_player_id,
|
router.push(href);
|
||||||
per_page: 20,
|
return;
|
||||||
});
|
}
|
||||||
const items = (data.items ?? []).filter(
|
router.push(settlementCenterListHref(adminSiteId));
|
||||||
(bill) =>
|
toast.info(
|
||||||
bill.bill_type === "player" &&
|
t("playersPanel.noPendingBillsGoCenter", {
|
||||||
bill.owner_id === row.id &&
|
defaultValue: "该玩家暂无待处理账单,已打开结算中心。",
|
||||||
(bill.status === "pending_confirm" || Number(bill.unpaid_amount ?? 0) > 0),
|
}),
|
||||||
);
|
|
||||||
setBillingBills(items);
|
|
||||||
const first = items[0] ?? null;
|
|
||||||
setSelectedBillId(first?.id ?? null);
|
|
||||||
setPayAmount(
|
|
||||||
first ? formatAdminMinorDecimal(first.unpaid_amount ?? 0, row.default_currency ?? "NPR") : "",
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -650,172 +601,50 @@ export function AgentsPlayersPanel({
|
|||||||
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
|
: t("playersPanel.billingLoadFailed", { defaultValue: "加载账单失败" }),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBillingLoading(false);
|
setSettlementNavBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirmBill(): Promise<void> {
|
|
||||||
if (selectedBill === null) return;
|
|
||||||
setBillingBusy(true);
|
|
||||||
try {
|
|
||||||
await postSettlementBillConfirm(selectedBill.id);
|
|
||||||
toast.success(
|
|
||||||
t("playersPanel.billConfirmed", { defaultValue: "账单已确认,请继续登记收付或核销" }),
|
|
||||||
);
|
|
||||||
if (billingPlayer) {
|
|
||||||
await openBillingDialog(billingPlayer);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
e instanceof LotteryApiBizError
|
|
||||||
? e.message
|
|
||||||
: t("playersPanel.billConfirmFailed", { defaultValue: "确认账单失败" }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setBillingBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePayBill(): Promise<void> {
|
|
||||||
if (selectedBill === null) return;
|
|
||||||
const fallbackAmount = formatAdminMinorDecimal(
|
|
||||||
selectedBill.unpaid_amount ?? 0,
|
|
||||||
billingCurrency,
|
|
||||||
);
|
|
||||||
const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency);
|
|
||||||
if (amount === null || amount <= 0 || amount > Number(selectedBill.unpaid_amount ?? 0)) {
|
|
||||||
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入有效的收付金额" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBillingBusy(true);
|
|
||||||
try {
|
|
||||||
await postSettlementBillPayment(selectedBill.id, {
|
|
||||||
amount,
|
|
||||||
method: payMethod.trim() || undefined,
|
|
||||||
proof: payProof.trim() || undefined,
|
|
||||||
});
|
|
||||||
toast.success(t("playersPanel.billPaid", { defaultValue: "已登记收付" }));
|
|
||||||
await load();
|
|
||||||
if (billingPlayer) {
|
|
||||||
await openBillingDialog(billingPlayer);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
e instanceof LotteryApiBizError
|
|
||||||
? e.message
|
|
||||||
: t("playersPanel.billPayFailed", { defaultValue: "登记收付失败" }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setBillingBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleWriteOffBill(): Promise<void> {
|
|
||||||
if (selectedBill === null) return;
|
|
||||||
const reason = badDebtReason.trim();
|
|
||||||
if (!reason) {
|
|
||||||
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBillingBusy(true);
|
|
||||||
try {
|
|
||||||
await postSettlementBillBadDebtWriteOff(selectedBill.id, {
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
toast.success(t("playersPanel.billWrittenOff", { defaultValue: "已核销坏账" }));
|
|
||||||
await load();
|
|
||||||
if (billingPlayer) {
|
|
||||||
await openBillingDialog(billingPlayer);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
e instanceof LotteryApiBizError
|
|
||||||
? e.message
|
|
||||||
: t("playersPanel.billWriteOffFailed", { defaultValue: "核销坏账失败" }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setBillingBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestConfirmBillAction(): void {
|
|
||||||
if (selectedBill === null) return;
|
|
||||||
requestConfirm({
|
|
||||||
title: t("playersPanel.confirmBillTitle", { defaultValue: "确认账单?" }),
|
|
||||||
description: t("playersPanel.confirmBillDescription", {
|
|
||||||
defaultValue: "确认后账单会进入待收付状态,请确认金额与玩家无误。",
|
|
||||||
}),
|
|
||||||
confirmLabel: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
|
|
||||||
confirmVariant: "default",
|
|
||||||
onConfirm: handleConfirmBill,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestPayBillAction(): void {
|
|
||||||
if (selectedBill === null) return;
|
|
||||||
const fallbackAmount = formatAdminMinorDecimal(
|
|
||||||
selectedBill.unpaid_amount ?? 0,
|
|
||||||
billingCurrency,
|
|
||||||
);
|
|
||||||
const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency);
|
|
||||||
if (amount === null || amount <= 0) {
|
|
||||||
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的有效金额" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (amount > Number(selectedBill.unpaid_amount ?? 0)) {
|
|
||||||
toast.error(t("playersPanel.paymentAmountTooLarge", { defaultValue: "收付金额不能超过未结金额" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestConfirm({
|
|
||||||
title: t("playersPanel.payBillConfirmTitle", { defaultValue: "确认登记收付?" }),
|
|
||||||
description: t("playersPanel.payBillConfirmDescription", {
|
|
||||||
defaultValue: "这会写入收付记录并更新玩家账单金额,请确认金额与凭证无误。",
|
|
||||||
}),
|
|
||||||
confirmLabel: t("agents:settlementBills.paid", { defaultValue: "登记收付" }),
|
|
||||||
confirmVariant: "default",
|
|
||||||
onConfirm: handlePayBill,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestWriteOffBillAction(): void {
|
|
||||||
if (selectedBill === null) return;
|
|
||||||
if (!badDebtReason.trim()) {
|
|
||||||
toast.error(t("playersPanel.badDebtReasonRequired", { defaultValue: "请填写核销原因" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestConfirm({
|
|
||||||
title: t("playersPanel.writeOffBillConfirmTitle", { defaultValue: "确认核销坏账?" }),
|
|
||||||
description: t("playersPanel.writeOffBillConfirmDescription", {
|
|
||||||
defaultValue: "核销会把该玩家账单未结金额归档为坏账记录,请确认已无法收回。",
|
|
||||||
}),
|
|
||||||
confirmLabel: t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" }),
|
|
||||||
confirmVariant: "destructive",
|
|
||||||
onConfirm: handleWriteOffBill,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="admin-list-toolbar">
|
||||||
{!embedded ? (
|
<div className="admin-list-field min-w-[12rem] flex-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="relative">
|
||||||
{t("playersPanel.creditListHint", {
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
|
<Input
|
||||||
})}
|
value={keyword}
|
||||||
</p>
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
) : (
|
onKeyDown={(e) => {
|
||||||
<div />
|
if (e.key === "Enter") {
|
||||||
)}
|
setAppliedKeyword(keyword);
|
||||||
{canCreatePlayer && !embedded ? (
|
setPage(1);
|
||||||
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
}
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
}}
|
||||||
{createPlayerLabel}
|
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>
|
</Button>
|
||||||
) : null}
|
{canCreatePlayer ? (
|
||||||
|
<Button type="button" size="sm" onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
|
{createPlayerLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -825,41 +654,51 @@ export function AgentsPlayersPanel({
|
|||||||
{listErr ? (
|
{listErr ? (
|
||||||
<p className="mb-3 text-sm text-destructive">{listErr}</p>
|
<p className="mb-3 text-sm text-destructive">{listErr}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
<div
|
||||||
|
className={
|
||||||
|
embedded
|
||||||
|
? "admin-table-inset overflow-hidden"
|
||||||
|
: "admin-table-shell overflow-hidden rounded-xl border border-border/70"
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
|
<TableHead className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
|
||||||
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
|
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
|
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">
|
{!embedded ? (
|
||||||
{t("players:riskTags", { defaultValue: "风控标签" })}
|
<>
|
||||||
</TableHead>
|
<TableHead className="whitespace-nowrap">
|
||||||
<TableHead className="whitespace-nowrap">
|
{t("players:riskTags", { defaultValue: "风控标签" })}
|
||||||
{t("players:fundingMode", { defaultValue: "资金模式" })}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="whitespace-nowrap">
|
||||||
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
|
{t("players:fundingMode", { defaultValue: "资金模式" })}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
|
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("players:rebateRate", { defaultValue: "回水" })}
|
{t("players:rebateRate", { defaultValue: "回水" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
|
|
||||||
{!embedded ? (
|
{!embedded ? (
|
||||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
|
||||||
) : null}
|
) : null}
|
||||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||||
|
<TableHead className="sticky right-0 z-20 w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
{t("common:table.actions", { defaultValue: "操作" })}
|
{t("common:table.actions", { defaultValue: "操作" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<AdminTableNoResourceRow colSpan={embedded ? 9 : 10} cellClassName="py-12 text-center" />
|
<AdminTableNoResourceRow colSpan={embedded ? 7 : 10} cellClassName="py-12 text-center" />
|
||||||
) : (
|
) : (
|
||||||
items.map((row) => {
|
items.map((row) => {
|
||||||
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
const balances = playerBalanceCells(row, formatAdminMinorUnits);
|
||||||
@@ -876,26 +715,30 @@ export function AgentsPlayersPanel({
|
|||||||
<span className="text-muted-foreground"> / </span>
|
<span className="text-muted-foreground"> / </span>
|
||||||
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
|
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[14rem]">
|
{!embedded ? (
|
||||||
{riskTags.length > 0 ? (
|
<>
|
||||||
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
|
<TableCell className="max-w-[14rem]">
|
||||||
{riskTags.map((tag) => (
|
{riskTags.length > 0 ? (
|
||||||
<span
|
<div className="flex flex-wrap gap-1" title={riskTags.join(", ")}>
|
||||||
key={`${row.id}-${tag}`}
|
{riskTags.map((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"
|
<span
|
||||||
>
|
key={`${row.id}-${tag}`}
|
||||||
{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"
|
||||||
</span>
|
>
|
||||||
))}
|
{tag}
|
||||||
</div>
|
</span>
|
||||||
) : (
|
))}
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</TableCell>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
<TableCell>
|
)}
|
||||||
<PlayerFundingModeBadge row={row} />
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
|
<PlayerFundingModeBadge row={row} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<TableCell className="text-right text-xs tabular-nums">
|
<TableCell className="text-right text-xs tabular-nums">
|
||||||
<span>{balances.balance}</span>
|
<span>{balances.balance}</span>
|
||||||
<span className="text-muted-foreground"> / </span>
|
<span className="text-muted-foreground"> / </span>
|
||||||
@@ -911,22 +754,22 @@ export function AgentsPlayersPanel({
|
|||||||
>
|
>
|
||||||
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
|
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
|
||||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
|
||||||
</TableCell>
|
|
||||||
{!embedded ? (
|
{!embedded ? (
|
||||||
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
|
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
|
||||||
{playerStatusLabel(row.status, t)}
|
{playerStatusLabel(row.status, t)}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
) : null}
|
|
||||||
<TableCell
|
<TableCell
|
||||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
className="sticky right-0 z-10 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<AdminRowActionsMenu
|
<AdminRowActionsMenu
|
||||||
busy={confirmBusy}
|
busy={confirmBusy || settlementNavBusy}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
key: "detail",
|
key: "detail",
|
||||||
@@ -942,7 +785,7 @@ export function AgentsPlayersPanel({
|
|||||||
defaultValue: "处理账单",
|
defaultValue: "处理账单",
|
||||||
}),
|
}),
|
||||||
icon: ReceiptText,
|
icon: ReceiptText,
|
||||||
onClick: () => void openBillingDialog(row),
|
onClick: () => void goToPlayerSettlement(row),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -1040,7 +883,10 @@ export function AgentsPlayersPanel({
|
|||||||
className="bg-background/50 transition-colors focus:bg-background"
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground/80">
|
<p className="text-[11px] text-muted-foreground/80">
|
||||||
{t("playersPanel.passwordHint", { defaultValue: "至少 8 位" })}
|
{t("playersPanel.passwordHint", {
|
||||||
|
defaultValue: "至少 {{min}} 位",
|
||||||
|
min: NATIVE_PLAYER_PASSWORD_MIN_LENGTH,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1101,157 +947,6 @@ export function AgentsPlayersPanel({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={billingDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setBillingDialogOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
setBillingPlayer(null);
|
|
||||||
setBillingBills([]);
|
|
||||||
setSelectedBillId(null);
|
|
||||||
resetBillingForm();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{t("playersPanel.manageSettlement", { defaultValue: "处理账单" })}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{billingLoading ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("playersPanel.billingLoading", { defaultValue: "正在加载账单…" })}
|
|
||||||
</p>
|
|
||||||
) : billingBills.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("playersPanel.noPendingBills", { defaultValue: "当前没有可处理的未结账单。" })}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t("playersPanel.selectBill", { defaultValue: "选择账单" })}</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedBillId ? String(selectedBillId) : ""}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const next = billingBills.find((bill) => bill.id === Number(value)) ?? null;
|
|
||||||
setSelectedBillId(next?.id ?? null);
|
|
||||||
setPayAmount(
|
|
||||||
next
|
|
||||||
? formatAdminMinorDecimal(next.unpaid_amount ?? 0, billingCurrency)
|
|
||||||
: "",
|
|
||||||
);
|
|
||||||
setPayMethod("");
|
|
||||||
setPayProof("");
|
|
||||||
setBadDebtReason("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t("playersPanel.selectBill", { defaultValue: "选择账单" })} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{billingBills.map((bill) => (
|
|
||||||
<SelectItem key={bill.id} value={String(bill.id)}>
|
|
||||||
{`#${bill.id} · ${bill.status} · ${bill.player_site_player_id ?? bill.owner_id} · ${formatAdminMinorUnits(bill.unpaid_amount ?? 0, billingCurrency)}`}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedBill ? (
|
|
||||||
<div className="space-y-4 rounded-xl border border-border/70 p-4">
|
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("playersPanel.billStatus", { defaultValue: "状态" })}:
|
|
||||||
</span>{" "}
|
|
||||||
{selectedBill.status}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("playersPanel.billUnpaid", { defaultValue: "未结" })}:
|
|
||||||
</span>{" "}
|
|
||||||
{formatAdminMinorUnits(selectedBill.unpaid_amount ?? 0, billingCurrency)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canOperateSelectedBill && selectedBill.status === "pending_confirm" ? (
|
|
||||||
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestConfirmBillAction}>
|
|
||||||
{t("agents:settlementBills.confirm", { defaultValue: "确认账单" })}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canOperateSelectedBill && selectedBill.status !== "pending_confirm" && Number(selectedBill.unpaid_amount ?? 0) > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
|
||||||
<Input
|
|
||||||
value={payAmount}
|
|
||||||
onChange={(e) => setPayAmount(e.target.value)}
|
|
||||||
inputMode="decimal"
|
|
||||||
placeholder={formatAdminMinorDecimal(
|
|
||||||
selectedBill.unpaid_amount ?? 0,
|
|
||||||
billingCurrency,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
|
||||||
<Input
|
|
||||||
value={payMethod}
|
|
||||||
onChange={(e) => setPayMethod(e.target.value)}
|
|
||||||
placeholder={t("agents:settlementBills.paymentMethodPlaceholder", {
|
|
||||||
defaultValue: "例如:现金 / 银行转账",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>{t("agents:settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
|
||||||
<Input
|
|
||||||
value={payProof}
|
|
||||||
onChange={(e) => setPayProof(e.target.value)}
|
|
||||||
placeholder={t("agents:settlementBills.paymentProofPlaceholder", {
|
|
||||||
defaultValue: "可填写流水号、截图说明或备注",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="button" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestPayBillAction}>
|
|
||||||
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{canFinanceAdjustments &&
|
|
||||||
["confirmed", "partial_paid", "overdue"].includes(selectedBill.status) &&
|
|
||||||
Number(selectedBill.unpaid_amount ?? 0) > 0 &&
|
|
||||||
!["adjustment", "reversal", "bad_debt"].includes(selectedBill.bill_type) ? (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1 pt-2">
|
|
||||||
<Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
|
||||||
<Input
|
|
||||||
value={badDebtReason}
|
|
||||||
onChange={(e) => setBadDebtReason(e.target.value)}
|
|
||||||
placeholder={t("agents:settlementBills.badDebtReasonPlaceholder", {
|
|
||||||
defaultValue: "例如:客户失联、确认坏账",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestWriteOffBillAction}>
|
|
||||||
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[460px]">
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import { AdminSubnav, AdminSubnavBar, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||||
AdminSubnavBar,
|
|
||||||
} from "@/components/admin/admin-subnav";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
@@ -22,6 +20,9 @@ import { cn } from "@/lib/utils";
|
|||||||
export function AgentsSubnav(): React.ReactElement {
|
export function AgentsSubnav(): React.ReactElement {
|
||||||
const { t } = useTranslation("agents");
|
const { t } = useTranslation("agents");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const isAgentsHome = pathname === "/admin/agents";
|
||||||
|
const agentsView = searchParams.get("view") === "list" ? "list" : "tree";
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||||
@@ -74,7 +75,7 @@ export function AgentsSubnav(): React.ReactElement {
|
|||||||
}, [deferredKeyword, siteOptions]);
|
}, [deferredKeyword, siteOptions]);
|
||||||
|
|
||||||
const siteReadOnlyLabel =
|
const siteReadOnlyLabel =
|
||||||
pathname !== "/admin/agents/list" &&
|
isAgentsHome &&
|
||||||
!canSwitchSite &&
|
!canSwitchSite &&
|
||||||
selectedSite != null ? (
|
selectedSite != null ? (
|
||||||
<div className="flex h-10 min-w-[200px] items-center justify-end gap-2 rounded-md border border-border/70 bg-background px-3 text-sm">
|
<div className="flex h-10 min-w-[200px] items-center justify-end gap-2 rounded-md border border-border/70 bg-background px-3 text-sm">
|
||||||
@@ -84,7 +85,7 @@ export function AgentsSubnav(): React.ReactElement {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const siteSelector =
|
const siteSelector =
|
||||||
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
isAgentsHome && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||||
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
|
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -152,11 +153,22 @@ export function AgentsSubnav(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSubnavBar trailing={siteSelector ?? siteReadOnlyLabel}>
|
<AdminSubnavBar trailing={siteSelector ?? siteReadOnlyLabel}>
|
||||||
<div className="pb-1">
|
{isAgentsHome ? (
|
||||||
<p className="text-sm font-medium text-foreground">
|
<AdminSubnav aria-label={t("viewModes.label")}>
|
||||||
{t("title", { defaultValue: "代理管理" })}
|
<AdminSubnavLink href="/admin/agents" active={agentsView === "tree"}>
|
||||||
</p>
|
{t("viewModes.tree")}
|
||||||
</div>
|
</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>
|
</AdminSubnavBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -28,6 +35,28 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
|||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminAuditLogListData } from "@/types/api/admin-audit";
|
import type { AdminAuditLogListData } from "@/types/api/admin-audit";
|
||||||
|
|
||||||
|
const MODULE_FILTER_OPTIONS = [
|
||||||
|
"agent",
|
||||||
|
"system",
|
||||||
|
"settings",
|
||||||
|
"integration",
|
||||||
|
"player_manage",
|
||||||
|
"player_service",
|
||||||
|
"draw",
|
||||||
|
"settlement",
|
||||||
|
"wallet",
|
||||||
|
"reconcile",
|
||||||
|
"reconcile_jobs",
|
||||||
|
"report_jobs",
|
||||||
|
"odds",
|
||||||
|
"play_config",
|
||||||
|
"risk_cap",
|
||||||
|
"jackpot",
|
||||||
|
"dashboard",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const OPERATOR_TYPE_OPTIONS = ["admin", "player", "system"] as const;
|
||||||
|
|
||||||
export function AuditLogsConsole(): React.ReactElement {
|
export function AuditLogsConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["audit", "common"]);
|
const { t } = useTranslation(["audit", "common"]);
|
||||||
const tRef = useTranslationRef(["audit", "common"]);
|
const tRef = useTranslationRef(["audit", "common"]);
|
||||||
@@ -39,15 +68,13 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
const [operatorId, setOperatorId] = useState("");
|
const [operatorId, setOperatorId] = useState("");
|
||||||
const [moduleCode, setModuleCode] = useState("");
|
const [moduleCode, setModuleCode] = useState("all");
|
||||||
const [actionCode, setActionCode] = useState("");
|
const [operatorType, setOperatorType] = useState("all");
|
||||||
const [operatorType, setOperatorType] = useState("");
|
|
||||||
const [startDate, setStartDate] = useState("");
|
const [startDate, setStartDate] = useState("");
|
||||||
const [endDate, setEndDate] = useState("");
|
const [endDate, setEndDate] = useState("");
|
||||||
const [appliedOperatorId, setAppliedOperatorId] = useState("");
|
const [appliedOperatorId, setAppliedOperatorId] = useState("");
|
||||||
const [appliedModule, setAppliedModule] = useState("");
|
const [appliedModule, setAppliedModule] = useState("all");
|
||||||
const [appliedAction, setAppliedAction] = useState("");
|
const [appliedOpType, setAppliedOpType] = useState("all");
|
||||||
const [appliedOpType, setAppliedOpType] = useState("");
|
|
||||||
const [appliedStartDate, setAppliedStartDate] = useState("");
|
const [appliedStartDate, setAppliedStartDate] = useState("");
|
||||||
const [appliedEndDate, setAppliedEndDate] = useState("");
|
const [appliedEndDate, setAppliedEndDate] = useState("");
|
||||||
|
|
||||||
@@ -66,9 +93,8 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
page,
|
page,
|
||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
operator_id: operatorIdParam,
|
operator_id: operatorIdParam,
|
||||||
module_code: appliedModule.trim() || undefined,
|
module_code: appliedModule !== "all" ? appliedModule : undefined,
|
||||||
action_code: appliedAction.trim() || undefined,
|
operator_type: appliedOpType !== "all" ? appliedOpType : undefined,
|
||||||
operator_type: appliedOpType.trim() || undefined,
|
|
||||||
start_date: appliedStartDate || undefined,
|
start_date: appliedStartDate || undefined,
|
||||||
end_date: appliedEndDate || undefined,
|
end_date: appliedEndDate || undefined,
|
||||||
});
|
});
|
||||||
@@ -79,189 +105,203 @@ export function AuditLogsConsole(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
}, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate]);
|
}, [page, perPage, appliedOperatorId, appliedModule, appliedOpType, appliedStartDate, appliedEndDate]);
|
||||||
|
|
||||||
const meta = data?.meta;
|
const meta = data?.meta;
|
||||||
|
|
||||||
|
const operatorTypeLabel = (type: string): string => {
|
||||||
|
const key = `operatorTypes.${type}` as const;
|
||||||
|
const translated = t(key);
|
||||||
|
return translated === key ? type : translated;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTargetColumn = (row: AdminAuditLogListData["items"][number]): boolean => {
|
||||||
|
const summary = row.summary_label || `${row.module_label} · ${row.action_label}`;
|
||||||
|
const target = row.target_label;
|
||||||
|
if (!target || target === "—") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !summary.includes(target);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="admin-list-card w-full max-w-none">
|
<Card className="admin-list-card w-full max-w-none">
|
||||||
<CardHeader className="admin-list-header">
|
<CardHeader className="admin-list-header">
|
||||||
<CardTitle className="admin-list-title">{t("title")}</CardTitle>
|
<CardTitle className="admin-list-title">{t("title")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<CardContent className="admin-list-content">
|
||||||
<div className="admin-list-toolbar">
|
<div className="admin-list-toolbar">
|
||||||
<div className="admin-list-field">
|
<div className="admin-list-field">
|
||||||
<Label htmlFor="aud-operator-id" className="sm:shrink-0">
|
<Label htmlFor="aud-module" className="sm:shrink-0">
|
||||||
{t("operator")}
|
{t("filterModule")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Select value={moduleCode} onValueChange={(value) => setModuleCode(value ?? "all")}>
|
||||||
id="aud-operator-id"
|
<SelectTrigger id="aud-module" className="w-full sm:w-44">
|
||||||
value={operatorId}
|
<SelectValue />
|
||||||
onChange={(e) => setOperatorId(e.target.value)}
|
</SelectTrigger>
|
||||||
placeholder={t("operatorIdPlaceholder")}
|
<SelectContent>
|
||||||
className="w-full sm:w-36"
|
<SelectItem value="all">{t("filterModuleAll")}</SelectItem>
|
||||||
inputMode="numeric"
|
{MODULE_FILTER_OPTIONS.map((code) => (
|
||||||
/>
|
<SelectItem key={code} value={code}>
|
||||||
</div>
|
{t(`moduleOptions.${code}`)}
|
||||||
<div className="admin-list-field">
|
</SelectItem>
|
||||||
<Label htmlFor="aud-mod" className="sm:shrink-0">
|
))}
|
||||||
{t("moduleCode")}
|
</SelectContent>
|
||||||
</Label>
|
</Select>
|
||||||
<Input
|
</div>
|
||||||
id="aud-mod"
|
<div className="admin-list-field">
|
||||||
value={moduleCode}
|
<Label htmlFor="aud-op-type" className="sm:shrink-0">
|
||||||
onChange={(e) => setModuleCode(e.target.value)}
|
{t("filterOperatorType")}
|
||||||
placeholder={t("exactMatch")}
|
</Label>
|
||||||
className="w-full sm:w-40"
|
<Select value={operatorType} onValueChange={(value) => setOperatorType(value ?? "all")}>
|
||||||
/>
|
<SelectTrigger id="aud-op-type" className="w-full sm:w-36">
|
||||||
</div>
|
<SelectValue />
|
||||||
<div className="admin-list-field">
|
</SelectTrigger>
|
||||||
<Label htmlFor="aud-act" className="sm:shrink-0">
|
<SelectContent>
|
||||||
{t("actionCode")}
|
<SelectItem value="all">{t("filterOperatorTypeAll")}</SelectItem>
|
||||||
</Label>
|
{OPERATOR_TYPE_OPTIONS.map((code) => (
|
||||||
<Input
|
<SelectItem key={code} value={code}>
|
||||||
id="aud-act"
|
{t(`operatorTypes.${code}`)}
|
||||||
value={actionCode}
|
</SelectItem>
|
||||||
onChange={(e) => setActionCode(e.target.value)}
|
))}
|
||||||
placeholder={t("exactMatch")}
|
</SelectContent>
|
||||||
className="w-full sm:w-40"
|
</Select>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="admin-list-field">
|
||||||
<div className="admin-list-field">
|
<Label htmlFor="aud-operator-id" className="sm:shrink-0">
|
||||||
<Label htmlFor="aud-op" className="sm:shrink-0">
|
{t("operator")}
|
||||||
{t("operatorType")}
|
</Label>
|
||||||
</Label>
|
<Input
|
||||||
<Input
|
id="aud-operator-id"
|
||||||
id="aud-op"
|
value={operatorId}
|
||||||
value={operatorType}
|
onChange={(e) => setOperatorId(e.target.value)}
|
||||||
onChange={(e) => setOperatorType(e.target.value)}
|
placeholder={t("operatorIdPlaceholder")}
|
||||||
placeholder={t("operatorTypePlaceholder")}
|
className="w-full sm:w-36"
|
||||||
className="w-full sm:w-40"
|
inputMode="numeric"
|
||||||
/>
|
|
||||||
</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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="admin-list-field">
|
||||||
<div className="admin-list-actions">
|
<Label htmlFor="aud-date-range" className="sm:shrink-0">
|
||||||
<AdminTableExportButton
|
{t("time")}
|
||||||
tableId="audit-logs-table"
|
</Label>
|
||||||
filename={exportLabels.filename}
|
<div className="min-w-0 w-full sm:w-56">
|
||||||
sheetName={exportLabels.sheetName}
|
<AdminDateRangeField
|
||||||
/>
|
id="aud-date-range"
|
||||||
<Button
|
from={startDate}
|
||||||
type="button"
|
to={endDate}
|
||||||
onClick={() => {
|
onRangeChange={(range) => {
|
||||||
setAppliedOperatorId(operatorId);
|
setStartDate(range.from);
|
||||||
setAppliedModule(moduleCode);
|
setEndDate(range.to);
|
||||||
setAppliedAction(actionCode);
|
}}
|
||||||
setAppliedOpType(operatorType);
|
/>
|
||||||
setAppliedStartDate(startDate);
|
</div>
|
||||||
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>
|
</div>
|
||||||
{meta ? (
|
<div className="admin-list-actions">
|
||||||
<AdminListPaginationFooter
|
<AdminTableExportButton
|
||||||
selectId="audit-logs-per-page"
|
tableId="audit-logs-table"
|
||||||
total={meta.total}
|
filename={exportLabels.filename}
|
||||||
page={meta.current_page}
|
sheetName={exportLabels.sheetName}
|
||||||
lastPage={Math.max(1, meta.last_page)}
|
/>
|
||||||
perPage={meta.per_page}
|
<Button
|
||||||
loading={loading}
|
type="button"
|
||||||
onPerPageChange={(n) => {
|
onClick={() => {
|
||||||
setPerPage(n);
|
setAppliedOperatorId(operatorId);
|
||||||
|
setAppliedModule(moduleCode);
|
||||||
|
setAppliedOpType(operatorType);
|
||||||
|
setAppliedStartDate(startDate);
|
||||||
|
setAppliedEndDate(endDate);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
onPageChange={setPage}
|
>
|
||||||
/>
|
{t("actions.search", { ns: "common" })}
|
||||||
) : null}
|
</Button>
|
||||||
</>
|
<Button
|
||||||
) : null}
|
type="button"
|
||||||
</CardContent>
|
variant="secondary"
|
||||||
</Card>
|
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;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("rounded-lg border border-border/70 bg-background", className)}>
|
||||||
className={cn(
|
<div className="flex flex-col gap-3 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
"overflow-hidden rounded-xl border border-border/60 bg-card",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:p-4">
|
|
||||||
<div className="min-w-0 flex-1">{switcher}</div>
|
<div className="min-w-0 flex-1">{switcher}</div>
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 sm:justify-end">{actions}</div>
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5 sm:justify-end">{actions}</div>
|
||||||
</div>
|
</div>
|
||||||
{footer ? (
|
{footer ? (
|
||||||
<div className="border-t border-border/50 bg-muted/20 px-3 py-2.5 sm:px-4">{footer}</div>
|
<div className="border-t border-border/60 px-3 py-2 text-xs text-muted-foreground sm:px-3">{footer}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Plus, RefreshCw, Rocket, Save } from "lucide-react";
|
import { Plus, RefreshCw, Save } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -75,7 +75,6 @@ export function ConfigVersionActions({
|
|||||||
{t("versionActions.saveDraft")}
|
{t("versionActions.saveDraft")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" disabled={draftActionBusy} onClick={onPublish}>
|
<Button type="button" size="sm" disabled={draftActionBusy} onClick={onPublish}>
|
||||||
<Rocket className="size-3.5" aria-hidden />
|
|
||||||
{resolvedPublishLabel}
|
{resolvedPublishLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Check, ChevronRight, Layers, MoreHorizontal } from "lucide-react";
|
import { Check, ChevronDown, MoreHorizontal } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -20,20 +20,13 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
Sheet,
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
|
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import type { ConfigVersionSummary } from "@/types/api/admin-config";
|
import type { ConfigVersionSummary } from "@/types/api/admin-config";
|
||||||
|
|
||||||
const STATUS_ORDER = ["draft", "active", "archived"] as const;
|
|
||||||
|
|
||||||
export type ConfigVersionSwitcherProps = {
|
export type ConfigVersionSwitcherProps = {
|
||||||
versions: ConfigVersionSummary[];
|
versions: ConfigVersionSummary[];
|
||||||
selectedId: string;
|
selectedId: string;
|
||||||
@@ -48,34 +41,12 @@ export type ConfigVersionSwitcherProps = {
|
|||||||
rollbackBusy?: boolean;
|
rollbackBusy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatVersionNote(
|
|
||||||
reason: string | null | undefined,
|
|
||||||
formatDt: (iso: string | null | undefined) => string,
|
|
||||||
): string | null {
|
|
||||||
if (!reason?.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = reason.trim();
|
|
||||||
const isoMatch = trimmed.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/);
|
|
||||||
if (trimmed.startsWith("draft") && isoMatch) {
|
|
||||||
return formatDt(isoMatch[0]);
|
|
||||||
}
|
|
||||||
if (trimmed.startsWith("seed:")) {
|
|
||||||
return trimmed.slice(5);
|
|
||||||
}
|
|
||||||
if (trimmed.length > 56) {
|
|
||||||
return `${trimmed.slice(0, 56)}…`;
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfigVersionSwitcher({
|
export function ConfigVersionSwitcher({
|
||||||
versions,
|
versions,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelectedIdChange,
|
onSelectedIdChange,
|
||||||
loading = false,
|
loading = false,
|
||||||
sheetTitle,
|
sheetTitle,
|
||||||
sheetDescription,
|
|
||||||
className,
|
className,
|
||||||
onDeleteVersion,
|
onDeleteVersion,
|
||||||
onRollbackVersion,
|
onRollbackVersion,
|
||||||
@@ -83,48 +54,24 @@ export function ConfigVersionSwitcher({
|
|||||||
}: ConfigVersionSwitcherProps) {
|
}: ConfigVersionSwitcherProps) {
|
||||||
const { t } = useTranslation(["config", "adminUsers"]);
|
const { t } = useTranslation(["config", "adminUsers"]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ConfigVersionSummary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ConfigVersionSummary | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
const resolvedSheetTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" });
|
const resolvedTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" });
|
||||||
const resolvedSheetDescription =
|
|
||||||
sheetDescription ?? t("versionSwitcher.sheetDescription", { ns: "config" });
|
|
||||||
|
|
||||||
const sortedVersions = useMemo(
|
const sortedVersions = useMemo(
|
||||||
() => [...versions].sort((a, b) => b.id - a.id),
|
() => [...versions].sort((a, b) => b.version_no - a.version_no),
|
||||||
[versions],
|
[versions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedVersions = useMemo(() => {
|
|
||||||
const groups = new Map<string, ConfigVersionSummary[]>();
|
|
||||||
for (const status of STATUS_ORDER) {
|
|
||||||
groups.set(status, []);
|
|
||||||
}
|
|
||||||
for (const v of sortedVersions) {
|
|
||||||
const list = groups.get(v.status) ?? [];
|
|
||||||
list.push(v);
|
|
||||||
groups.set(v.status, list);
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}, [sortedVersions]);
|
|
||||||
|
|
||||||
const selectedVersion = useMemo(
|
const selectedVersion = useMemo(
|
||||||
() => sortedVersions.find((v) => String(v.id) === selectedId) ?? null,
|
() => sortedVersions.find((v) => String(v.id) === selectedId) ?? null,
|
||||||
[selectedId, sortedVersions],
|
[selectedId, sortedVersions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleSections = useMemo(
|
|
||||||
() =>
|
|
||||||
STATUS_ORDER.map((status) => ({
|
|
||||||
status,
|
|
||||||
rows: groupedVersions.get(status) ?? [],
|
|
||||||
})).filter((section) => section.rows.length > 0),
|
|
||||||
[groupedVersions],
|
|
||||||
);
|
|
||||||
|
|
||||||
function switchTo(id: number) {
|
function switchTo(id: number) {
|
||||||
onSelectedIdChange(String(id));
|
onSelectedIdChange(String(id));
|
||||||
setSheetOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
@@ -143,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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex min-w-0 items-center gap-3", className)}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<PopoverTrigger
|
||||||
{selectedVersion ? (
|
|
||||||
<>
|
|
||||||
<span className="font-mono text-base font-semibold tabular-nums text-foreground">
|
|
||||||
v{selectedVersion.version_no}
|
|
||||||
</span>
|
|
||||||
<ConfigStatusBadge status={selectedVersion.status} className="h-5 px-1.5 text-[11px]" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{loading
|
|
||||||
? t("versionSwitcher.loading", { ns: "config" })
|
|
||||||
: t("versionSwitcher.noneSelected", { ns: "config" })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
disabled={loading || sortedVersions.length === 0}
|
disabled={loading || sortedVersions.length === 0}
|
||||||
onClick={() => setSheetOpen(true)}
|
className={cn(
|
||||||
className="h-8 shrink-0 gap-1.5 text-muted-foreground hover:text-foreground"
|
"inline-flex h-9 min-w-[10rem] max-w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none transition-colors hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Layers className="size-3.5" aria-hidden />
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
{t("versionSwitcher.switch", { ns: "config" })}
|
<span className="truncate font-mono tabular-nums">{triggerLabel}</span>
|
||||||
</Button>
|
{selectedVersion ? (
|
||||||
</div>
|
<ConfigStatusBadge status={selectedVersion.status} className="h-5 shrink-0 px-1.5 text-[11px]" />
|
||||||
|
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
className="flex w-full flex-col gap-0 overflow-hidden border-l bg-background p-0 sm:max-w-md"
|
|
||||||
>
|
|
||||||
<SheetHeader className="space-y-1 border-b border-border/60 px-5 py-4 text-left">
|
|
||||||
<SheetTitle className="text-[15px] font-semibold tracking-tight">
|
|
||||||
{resolvedSheetTitle}
|
|
||||||
</SheetTitle>
|
|
||||||
{resolvedSheetDescription ? (
|
|
||||||
<SheetDescription className="text-[13px] leading-relaxed">
|
|
||||||
{resolvedSheetDescription}
|
|
||||||
</SheetDescription>
|
|
||||||
) : null}
|
) : null}
|
||||||
</SheetHeader>
|
</span>
|
||||||
|
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
<PopoverContent align="start" className="w-[min(20rem,calc(100vw-2rem))] p-0">
|
||||||
|
<div className="border-b border-border/60 px-3 py-2 text-sm font-medium">{resolvedTitle}</div>
|
||||||
|
<ScrollArea className="max-h-72">
|
||||||
{sortedVersions.length === 0 ? (
|
{sortedVersions.length === 0 ? (
|
||||||
<AdminNoResourceState compact className="px-2 py-8" />
|
<AdminNoResourceState compact className="px-3 py-6" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<ul className="p-1">
|
||||||
{visibleSections.map((section) => (
|
{sortedVersions.map((v) => {
|
||||||
<section key={section.status}>
|
const isCurrent = selectedId === String(v.id);
|
||||||
<p className="mb-2 px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
const secondary =
|
||||||
{t(`versionStatus.${section.status}`, { ns: "config" })}
|
v.status === "active" && v.effective_at ? formatDt(v.effective_at) : null;
|
||||||
</p>
|
const showMenu =
|
||||||
<ul className="space-y-1">
|
(onDeleteVersion && v.status !== "active") ||
|
||||||
{section.rows.map((v) => {
|
(onRollbackVersion && v.status !== "draft");
|
||||||
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");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={v.id}>
|
<li key={v.id} className="flex items-center gap-0.5">
|
||||||
<div
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
"group flex items-stretch gap-0.5 rounded-lg border transition-colors",
|
className={cn(
|
||||||
isCurrent
|
"flex min-w-0 flex-1 items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors",
|
||||||
? "border-primary/40 bg-primary/[0.04]"
|
isCurrent ? "bg-muted" : "hover:bg-muted/60",
|
||||||
: "border-transparent hover:border-border/60 hover:bg-muted/30",
|
)}
|
||||||
)}
|
onClick={() => switchTo(v.id)}
|
||||||
>
|
>
|
||||||
<button
|
<span className="font-mono tabular-nums">v{v.version_no}</span>
|
||||||
type="button"
|
<ConfigStatusBadge status={v.status} className="h-5 px-1.5 text-[11px]" />
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left"
|
{secondary ? (
|
||||||
onClick={() => switchTo(v.id)}
|
<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">
|
{t("versionSwitcher.rollback", { ns: "config" })}
|
||||||
<div className="flex items-center gap-2">
|
</DropdownMenuItem>
|
||||||
<span className="font-mono text-[15px] font-semibold tabular-nums text-foreground">
|
) : null}
|
||||||
v{v.version_no}
|
{onDeleteVersion && v.status !== "active" ? (
|
||||||
</span>
|
<DropdownMenuItem
|
||||||
<ConfigStatusBadge
|
variant="destructive"
|
||||||
status={v.status}
|
disabled={deletingId === v.id}
|
||||||
className="h-5 px-1.5 text-[11px]"
|
onClick={() => setDeleteTarget(v)}
|
||||||
/>
|
>
|
||||||
</div>
|
{t("versionSwitcher.delete", { ns: "config" })}
|
||||||
{meta ? (
|
</DropdownMenuItem>
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
) : null}
|
||||||
{meta}
|
</DropdownMenuContent>
|
||||||
</p>
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</li>
|
||||||
{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 />
|
</ul>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</ScrollArea>
|
||||||
</SheetContent>
|
</PopoverContent>
|
||||||
</Sheet>
|
</Popover>
|
||||||
|
|
||||||
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<Dialog open={deleteTarget !== null} onOpenChange={(openState) => !openState && setDeleteTarget(null)}>
|
||||||
<DialogContent showCloseButton className="sm:max-w-md">
|
<DialogContent showCloseButton className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
|
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("versionSwitcher.deleteConfirmDescription", {
|
{t("versionSwitcher.deleteConfirmDescription", {
|
||||||
ns: "config",
|
ns: "config",
|
||||||
id: deleteTarget?.id,
|
|
||||||
version: deleteTarget?.version_no,
|
version: deleteTarget?.version_no,
|
||||||
})}
|
})}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
import {
|
|
||||||
ConfigVersionToolbarMeta,
|
|
||||||
ConfigVersionToolbarMetaEmphasis,
|
|
||||||
} from "@/modules/config/config-version-toolbar-meta";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -37,7 +34,7 @@ import { ratioToPercentUi } from "@/lib/admin-rate-percent";
|
|||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
|
||||||
import { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
import { ensureAdminPlayTypesLoaded, resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
@@ -56,9 +53,7 @@ import type {
|
|||||||
} from "@/types/api/admin-config";
|
} from "@/types/api/admin-config";
|
||||||
|
|
||||||
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
|
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
|
||||||
import { oddsDraftIsDirty } from "@/modules/config/doc/odds-config-dirty";
|
|
||||||
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
|
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
|
||||||
import { OddsConfigSummaryPanel } from "@/modules/config/doc/odds-config-summary-panel";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -120,7 +115,6 @@ export function OddsConfigDocScreen({
|
|||||||
const tRef = useTranslationRef(["config", "common"]);
|
const tRef = useTranslationRef(["config", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
|
||||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
const [internalSelectedId, setInternalSelectedId] = useState("");
|
const [internalSelectedId, setInternalSelectedId] = useState("");
|
||||||
@@ -465,8 +459,6 @@ export function OddsConfigDocScreen({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeHead = resolvedList.find((x) => x.status === "active");
|
|
||||||
|
|
||||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||||
try {
|
try {
|
||||||
await deleteOddsVersion(row.id);
|
await deleteOddsVersion(row.id);
|
||||||
@@ -509,18 +501,10 @@ export function OddsConfigDocScreen({
|
|||||||
{ id: "d2", label: "2D" },
|
{ id: "d2", label: "2D" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeCatLabel = catTabs.find((tab) => tab.id === catTab)?.label ?? catTab;
|
|
||||||
const activePlayLabel = resolvedPlayCode
|
const activePlayLabel = resolvedPlayCode
|
||||||
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
|
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
const isDirty = useMemo(() => {
|
|
||||||
if (!resolvedDetail || !isDraft) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return oddsDraftIsDirty(resolvedDraftRows, resolvedDetail.items);
|
|
||||||
}, [isDraft, resolvedDetail, resolvedDraftRows]);
|
|
||||||
|
|
||||||
const filtersInner = (
|
const filtersInner = (
|
||||||
<>
|
<>
|
||||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||||
@@ -602,8 +586,7 @@ export function OddsConfigDocScreen({
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelectedIdChange={setSelectedId}
|
onSelectedIdChange={setSelectedId}
|
||||||
loading={resolvedLoadingList}
|
loading={resolvedLoadingList}
|
||||||
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
|
||||||
sheetDescription={embedded ? undefined : t("odds.sheetDescription", { ns: "config" })}
|
|
||||||
onDeleteVersion={handleDeleteVersion}
|
onDeleteVersion={handleDeleteVersion}
|
||||||
onRollbackVersion={requestRollback}
|
onRollbackVersion={requestRollback}
|
||||||
rollbackBusy={saving}
|
rollbackBusy={saving}
|
||||||
@@ -623,58 +606,9 @@ export function OddsConfigDocScreen({
|
|||||||
onPublish={() => void requestPublishConfirm()}
|
onPublish={() => void requestPublishConfirm()}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
footer={
|
|
||||||
!resolvedDetail ? null : (
|
|
||||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
|
||||||
<span>
|
|
||||||
{t("odds.activeVersionPrefix", { ns: "config" })}
|
|
||||||
{activeHead ? (
|
|
||||||
<>
|
|
||||||
v{activeHead.version_no}
|
|
||||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{!isDraft ? (
|
|
||||||
<ConfigVersionToolbarMetaEmphasis>
|
|
||||||
{t("odds.readOnlyHint", { ns: "config" })}
|
|
||||||
</ConfigVersionToolbarMetaEmphasis>
|
|
||||||
) : activeHead ? (
|
|
||||||
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
|
||||||
) : null}
|
|
||||||
</ConfigVersionToolbarMeta>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const rebateField = (
|
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
|
|
||||||
<div className="grid max-w-xs gap-1.5">
|
|
||||||
<Label htmlFor="odds-rebate-rate">{t("odds.rebateRate", { ns: "config" })}</Label>
|
|
||||||
{canEditDraft ? (
|
|
||||||
<Input
|
|
||||||
id="odds-rebate-rate"
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
className="h-9 text-base font-semibold"
|
|
||||||
disabled={saving}
|
|
||||||
value={rebatePercentUi}
|
|
||||||
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
|
||||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ConfigReadonlyValue className="h-9 w-full max-w-xs justify-center text-base font-semibold">
|
|
||||||
{rebatePercentUi}
|
|
||||||
</ConfigReadonlyValue>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
|
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
|
||||||
const row = scopeRows[scope];
|
const row = scopeRows[scope];
|
||||||
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||||
@@ -726,6 +660,26 @@ export function OddsConfigDocScreen({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("odds.rebateRate", { ns: "config" })}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{canEditDraft ? (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="ml-auto h-9 w-full max-w-[9rem] text-base font-semibold"
|
||||||
|
disabled={saving}
|
||||||
|
value={rebatePercentUi}
|
||||||
|
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
||||||
|
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ConfigReadonlyValue className="ml-auto h-9 w-full max-w-[9rem] justify-center text-base font-semibold">
|
||||||
|
{rebatePercentUi}
|
||||||
|
</ConfigReadonlyValue>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
@@ -798,19 +752,7 @@ export function OddsConfigDocScreen({
|
|||||||
/>
|
/>
|
||||||
) : resolvedPlayCode ? (
|
) : resolvedPlayCode ? (
|
||||||
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
|
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
|
||||||
{mergedLayout ? (
|
{mergedLayout ? mergedOddsTable : classicOddsGrid}
|
||||||
<div className="space-y-4">
|
|
||||||
{mergedOddsTable}
|
|
||||||
{rebateField}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{classicOddsGrid}
|
|
||||||
{!embedded ? (
|
|
||||||
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
@@ -889,51 +831,31 @@ export function OddsConfigDocScreen({
|
|||||||
if (embedded && mergedLayout) {
|
if (embedded && mergedLayout) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">{toolbarBlock}</div>
|
{toolbarBlock}
|
||||||
|
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_min(100%,260px)] xl:items-start">
|
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
|
||||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
<OddsConfigPlayNav
|
||||||
<div className="grid gap-0 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
|
catTab={catTab}
|
||||||
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
|
onCatTabChange={setCatTab}
|
||||||
<OddsConfigPlayNav
|
onPlayCodeChange={setPlayCode}
|
||||||
catTab={catTab}
|
types={sortedTypes}
|
||||||
onCatTabChange={setCatTab}
|
resolvedPlayCode={resolvedPlayCode}
|
||||||
onPlayCodeChange={setPlayCode}
|
/>
|
||||||
types={sortedTypes}
|
</aside>
|
||||||
resolvedPlayCode={resolvedPlayCode}
|
<div className="min-w-0">
|
||||||
/>
|
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
|
||||||
</aside>
|
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<OddsConfigSummaryPanel
|
|
||||||
compact
|
|
||||||
detail={resolvedDetail}
|
|
||||||
activeHead={activeHead ?? null}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{dialogs}
|
{dialogs}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Rocket, Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type OddsConfigDraftBarProps = {
|
type OddsConfigDraftBarProps = {
|
||||||
isDirty: boolean;
|
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
loadingDetail?: boolean;
|
loadingDetail?: boolean;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -16,7 +15,6 @@ type OddsConfigDraftBarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function OddsConfigDraftBar({
|
export function OddsConfigDraftBar({
|
||||||
isDirty,
|
|
||||||
saving,
|
saving,
|
||||||
loadingDetail = false,
|
loadingDetail = false,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -29,23 +27,17 @@ export function OddsConfigDraftBar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"sticky bottom-0 z-10 flex flex-col gap-3 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-5",
|
"sticky bottom-0 z-10 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 bg-card/95 px-4 py-2.5 backdrop-blur sm:px-5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-muted-foreground">
|
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
|
||||||
{isDirty ? t("odds.draftBar.unsaved") : t("odds.draftBar.saved")}
|
<Save className="size-3.5" aria-hidden />
|
||||||
</p>
|
{t("versionActions.saveDraft")}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</Button>
|
||||||
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
|
<Button type="button" size="sm" disabled={busy} onClick={onPublish}>
|
||||||
<Save className="size-3.5" aria-hidden />
|
{t("versionActions.publishCurrent")}
|
||||||
{t("versionActions.saveDraft")}
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" disabled={busy} onClick={onPublish}>
|
|
||||||
<Rocket className="size-3.5" aria-hidden />
|
|
||||||
{t("versionActions.publishCurrent")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ export function OddsConfigPlayNav({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors",
|
"w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors",
|
||||||
active
|
active
|
||||||
? "bg-primary font-medium text-primary-foreground shadow-sm"
|
? "bg-muted font-medium text-foreground"
|
||||||
: "text-foreground hover:bg-muted/80",
|
: "text-foreground hover:bg-muted/60",
|
||||||
)}
|
)}
|
||||||
onClick={() => onPlayCodeChange(type.play_code)}
|
onClick={() => onPlayCodeChange(type.play_code)}
|
||||||
aria-current={active ? "true" : undefined}
|
aria-current={active ? "true" : undefined}
|
||||||
|
|||||||
@@ -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 { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { ConfigChipGroup } from "@/modules/config/config-chip-group";
|
import { ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
import {
|
|
||||||
ConfigVersionToolbarMeta,
|
|
||||||
ConfigVersionToolbarMetaEmphasis,
|
|
||||||
} from "@/modules/config/config-version-toolbar-meta";
|
|
||||||
import { ConfigSection } from "@/modules/config/config-section";
|
|
||||||
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -52,7 +48,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
|||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
@@ -152,7 +148,6 @@ export function PlayConfigDocScreen() {
|
|||||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
|
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
|
||||||
@@ -429,8 +424,6 @@ export function PlayConfigDocScreen() {
|
|||||||
return <span>{name || row.play_code}</span>;
|
return <span>{name || row.play_code}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeHead = list.find((x) => x.status === "active");
|
|
||||||
|
|
||||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||||
try {
|
try {
|
||||||
await deletePlayConfigVersion(row.id);
|
await deletePlayConfigVersion(row.id);
|
||||||
@@ -488,7 +481,7 @@ export function PlayConfigDocScreen() {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelectedIdChange={setSelectedId}
|
onSelectedIdChange={setSelectedId}
|
||||||
loading={loadingList}
|
loading={loadingList}
|
||||||
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
|
||||||
onDeleteVersion={handleDeleteVersion}
|
onDeleteVersion={handleDeleteVersion}
|
||||||
onRollbackVersion={requestRollback}
|
onRollbackVersion={requestRollback}
|
||||||
rollbackBusy={saving}
|
rollbackBusy={saving}
|
||||||
@@ -515,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 ? (
|
{detail ? (
|
||||||
<ConfigSection
|
<div className="space-y-3">
|
||||||
title={t("play.filters.sectionTitle", { ns: "config" })}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
description={isDraft ? t("play.filters.sectionDescription", { ns: "config" }) : undefined}
|
<Input
|
||||||
>
|
value={keyword}
|
||||||
{!isDraft ? (
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
|
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
|
||||||
{t("play.readOnlyDraftHint", { ns: "config" })}
|
className="h-8 w-full min-w-[12rem] max-w-xs"
|
||||||
</div>
|
/>
|
||||||
) : null}
|
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
<SelectTrigger className="h-8 w-[8.5rem]">
|
||||||
<div className="flex flex-1 flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
|
<SelectValue>
|
||||||
<div className="flex min-w-0 flex-col gap-1.5 md:w-[320px]">
|
{categoryFilter === "all"
|
||||||
<span className="text-sm font-medium">{t("play.filters.keyword", { ns: "config" })}</span>
|
? t("play.filters.allCategories", { ns: "config" })
|
||||||
<Input
|
: categoryLabel(categoryFilter)}
|
||||||
value={keyword}
|
</SelectValue>
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
</SelectTrigger>
|
||||||
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
|
<SelectContent>
|
||||||
className="h-8"
|
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
|
||||||
/>
|
{categoryOptions.map((category) => (
|
||||||
</div>
|
<SelectItem key={category} value={category}>
|
||||||
<div className="flex flex-col gap-1.5 md:w-[140px]">
|
{categoryLabel(category)}
|
||||||
<span className="text-sm font-medium">{t("play.filters.category", { ns: "config" })}</span>
|
</SelectItem>
|
||||||
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
|
))}
|
||||||
<SelectTrigger className="h-8">
|
<SelectItem value="uncategorized">
|
||||||
<SelectValue>
|
{t("play.filters.uncategorized", { ns: "config" })}
|
||||||
{categoryFilter === "all"
|
</SelectItem>
|
||||||
? t("play.filters.allCategories", { ns: "config" })
|
</SelectContent>
|
||||||
: categoryLabel(categoryFilter)}
|
</Select>
|
||||||
</SelectValue>
|
<Select
|
||||||
</SelectTrigger>
|
value={statusFilter}
|
||||||
<SelectContent>
|
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
||||||
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
|
>
|
||||||
{categoryOptions.map((category) => (
|
<SelectTrigger className="h-8 w-[6.5rem]">
|
||||||
<SelectItem key={category} value={category}>
|
<SelectValue>
|
||||||
{categoryLabel(category)}
|
{statusFilter === "all"
|
||||||
</SelectItem>
|
? t("play.filters.allStatuses", { ns: "config" })
|
||||||
))}
|
: statusFilter === "enabled"
|
||||||
<SelectItem value="uncategorized">
|
? t("play.states.enabled", { ns: "config" })
|
||||||
{t("play.filters.uncategorized", { ns: "config" })}
|
: t("play.states.disabled", { ns: "config" })}
|
||||||
</SelectItem>
|
</SelectValue>
|
||||||
</SelectContent>
|
</SelectTrigger>
|
||||||
</Select>
|
<SelectContent>
|
||||||
</div>
|
<SelectItem value="all">{t("play.filters.allStatuses", { ns: "config" })}</SelectItem>
|
||||||
<div className="flex flex-col gap-1.5 md:w-[140px]">
|
<SelectItem value="enabled">{t("play.states.enabled", { ns: "config" })}</SelectItem>
|
||||||
<span className="text-sm font-medium">{t("play.filters.status", { ns: "config" })}</span>
|
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
|
||||||
<Select
|
</SelectContent>
|
||||||
value={statusFilter}
|
</Select>
|
||||||
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
|
{(keyword || categoryFilter !== "all" || statusFilter !== "all") ? (
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
className="h-8 px-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setKeyword("");
|
setKeyword("");
|
||||||
setCategoryFilter("all");
|
setCategoryFilter("all");
|
||||||
@@ -617,76 +573,52 @@ export function PlayConfigDocScreen() {
|
|||||||
>
|
>
|
||||||
{t("play.filters.reset", { ns: "config" })}
|
{t("play.filters.reset", { ns: "config" })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
<ConfigChipGroup label={t("play.batchSwitchesTitle", { ns: "config" })}>
|
||||||
<div className="text-xs font-medium text-muted-foreground">
|
{batchSwitchStates.map((group) => {
|
||||||
{t("play.batchSwitchesTitle", { ns: "config" })}
|
const groupOn = group.allEnabled;
|
||||||
</div>
|
const isPartial =
|
||||||
<ConfigChipGroup>
|
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||||
{batchSwitchStates.map((group) => {
|
return (
|
||||||
const groupOn = group.allEnabled;
|
<label
|
||||||
const isPartial =
|
key={group.key}
|
||||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm"
|
||||||
return (
|
>
|
||||||
<div
|
<span>{group.label}</span>
|
||||||
key={group.key}
|
<Checkbox
|
||||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-card px-3 py-2"
|
checked={groupOn}
|
||||||
>
|
indeterminate={isPartial}
|
||||||
<div className="min-w-0">
|
disabled={saving || group.total === 0 || confirmBusy}
|
||||||
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
aria-label={t("play.aria.batchGroupSwitch", {
|
||||||
<p className="text-xs text-muted-foreground">
|
ns: "config",
|
||||||
{group.total > 0
|
group: group.label,
|
||||||
? isPartial
|
})}
|
||||||
? t("play.batchPartialEnabled", {
|
onCheckedChange={(checked) => {
|
||||||
ns: "config",
|
const enable = checked === true;
|
||||||
enabledCount: group.enabledCount,
|
const action = enable
|
||||||
total: group.total,
|
? t("play.batchSwitchEnable", { ns: "config" })
|
||||||
})
|
: t("play.batchSwitchDisable", { ns: "config" });
|
||||||
: t("play.batchEnabledCount", {
|
requestConfirm({
|
||||||
ns: "config",
|
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
||||||
enabledCount: group.enabledCount,
|
description: t("play.batchSwitchConfirmDescription", {
|
||||||
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", {
|
|
||||||
ns: "config",
|
ns: "config",
|
||||||
|
action,
|
||||||
group: group.label,
|
group: group.label,
|
||||||
})}
|
count: group.total,
|
||||||
onCheckedChange={(checked) => {
|
}),
|
||||||
const enable = checked === true;
|
confirmVariant: enable ? "default" : "destructive",
|
||||||
const action = enable
|
onConfirm: () => applyBatchSwitch(group, enable),
|
||||||
? t("play.batchSwitchEnable", { ns: "config" })
|
});
|
||||||
: t("play.batchSwitchDisable", { ns: "config" });
|
}}
|
||||||
requestConfirm({
|
/>
|
||||||
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
|
</label>
|
||||||
description: t("play.batchSwitchConfirmDescription", {
|
);
|
||||||
ns: "config",
|
})}
|
||||||
action,
|
</ConfigChipGroup>
|
||||||
group: group.label,
|
|
||||||
count: group.total,
|
|
||||||
}),
|
|
||||||
confirmVariant: enable ? "default" : "destructive",
|
|
||||||
onConfirm: () => applyBatchSwitch(group, enable),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ConfigChipGroup>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</ConfigSection>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|||||||
@@ -481,8 +481,7 @@ export function RebateConfigDocScreen({
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelectedIdChange={setSelectedId}
|
onSelectedIdChange={setSelectedId}
|
||||||
loading={resolvedLoading}
|
loading={resolvedLoading}
|
||||||
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
|
||||||
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
|
|
||||||
onDeleteVersion={handleDeleteVersion}
|
onDeleteVersion={handleDeleteVersion}
|
||||||
onRollbackVersion={requestRollback}
|
onRollbackVersion={requestRollback}
|
||||||
rollbackBusy={saving}
|
rollbackBusy={saving}
|
||||||
|
|||||||
@@ -15,14 +15,11 @@ import {
|
|||||||
putRiskCapItems,
|
putRiskCapItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
import { getAdminDraws } from "@/api/admin-draws";
|
import { getAdminDraws } from "@/api/admin-draws";
|
||||||
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
import {
|
import { ConfigVersionToolbarMeta } from "@/modules/config/config-version-toolbar-meta";
|
||||||
ConfigVersionToolbarMeta,
|
|
||||||
ConfigVersionToolbarMetaEmphasis,
|
|
||||||
} from "@/modules/config/config-version-toolbar-meta";
|
|
||||||
import { ConfigSection } from "@/modules/config/config-section";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -35,7 +32,6 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
|
||||||
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -44,13 +40,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
@@ -63,9 +53,7 @@ import { PRD_RISK_CAP_MANAGE } from "@/lib/admin-prd";
|
|||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
||||||
import type {
|
import type { AdminDrawListItem } from "@/types/api/admin-draws";
|
||||||
AdminDrawListItem,
|
|
||||||
} from "@/types/api/admin-draws";
|
|
||||||
import type {
|
import type {
|
||||||
ConfigVersionSummary,
|
ConfigVersionSummary,
|
||||||
RiskCapItemRow,
|
RiskCapItemRow,
|
||||||
@@ -102,6 +90,14 @@ function formatMinorToEditableMajor(minor: number, currencyCode: string): string
|
|||||||
return formatAdminMinorDecimal(minor, currencyCode).replace(/,/g, "");
|
return formatAdminMinorDecimal(minor, currencyCode).replace(/,/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncDefaultCapInput(rows: DraftRiskRow[], currencyCode: string): string {
|
||||||
|
const defaultRow = rows.find(isDefaultRiskRow);
|
||||||
|
if (!defaultRow) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return formatMinorToEditableMajor(defaultRow.cap_amount, currencyCode);
|
||||||
|
}
|
||||||
|
|
||||||
export function RiskCapDocScreen() {
|
export function RiskCapDocScreen() {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
const tRef = useTranslationRef(["config", "common"]);
|
const tRef = useTranslationRef(["config", "common"]);
|
||||||
@@ -113,16 +109,15 @@ export function RiskCapDocScreen() {
|
|||||||
const [selectedId, setSelectedId] = useState("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
const [detail, setDetail] = useState<RiskCapVersionDetail | null>(null);
|
const [detail, setDetail] = useState<RiskCapVersionDetail | null>(null);
|
||||||
const [draftRows, setDraftRows] = useState<DraftRiskRow[]>([]);
|
const [draftRows, setDraftRows] = useState<DraftRiskRow[]>([]);
|
||||||
|
const [defaultCapInput, setDefaultCapInput] = useState("");
|
||||||
const [loadingList, setLoadingList] = useState(true);
|
const [loadingList, setLoadingList] = useState(true);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [drawOptions, setDrawOptions] = useState<AdminDrawListItem[]>([]);
|
const [drawOptions, setDrawOptions] = useState<AdminDrawListItem[]>([]);
|
||||||
|
|
||||||
const [defaultCapStr, setDefaultCapStr] = useState("");
|
|
||||||
const [syncOpen, setSyncOpen] = useState(false);
|
|
||||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||||
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
||||||
|
const [showNumberCaps, setShowNumberCaps] = useState(false);
|
||||||
|
|
||||||
const amountCurrencyCode = "NPR";
|
const amountCurrencyCode = "NPR";
|
||||||
|
|
||||||
@@ -159,40 +154,49 @@ export function RiskCapDocScreen() {
|
|||||||
void loadDrawOptions();
|
void loadDrawOptions();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
const applyDefaultCapInput = useCallback(
|
||||||
const defaultRow = rows.find(isDefaultRiskRow);
|
(value: string) =>
|
||||||
if (!defaultRow) {
|
(prev: DraftRiskRow[]): DraftRiskRow[] => {
|
||||||
setDefaultCapStr("");
|
const n = parseAdminMajorToMinor(value, amountCurrencyCode);
|
||||||
return;
|
const rest = prev.filter((row) => !isDefaultRiskRow(row));
|
||||||
}
|
if (n != null && n > 0) {
|
||||||
setDefaultCapStr(formatMinorToEditableMajor(defaultRow.cap_amount, amountCurrencyCode));
|
return [defaultRiskRowFromAmount(n), ...rest];
|
||||||
}
|
}
|
||||||
|
return rest;
|
||||||
|
},
|
||||||
|
[amountCurrencyCode],
|
||||||
|
);
|
||||||
|
|
||||||
const loadDetail = useCallback(async (id: number) => {
|
const loadDetail = useCallback(
|
||||||
setLoadingDetail(true);
|
async (id: number) => {
|
||||||
try {
|
setLoadingDetail(true);
|
||||||
const d = await getRiskCapVersion(id);
|
try {
|
||||||
setDetail(d);
|
const d = await getRiskCapVersion(id);
|
||||||
const mapped = d.items.map((it) => ({
|
setDetail(d);
|
||||||
clientKey: `srv-${it.id}`,
|
const mapped = d.items.map((it) => ({
|
||||||
draw_id: it.draw_id,
|
clientKey: `srv-${it.id}`,
|
||||||
normalized_number: it.normalized_number,
|
draw_id: it.draw_id,
|
||||||
cap_amount: it.cap_amount,
|
normalized_number: it.normalized_number,
|
||||||
cap_type: it.cap_type,
|
cap_amount: it.cap_amount,
|
||||||
}));
|
cap_type: it.cap_type,
|
||||||
setDraftRows(mapped);
|
}));
|
||||||
syncDefaultCapFromRows(mapped);
|
setDraftRows(mapped);
|
||||||
} catch (e) {
|
setDefaultCapInput(syncDefaultCapInput(mapped, amountCurrencyCode));
|
||||||
toast.error(
|
setShowNumberCaps(mapped.some((row) => !isDefaultRiskRow(row)));
|
||||||
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
} catch (e) {
|
||||||
);
|
toast.error(
|
||||||
setDetail(null);
|
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
|
||||||
setDraftRows([]);
|
);
|
||||||
syncDefaultCapFromRows([]);
|
setDetail(null);
|
||||||
} finally {
|
setDraftRows([]);
|
||||||
setLoadingDetail(false);
|
setDefaultCapInput("");
|
||||||
}
|
setShowNumberCaps(false);
|
||||||
}, []);
|
} finally {
|
||||||
|
setLoadingDetail(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[amountCurrencyCode, tRef],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
@@ -201,7 +205,7 @@ export function RiskCapDocScreen() {
|
|||||||
setSelectedId("");
|
setSelectedId("");
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setDraftRows([]);
|
setDraftRows([]);
|
||||||
syncDefaultCapFromRows([]);
|
setDefaultCapInput("");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -251,11 +255,13 @@ export function RiskCapDocScreen() {
|
|||||||
if (!detail || !canEditDraft) {
|
if (!detail || !canEditDraft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draftRows.length === 0) {
|
const rowsToSave = applyDefaultCapInput(defaultCapInput)(draftRows);
|
||||||
|
setDraftRows(rowsToSave);
|
||||||
|
if (rowsToSave.length === 0) {
|
||||||
toast.error(t("riskCap.validation.requireAtLeastOne", { ns: "config" }));
|
toast.error(t("riskCap.validation.requireAtLeastOne", { ns: "config" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const r of draftRows) {
|
for (const r of rowsToSave) {
|
||||||
if (isDefaultRiskRow(r)) {
|
if (isDefaultRiskRow(r)) {
|
||||||
if (r.cap_amount <= 0) {
|
if (r.cap_amount <= 0) {
|
||||||
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
|
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
|
||||||
@@ -278,7 +284,7 @@ export function RiskCapDocScreen() {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = draftRows.map((r) => ({
|
const payload = rowsToSave.map((r) => ({
|
||||||
draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null,
|
draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null,
|
||||||
normalized_number: r.normalized_number,
|
normalized_number: r.normalized_number,
|
||||||
cap_amount: r.cap_amount,
|
cap_amount: r.cap_amount,
|
||||||
@@ -294,7 +300,7 @@ export function RiskCapDocScreen() {
|
|||||||
cap_type: it.cap_type,
|
cap_type: it.cap_type,
|
||||||
}));
|
}));
|
||||||
setDraftRows(saved);
|
setDraftRows(saved);
|
||||||
syncDefaultCapFromRows(saved);
|
setDefaultCapInput(syncDefaultCapInput(saved, amountCurrencyCode));
|
||||||
toast.success(t("versionActions.saveDraft", { ns: "config" }));
|
toast.success(t("versionActions.saveDraft", { ns: "config" }));
|
||||||
void refreshList();
|
void refreshList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -320,7 +326,7 @@ export function RiskCapDocScreen() {
|
|||||||
cap_type: it.cap_type,
|
cap_type: it.cap_type,
|
||||||
}));
|
}));
|
||||||
setDraftRows(pub);
|
setDraftRows(pub);
|
||||||
syncDefaultCapFromRows(pub);
|
setDefaultCapInput(syncDefaultCapInput(pub, amountCurrencyCode));
|
||||||
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
|
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
|
||||||
void refreshList();
|
void refreshList();
|
||||||
setSelectedId(String(d.id));
|
setSelectedId(String(d.id));
|
||||||
@@ -351,7 +357,7 @@ export function RiskCapDocScreen() {
|
|||||||
cap_type: it.cap_type,
|
cap_type: it.cap_type,
|
||||||
}));
|
}));
|
||||||
setDraftRows(nd);
|
setDraftRows(nd);
|
||||||
syncDefaultCapFromRows(nd);
|
setDefaultCapInput(syncDefaultCapInput(nd, amountCurrencyCode));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.createDraftFailed", { ns: "config" }));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.createDraftFailed", { ns: "config" }));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -359,37 +365,25 @@ export function RiskCapDocScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDefaultCap() {
|
|
||||||
const n = parseAdminMajorToMinor(defaultCapStr, amountCurrencyCode);
|
|
||||||
if (n == null || !Number.isFinite(n) || n <= 0) {
|
|
||||||
toast.error(t("riskCap.validation.enterValidCapAmount", { ns: "config" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraftRows((prev) => {
|
|
||||||
const next = prev.filter((row) => !isDefaultRiskRow(row));
|
|
||||||
return [defaultRiskRowFromAmount(n), ...next];
|
|
||||||
});
|
|
||||||
setSyncOpen(false);
|
|
||||||
toast.message(t("riskCap.savedLocalDraft", { ns: "config" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const specialRows = useMemo(
|
const specialRows = useMemo(
|
||||||
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
|
() =>
|
||||||
|
draftRows
|
||||||
|
.map((row, index) => ({ row, index }))
|
||||||
|
.filter(({ row }) => !isDefaultRiskRow(row) && row.draw_id == null),
|
||||||
[draftRows],
|
[draftRows],
|
||||||
);
|
);
|
||||||
const globalRows = useMemo(
|
|
||||||
() => specialRows.filter(({ row }) => row.draw_id == null),
|
const drawBoundRows = useMemo(
|
||||||
[specialRows],
|
() => draftRows.filter((row) => !isDefaultRiskRow(row) && row.draw_id != null),
|
||||||
);
|
[draftRows],
|
||||||
const drawRows = useMemo(
|
|
||||||
() => specialRows.filter(({ row }) => row.draw_id != null),
|
|
||||||
[specialRows],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function drawLabel(drawId: number): string {
|
||||||
|
return drawOptions.find((draw) => draw.id === drawId)?.draw_no ?? String(drawId);
|
||||||
|
}
|
||||||
|
|
||||||
const defaultCapDisplay = detail
|
const defaultCapDisplay = detail
|
||||||
? formatAdminMinorDecimal(
|
? formatAdminMinorDecimal(draftRows.find(isDefaultRiskRow)?.cap_amount ?? 0, amountCurrencyCode)
|
||||||
draftRows.find(isDefaultRiskRow)?.cap_amount ?? 0,
|
|
||||||
amountCurrencyCode,
|
|
||||||
)
|
|
||||||
: formatAdminMinorDecimal(0, amountCurrencyCode);
|
: formatAdminMinorDecimal(0, amountCurrencyCode);
|
||||||
|
|
||||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||||
@@ -436,7 +430,7 @@ export function RiskCapDocScreen() {
|
|||||||
cap_type: it.cap_type,
|
cap_type: it.cap_type,
|
||||||
}));
|
}));
|
||||||
setDraftRows(mapped);
|
setDraftRows(mapped);
|
||||||
syncDefaultCapFromRows(mapped);
|
setDefaultCapInput(syncDefaultCapInput(mapped, amountCurrencyCode));
|
||||||
setRollbackOpen(false);
|
setRollbackOpen(false);
|
||||||
setRollbackTarget(null);
|
setRollbackTarget(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -458,7 +452,7 @@ export function RiskCapDocScreen() {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelectedIdChange={setSelectedId}
|
onSelectedIdChange={setSelectedId}
|
||||||
loading={loadingList}
|
loading={loadingList}
|
||||||
sheetTitle={`${t("nav.items.risk-cap", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
|
||||||
onDeleteVersion={handleDeleteVersion}
|
onDeleteVersion={handleDeleteVersion}
|
||||||
onRollbackVersion={requestRollback}
|
onRollbackVersion={requestRollback}
|
||||||
rollbackBusy={saving}
|
rollbackBusy={saving}
|
||||||
@@ -486,276 +480,191 @@ export function RiskCapDocScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
detail ? (
|
detail?.effective_at ? (
|
||||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
<ConfigVersionToolbarMeta>
|
||||||
<span>
|
<span>
|
||||||
{t("riskCap.effectiveAt", {
|
{t("riskCap.effectiveAt", {
|
||||||
ns: "config",
|
ns: "config",
|
||||||
value: detail.effective_at ? formatDt(detail.effective_at) : "—",
|
value: formatDt(detail.effective_at),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{!isDraft ? (
|
|
||||||
<ConfigVersionToolbarMetaEmphasis>
|
|
||||||
{t("riskCap.readOnlyHint", { ns: "config" })}
|
|
||||||
</ConfigVersionToolbarMetaEmphasis>
|
|
||||||
) : (
|
|
||||||
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
|
||||||
)}
|
|
||||||
</ConfigVersionToolbarMeta>
|
</ConfigVersionToolbarMeta>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
contentClassName="space-y-8"
|
contentClassName="space-y-6"
|
||||||
>
|
>
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<AdminPageCard title={t("riskCap.defaultCap.title", { ns: "config" })}>
|
||||||
{[
|
<div className="max-w-xs space-y-2">
|
||||||
{
|
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
|
||||||
key: "default",
|
{canEditDraft ? (
|
||||||
label: t("riskCap.summary.defaultCap", { ns: "config" }),
|
<Input
|
||||||
value: defaultCapDisplay,
|
id="default-cap"
|
||||||
hint: t("riskCap.summary.defaultHint", { ns: "config" }),
|
type="text"
|
||||||
},
|
inputMode="decimal"
|
||||||
{
|
className="font-semibold tabular-nums"
|
||||||
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"
|
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
|
value={defaultCapInput}
|
||||||
>
|
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
|
||||||
{t("riskCap.actions.addSpecialCap", { ns: "config" })}
|
onChange={(e) => setDefaultCapInput(e.target.value)}
|
||||||
</Button>
|
onBlur={() => setDraftRows(applyDefaultCapInput(defaultCapInput))}
|
||||||
) : null
|
/>
|
||||||
}
|
) : (
|
||||||
>
|
<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 ? (
|
{loadingDetail ? (
|
||||||
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
|
<AdminLoadingState
|
||||||
) : specialRows.length === 0 ? (
|
minHeight="6rem"
|
||||||
|
className="py-4"
|
||||||
|
label={t("riskCap.loadingDetails", { ns: "config" })}
|
||||||
|
/>
|
||||||
|
) : specialRows.length === 0 && drawBoundRows.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[
|
{specialRows.length > 0 ? (
|
||||||
{
|
<div className="admin-table-shell">
|
||||||
key: "global",
|
<Table>
|
||||||
title: t("riskCap.groups.globalTitle", { ns: "config" }),
|
<TableHeader>
|
||||||
description: t("riskCap.groups.globalDescription", { ns: "config" }),
|
<TableRow>
|
||||||
rows: globalRows,
|
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||||
emptyText: t("riskCap.groups.globalEmpty", { ns: "config" }),
|
<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" })}
|
||||||
key: "draw",
|
</TableHead>
|
||||||
title: t("riskCap.groups.drawTitle", { ns: "config" }),
|
</TableRow>
|
||||||
description: t("riskCap.groups.drawDescription", { ns: "config" }),
|
</TableHeader>
|
||||||
rows: drawRows,
|
<TableBody>
|
||||||
emptyText: t("riskCap.groups.drawEmpty", { ns: "config" }),
|
{specialRows.map(({ row: r, index: idx }) => (
|
||||||
},
|
<TableRow key={r.clientKey}>
|
||||||
].map((group) => (
|
<TableCell>
|
||||||
<div key={group.key} className="rounded-xl border border-border/60 bg-muted/10 p-3">
|
{canEditDraft ? (
|
||||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
<Input
|
||||||
<div className="space-y-1">
|
className="h-8 font-mono tabular-nums"
|
||||||
<h3 className="text-sm font-semibold text-foreground">{group.title}</h3>
|
maxLength={4}
|
||||||
<p className="text-xs leading-5 text-muted-foreground">{group.description}</p>
|
disabled={saving}
|
||||||
</div>
|
value={r.normalized_number}
|
||||||
<span className="rounded-full bg-background px-2 py-1 text-xs text-muted-foreground">
|
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
||||||
{t("riskCap.groups.count", { ns: "config", count: group.rows.length })}
|
onChange={(e) =>
|
||||||
</span>
|
updateRow(idx, {
|
||||||
</div>
|
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||||
|
})
|
||||||
{group.rows.length === 0 ? (
|
}
|
||||||
<p className="text-sm text-muted-foreground">{group.emptyText}</p>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
|
||||||
<TableHeader>
|
)}
|
||||||
<TableRow>
|
</TableCell>
|
||||||
<TableHead className="w-[180px]">{t("riskCap.table.scope", { ns: "config" })}</TableHead>
|
<TableCell>
|
||||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
{canEditDraft ? (
|
||||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
<Input
|
||||||
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
type="text"
|
||||||
{t("riskCap.table.actions", { ns: "config" })}
|
inputMode="decimal"
|
||||||
</TableHead>
|
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>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{group.rows.map(({ row: r, index: idx }) => (
|
</Table>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
|
|
||||||
|
{drawBoundRows.length > 0 ? (
|
||||||
|
<div className="admin-table-shell">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("riskCap.table.draw", { ns: "config" })}</TableHead>
|
||||||
|
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||||
|
<TableHead>{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{drawBoundRows.map((row) => (
|
||||||
|
<TableRow key={row.clientKey}>
|
||||||
|
<TableCell>{drawLabel(row.draw_id ?? 0)}</TableCell>
|
||||||
|
<TableCell className="font-mono">{row.normalized_number}</TableCell>
|
||||||
|
<TableCell>{formatAdminMinorDecimal(row.cap_amount, amountCurrencyCode)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ConfigSection>
|
</AdminPageCard>
|
||||||
|
) : null}
|
||||||
<RiskCapRuntimePanel />
|
|
||||||
|
|
||||||
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
|
|
||||||
<DialogContent showCloseButton className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("riskCap.syncDialog.title", { ns: "config" })}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("riskCap.syncDialog.description", { ns: "config", value: defaultCapStr || "(empty)" })}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
|
|
||||||
{t("actions.cancel", { ns: "adminUsers" })}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={applyDefaultCap}>
|
|
||||||
{t("riskCap.syncDialog.confirm", { ns: "config" })}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
||||||
<DialogContent showCloseButton className="sm:max-w-md">
|
<DialogContent showCloseButton className="sm:max-w-md">
|
||||||
|
|||||||
@@ -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 { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
|
import {
|
||||||
|
BarChart3,
|
||||||
|
ClipboardList,
|
||||||
|
Network,
|
||||||
|
RefreshCw,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Wallet,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||||
import { normalizeAdminLanguage } from "@/i18n";
|
import { normalizeAdminLanguage } from "@/i18n";
|
||||||
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -22,20 +29,20 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
|
||||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
|
||||||
import {
|
import {
|
||||||
DashboardKpiCard,
|
DashboardKpiCard,
|
||||||
|
DashboardQuickLinksCard,
|
||||||
|
DashboardReportsTeaser,
|
||||||
DashboardScopeMetric,
|
DashboardScopeMetric,
|
||||||
DashboardSignedStatRow,
|
DashboardSignedStatRow,
|
||||||
DashboardStatRow,
|
DashboardStatRow,
|
||||||
} from "@/modules/dashboard/dashboard-visuals";
|
} from "@/modules/dashboard/dashboard-visuals";
|
||||||
|
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
|
||||||
import {
|
import {
|
||||||
formatDashboardCreditMajor,
|
formatDashboardCreditMajor,
|
||||||
formatDashboardMoneyMinor,
|
formatDashboardMoneyMinor,
|
||||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||||
import type { AdminDashboardAgentOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
|
import type { AdminDashboardAgentOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
|
||||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
function buildTodayBetHint(
|
function buildTodayBetHint(
|
||||||
@@ -52,6 +59,13 @@ function buildTodayBetHint(
|
|||||||
return `${dateHint} · ${t("agent.noBetToday")}`;
|
return `${dateHint} · ${t("agent.noBetToday")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentLineHref(agentNodeId: number | null | undefined): string {
|
||||||
|
if (agentNodeId != null && agentNodeId > 0) {
|
||||||
|
return `/admin/agents?agent_node_id=${agentNodeId}`;
|
||||||
|
}
|
||||||
|
return "/admin/agents";
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentDashboardConsole(): ReactElement {
|
export function AgentDashboardConsole(): ReactElement {
|
||||||
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
|
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
|
||||||
const tRef = useTranslationRef(["dashboard", "common"]);
|
const tRef = useTranslationRef(["dashboard", "common"]);
|
||||||
@@ -69,25 +83,33 @@ export function AgentDashboardConsole(): ReactElement {
|
|||||||
}, [i18n.language, i18n.resolvedLanguage, t]);
|
}, [i18n.language, i18n.resolvedLanguage, t]);
|
||||||
|
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const playOptions = useCachedPlayTypeOptions();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
|
||||||
const [drawId, setDrawId] = useState<number | null>(null);
|
|
||||||
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
|
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
|
||||||
|
|
||||||
const analyticsScope = useMemo(
|
const canReports = adminHasAnyPermission(permissions, [PRD_REPORT_VIEW]);
|
||||||
() => ({
|
|
||||||
siteCode: agent?.site_code ?? "",
|
|
||||||
agentNodeId: agent?.id,
|
|
||||||
}),
|
|
||||||
[agent?.id, agent?.site_code],
|
|
||||||
);
|
|
||||||
|
|
||||||
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
|
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
|
||||||
|
const lineHref = agentLineHref(agent?.id);
|
||||||
|
|
||||||
|
const quickLinks = useMemo(
|
||||||
|
() => [
|
||||||
|
{ href: "/admin/tickets", label: t("agent.quickLinks.tickets"), icon: ClipboardList },
|
||||||
|
{ href: lineHref, label: t("agent.quickLinks.players"), icon: Users },
|
||||||
|
{ href: lineHref, label: t("agent.quickLinks.agents"), icon: Network },
|
||||||
|
{
|
||||||
|
href: settlementCenterScopeHref(undefined, "awaiting_payment"),
|
||||||
|
label: t("agent.quickLinks.bills"),
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
|
...(canReports
|
||||||
|
? [{ href: "/admin/reports", label: t("agent.quickLinks.reports"), icon: BarChart3 }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
[canReports, lineHref, t],
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(async (isRefresh = false) => {
|
const load = useCallback(async (isRefresh = false) => {
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
@@ -99,14 +121,8 @@ export function AgentDashboardConsole(): ReactElement {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const d = await getAdminDashboard();
|
const d = await getAdminDashboard();
|
||||||
setHall(d.hall);
|
|
||||||
setOverview(d.agent_overview);
|
setOverview(d.agent_overview);
|
||||||
setApiWarnings(d.warnings ?? []);
|
setApiWarnings(d.warnings ?? []);
|
||||||
if (d.resolved_draw != null) {
|
|
||||||
setDrawId(d.resolved_draw.id);
|
|
||||||
} else {
|
|
||||||
setDrawId(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||||
@@ -175,6 +191,7 @@ export function AgentDashboardConsole(): ReactElement {
|
|||||||
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
||||||
icon={<TrendingUp className="size-4" />}
|
icon={<TrendingUp className="size-4" />}
|
||||||
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
|
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
|
||||||
|
href="/admin/tickets"
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("agent.todayShareProfit")}
|
label={t("agent.todayShareProfit")}
|
||||||
@@ -184,12 +201,14 @@ export function AgentDashboardConsole(): ReactElement {
|
|||||||
hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
|
hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
|
||||||
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
|
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
|
||||||
})}`}
|
})}`}
|
||||||
|
href={canReports ? "/admin/reports" : undefined}
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("agent.activePlayersToday")}
|
label={t("agent.activePlayersToday")}
|
||||||
value={overview.active_player_count_today}
|
value={overview.active_player_count_today}
|
||||||
icon={<Users className="size-4" />}
|
icon={<Users className="size-4" />}
|
||||||
hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })}
|
hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })}
|
||||||
|
href={lineHref}
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("agent.pendingBills")}
|
label={t("agent.pendingBills")}
|
||||||
@@ -199,6 +218,7 @@ export function AgentDashboardConsole(): ReactElement {
|
|||||||
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
|
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
|
||||||
})}
|
})}
|
||||||
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
|
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
|
||||||
|
href={settlementCenterScopeHref(undefined, "awaiting_payment")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -289,6 +309,8 @@ export function AgentDashboardConsole(): ReactElement {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||||
@@ -296,18 +318,10 @@ export function AgentDashboardConsole(): ReactElement {
|
|||||||
</AdminNoResourceState>
|
</AdminNoResourceState>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DashboardCurrentDrawCard
|
|
||||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
|
||||||
hall={hall}
|
|
||||||
drawId={drawId}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{canAnalytics ? (
|
{canAnalytics ? (
|
||||||
<DashboardAnalyticsPanel
|
<DashboardReportsTeaser
|
||||||
enabled={canAnalytics}
|
title={t("analytics.title")}
|
||||||
playOptions={playOptions}
|
description={t("agent.shareProfitScopeHint")}
|
||||||
scope={analyticsScope}
|
|
||||||
/>
|
/>
|
||||||
) : !loading ? (
|
) : !loading ? (
|
||||||
<p className="text-xs text-muted-foreground">{t("warnings.analyticsUnavailable")}</p>
|
<p className="text-xs text-muted-foreground">{t("warnings.analyticsUnavailable")}</p>
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
AdminDashboardDrawPanel,
|
|
||||||
AdminDashboardLifetimeFinance,
|
AdminDashboardLifetimeFinance,
|
||||||
AdminDashboardPlatformRisk,
|
AdminDashboardPlatformRisk,
|
||||||
AdminDashboardResultBatchQueue,
|
AdminDashboardResultBatchQueue,
|
||||||
@@ -172,7 +171,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
|
|
||||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||||
const [drawId, setDrawId] = useState<number | null>(null);
|
const [drawId, setDrawId] = useState<number | null>(null);
|
||||||
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
|
|
||||||
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||||
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
|
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
|
||||||
const [resultBatchQueue, setResultBatchQueue] = useState<AdminDashboardResultBatchQueue | null>(
|
const [resultBatchQueue, setResultBatchQueue] = useState<AdminDashboardResultBatchQueue | null>(
|
||||||
@@ -201,7 +199,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setFinance(null);
|
setFinance(null);
|
||||||
setCapabilities(null);
|
setCapabilities(null);
|
||||||
setDrawPanel(null);
|
|
||||||
setResultBatchQueue(null);
|
setResultBatchQueue(null);
|
||||||
setLifetimeFinance(null);
|
setLifetimeFinance(null);
|
||||||
setTodayFinance(null);
|
setTodayFinance(null);
|
||||||
@@ -230,9 +227,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setTodayFinance(d.today_finance);
|
setTodayFinance(d.today_finance);
|
||||||
setApiWarnings(d.warnings ?? []);
|
setApiWarnings(d.warnings ?? []);
|
||||||
setPlatformRisk(d.platform_risk);
|
setPlatformRisk(d.platform_risk);
|
||||||
if (d.draw != null) {
|
|
||||||
setDrawPanel(d.draw);
|
|
||||||
}
|
|
||||||
if (d.risk != null) {
|
if (d.risk != null) {
|
||||||
setRiskLocked(d.risk.locked_amount);
|
setRiskLocked(d.risk.locked_amount);
|
||||||
setRiskCap(d.risk.cap_amount);
|
setRiskCap(d.risk.cap_amount);
|
||||||
@@ -544,6 +538,23 @@ export function DashboardConsole(): ReactElement {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showAnalytics ? (
|
||||||
|
<Card className="admin-list-card min-w-0 py-0">
|
||||||
|
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||||
|
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-52 w-full" />
|
||||||
|
) : finance ? (
|
||||||
|
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||||
|
) : (
|
||||||
|
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!showAnalytics ? (
|
{!showAnalytics ? (
|
||||||
<Card className="admin-list-card min-w-0 py-0">
|
<Card className="admin-list-card min-w-0 py-0">
|
||||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||||
@@ -565,22 +576,7 @@ export function DashboardConsole(): ReactElement {
|
|||||||
{showAnalytics ? (
|
{showAnalytics ? (
|
||||||
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
||||||
<DashboardPlayRankingCard analytics={analytics} />
|
<DashboardPlayRankingCard analytics={analytics} />
|
||||||
<DashboardAgentRankingCard analytics={analytics} />
|
<DashboardAgentRankingCard analytics={analytics} />
|
||||||
|
|
||||||
<Card className="admin-list-card min-w-0 py-0">
|
|
||||||
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
|
||||||
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 py-4">
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className="h-52 w-full" />
|
|
||||||
) : finance ? (
|
|
||||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
|
||||||
) : (
|
|
||||||
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
</aside>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import Link from "next/link";
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRightIcon,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronRightIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -19,7 +25,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
|
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -268,6 +274,7 @@ export function DashboardKpiCard({
|
|||||||
currencyCode,
|
currencyCode,
|
||||||
sparklineValues,
|
sparklineValues,
|
||||||
deltaLabel,
|
deltaLabel,
|
||||||
|
href,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value?: ReactNode;
|
value?: ReactNode;
|
||||||
@@ -281,6 +288,8 @@ export function DashboardKpiCard({
|
|||||||
currencyCode?: string | null;
|
currencyCode?: string | null;
|
||||||
sparklineValues?: number[];
|
sparklineValues?: number[];
|
||||||
deltaLabel?: ReactNode;
|
deltaLabel?: ReactNode;
|
||||||
|
/** 整张卡片可点击跳转 */
|
||||||
|
href?: string;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const resolvedValue =
|
const resolvedValue =
|
||||||
typeof signedAmountMinor === "number" && currencyCode !== undefined
|
typeof signedAmountMinor === "number" && currencyCode !== undefined
|
||||||
@@ -295,17 +304,30 @@ export function DashboardKpiCard({
|
|||||||
? String(resolvedValue)
|
? String(resolvedValue)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
const card = (
|
||||||
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
|
<div
|
||||||
|
className={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">
|
<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>
|
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
|
||||||
<div
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
className={cn(
|
<div
|
||||||
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4",
|
className={cn(
|
||||||
kpiAccentClass(accent),
|
"flex size-9 items-center justify-center rounded-lg [&_svg]:size-4",
|
||||||
)}
|
kpiAccentClass(accent),
|
||||||
>
|
)}
|
||||||
{icon}
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
{href ? (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className="size-4 text-muted-foreground/50 transition group-hover/kpi:text-primary"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -336,10 +358,126 @@ export function DashboardKpiCard({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{hint ? (
|
{hint ? (
|
||||||
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-2 text-[11px] leading-snug",
|
||||||
|
href ? "text-primary/80 group-hover/kpi:underline" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!href) {
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group/kpi block h-full min-h-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardQuickLink = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 仪表盘快捷入口:按钮或图标卡片两种布局 */
|
||||||
|
export function DashboardQuickLinksCard({
|
||||||
|
title,
|
||||||
|
links,
|
||||||
|
variant = "buttons",
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
links: readonly DashboardQuickLink[];
|
||||||
|
variant?: "buttons" | "tiles";
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
|
||||||
|
if (links.length === 0) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent
|
||||||
|
className={cn(
|
||||||
|
variant === "buttons" ? "flex flex-wrap gap-2" : "grid gap-3 sm:grid-cols-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{links.map((link) => {
|
||||||
|
if (variant === "buttons") {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = link.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="flex flex-col gap-2 rounded-xl border bg-muted/20 px-4 py-4 transition-colors hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
{Icon ? <Icon className="size-5 text-primary" aria-hidden /> : null}
|
||||||
|
<span className="text-sm font-medium">{link.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t("cs.openModule")}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轻量报表入口:替代站点/代理仪表盘内嵌完整 analytics 面板 */
|
||||||
|
export function DashboardReportsTeaser({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
href = "/admin/reports",
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href?: string;
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t("viewReports")}
|
||||||
|
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniSparkline({
|
function MiniSparkline({
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import {
|
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||||
DashboardKpiCard,
|
|
||||||
DashboardScopeMetric,
|
|
||||||
} from "@/modules/dashboard/dashboard-visuals";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import type {
|
import type {
|
||||||
@@ -136,18 +133,21 @@ export function SiteCsDashboardConsole(): ReactElement {
|
|||||||
value={overview.player_count}
|
value={overview.player_count}
|
||||||
icon={<Users className="size-4" />}
|
icon={<Users className="size-4" />}
|
||||||
hint={t("cs.playerCountHint")}
|
hint={t("cs.playerCountHint")}
|
||||||
|
href="/admin/players"
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("cs.ticketsToday")}
|
label={t("cs.ticketsToday")}
|
||||||
value={overview.ticket_order_count_today}
|
value={overview.ticket_order_count_today}
|
||||||
icon={<ClipboardList className="size-4" />}
|
icon={<ClipboardList className="size-4" />}
|
||||||
hint={activityHint}
|
hint={activityHint}
|
||||||
|
href="/admin/tickets"
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("cs.activePlayersToday")}
|
label={t("cs.activePlayersToday")}
|
||||||
value={overview.active_player_count_today}
|
value={overview.active_player_count_today}
|
||||||
icon={<Search className="size-4" />}
|
icon={<Search className="size-4" />}
|
||||||
hint={t("cs.activePlayersHint")}
|
hint={t("cs.activePlayersHint")}
|
||||||
|
href="/admin/tickets"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,19 +172,6 @@ export function SiteCsDashboardConsole(): ReactElement {
|
|||||||
})}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-semibold">{t("cs.scopeTitle")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<DashboardScopeMetric label={t("cs.playerCount")} value={String(overview.player_count)} />
|
|
||||||
<DashboardScopeMetric
|
|
||||||
label={t("cs.ticketsToday")}
|
|
||||||
value={String(overview.ticket_order_count_today)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
|
import {
|
||||||
|
BarChart3,
|
||||||
|
ClipboardList,
|
||||||
|
Network,
|
||||||
|
RefreshCw,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Wallet,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
||||||
import { normalizeAdminLanguage } from "@/i18n";
|
import { normalizeAdminLanguage } from "@/i18n";
|
||||||
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -20,19 +27,19 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
|
||||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
|
||||||
import {
|
import {
|
||||||
DashboardKpiCard,
|
DashboardKpiCard,
|
||||||
|
DashboardQuickLinksCard,
|
||||||
|
DashboardReportsTeaser,
|
||||||
DashboardScopeMetric,
|
DashboardScopeMetric,
|
||||||
DashboardSignedStatRow,
|
DashboardSignedStatRow,
|
||||||
DashboardStatRow,
|
DashboardStatRow,
|
||||||
} from "@/modules/dashboard/dashboard-visuals";
|
} from "@/modules/dashboard/dashboard-visuals";
|
||||||
|
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
|
||||||
import {
|
import {
|
||||||
formatDashboardMoneyMinor,
|
formatDashboardMoneyMinor,
|
||||||
} from "@/modules/dashboard/use-dashboard-analytics";
|
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||||
import type { AdminDashboardSiteOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
|
import type { AdminDashboardSiteOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
|
||||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
function buildTodayBetHint(
|
function buildTodayBetHint(
|
||||||
@@ -65,26 +72,32 @@ export function SiteDashboardConsole(): ReactElement {
|
|||||||
return formatAdminCalendarToday(locale, weekday);
|
return formatAdminCalendarToday(locale, weekday);
|
||||||
}, [i18n.language, i18n.resolvedLanguage, t]);
|
}, [i18n.language, i18n.resolvedLanguage, t]);
|
||||||
|
|
||||||
const playOptions = useCachedPlayTypeOptions();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
|
||||||
const [drawId, setDrawId] = useState<number | null>(null);
|
|
||||||
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
|
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
|
||||||
|
|
||||||
const analyticsScope = useMemo(
|
const canReports = adminHasAnyPermission(permissions, [PRD_REPORT_VIEW]);
|
||||||
() => ({
|
|
||||||
siteCode: site?.code ?? overview?.site_code ?? "",
|
|
||||||
agentNodeId: undefined,
|
|
||||||
}),
|
|
||||||
[overview?.site_code, site?.code],
|
|
||||||
);
|
|
||||||
|
|
||||||
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
|
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
|
||||||
|
|
||||||
|
const quickLinks = useMemo(
|
||||||
|
() => [
|
||||||
|
{ href: "/admin/tickets", label: t("site.quickLinks.tickets"), icon: ClipboardList },
|
||||||
|
{ href: "/admin/players", label: t("site.quickLinks.players"), icon: Users },
|
||||||
|
{ href: "/admin/agents", label: t("site.quickLinks.agents"), icon: Network },
|
||||||
|
{
|
||||||
|
href: settlementCenterScopeHref(site?.id, "awaiting_payment"),
|
||||||
|
label: t("site.quickLinks.bills"),
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
|
...(canReports
|
||||||
|
? [{ href: "/admin/reports", label: t("site.quickLinks.reports"), icon: BarChart3 }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
[canReports, site?.id, t],
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(async (isRefresh = false) => {
|
const load = useCallback(async (isRefresh = false) => {
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
@@ -95,14 +108,8 @@ export function SiteDashboardConsole(): ReactElement {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const d = await getAdminDashboard();
|
const d = await getAdminDashboard();
|
||||||
setHall(d.hall);
|
|
||||||
setOverview(d.site_overview);
|
setOverview(d.site_overview);
|
||||||
setApiWarnings(d.warnings ?? []);
|
setApiWarnings(d.warnings ?? []);
|
||||||
if (d.resolved_draw != null) {
|
|
||||||
setDrawId(d.resolved_draw.id);
|
|
||||||
} else {
|
|
||||||
setDrawId(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||||
@@ -171,6 +178,7 @@ export function SiteDashboardConsole(): ReactElement {
|
|||||||
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
|
||||||
icon={<TrendingUp className="size-4" />}
|
icon={<TrendingUp className="size-4" />}
|
||||||
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
|
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
|
||||||
|
href="/admin/tickets"
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("site.todayProfit")}
|
label={t("site.todayProfit")}
|
||||||
@@ -180,12 +188,14 @@ export function SiteDashboardConsole(): ReactElement {
|
|||||||
hint={t("todayPayoutHint", {
|
hint={t("todayPayoutHint", {
|
||||||
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
|
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
|
||||||
})}
|
})}
|
||||||
|
href={canReports ? "/admin/reports" : undefined}
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("site.activePlayersToday")}
|
label={t("site.activePlayersToday")}
|
||||||
value={overview.active_player_count_today}
|
value={overview.active_player_count_today}
|
||||||
icon={<Users className="size-4" />}
|
icon={<Users className="size-4" />}
|
||||||
hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })}
|
hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })}
|
||||||
|
href="/admin/players"
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("site.pendingBills")}
|
label={t("site.pendingBills")}
|
||||||
@@ -195,6 +205,7 @@ export function SiteDashboardConsole(): ReactElement {
|
|||||||
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
|
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
|
||||||
})}
|
})}
|
||||||
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
|
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
|
||||||
|
href={settlementCenterScopeHref(site?.id, "awaiting_payment")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -245,6 +256,8 @@ export function SiteDashboardConsole(): ReactElement {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||||
@@ -252,18 +265,10 @@ export function SiteDashboardConsole(): ReactElement {
|
|||||||
</AdminNoResourceState>
|
</AdminNoResourceState>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DashboardCurrentDrawCard
|
|
||||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
|
||||||
hall={hall}
|
|
||||||
drawId={drawId}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{canAnalytics ? (
|
{canAnalytics ? (
|
||||||
<DashboardAnalyticsPanel
|
<DashboardReportsTeaser
|
||||||
enabled={canAnalytics}
|
title={t("analytics.title")}
|
||||||
playOptions={playOptions}
|
description={t("site.profitScopeHint")}
|
||||||
scope={analyticsScope}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ import { Button, buttonVariants } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
|
|
||||||
import {
|
import {
|
||||||
AbnormalTransferPanelFooter,
|
AbnormalTransferPanelFooter,
|
||||||
DashboardKpiCard,
|
DashboardKpiCard,
|
||||||
DashboardScopeMetric,
|
DashboardQuickLinksCard,
|
||||||
DashboardStatRow,
|
|
||||||
} from "@/modules/dashboard/dashboard-visuals";
|
} from "@/modules/dashboard/dashboard-visuals";
|
||||||
|
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
|
||||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
@@ -27,7 +26,6 @@ import type {
|
|||||||
AdminDashboardSiteFinanceOverview,
|
AdminDashboardSiteFinanceOverview,
|
||||||
AdminDashboardWarning,
|
AdminDashboardWarning,
|
||||||
} from "@/types/api/admin-dashboard";
|
} from "@/types/api/admin-dashboard";
|
||||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
export function SiteFinanceDashboardConsole(): ReactElement {
|
export function SiteFinanceDashboardConsole(): ReactElement {
|
||||||
@@ -40,8 +38,6 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
|
||||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
|
||||||
const [drawId, setDrawId] = useState<number | null>(null);
|
|
||||||
const [overview, setOverview] = useState<AdminDashboardSiteFinanceOverview | null>(null);
|
const [overview, setOverview] = useState<AdminDashboardSiteFinanceOverview | null>(null);
|
||||||
const [walletPermission, setWalletPermission] = useState(false);
|
const [walletPermission, setWalletPermission] = useState(false);
|
||||||
|
|
||||||
@@ -55,15 +51,9 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const d = await getAdminDashboard();
|
const d = await getAdminDashboard();
|
||||||
setHall(d.hall);
|
|
||||||
setOverview(d.site_finance_overview);
|
setOverview(d.site_finance_overview);
|
||||||
setApiWarnings(d.warnings ?? []);
|
setApiWarnings(d.warnings ?? []);
|
||||||
setWalletPermission(d.capabilities?.wallet_transfer_view ?? false);
|
setWalletPermission(d.capabilities?.wallet_transfer_view ?? false);
|
||||||
if (d.resolved_draw != null) {
|
|
||||||
setDrawId(d.resolved_draw.id);
|
|
||||||
} else {
|
|
||||||
setDrawId(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
|
||||||
@@ -80,15 +70,19 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
|||||||
|
|
||||||
const displayCurrency = overview?.currency_code ?? "NPR";
|
const displayCurrency = overview?.currency_code ?? "NPR";
|
||||||
const abnormalCount = overview?.abnormal_transfer_count ?? null;
|
const abnormalCount = overview?.abnormal_transfer_count ?? null;
|
||||||
|
const abnormalHref = "/admin/wallet/transfer-orders?abnormal=1";
|
||||||
|
|
||||||
const quickLinks = useMemo(
|
const quickLinks = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ href: "/admin/reconcile", label: t("finance.quickLinks.reconcile") },
|
{ href: "/admin/reconcile", label: t("finance.quickLinks.reconcile") },
|
||||||
{ href: "/admin/wallet/transfer-orders", label: t("finance.quickLinks.transfers") },
|
{ href: "/admin/wallet/transfer-orders", label: t("finance.quickLinks.transfers") },
|
||||||
{ href: "/admin/settlement-center", label: t("finance.quickLinks.bills") },
|
{
|
||||||
|
href: settlementCenterScopeHref(site?.id, "awaiting_payment"),
|
||||||
|
label: t("finance.quickLinks.bills"),
|
||||||
|
},
|
||||||
{ href: "/admin/reports", label: t("finance.quickLinks.reports") },
|
{ href: "/admin/reports", label: t("finance.quickLinks.reports") },
|
||||||
],
|
],
|
||||||
[t],
|
[site?.id, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -144,6 +138,7 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
|||||||
icon={<AlertTriangle className="size-4" />}
|
icon={<AlertTriangle className="size-4" />}
|
||||||
hint={t("abnormalTransferScope")}
|
hint={t("abnormalTransferScope")}
|
||||||
accent={(abnormalCount ?? 0) > 0 ? "destructive" : "muted"}
|
accent={(abnormalCount ?? 0) > 0 ? "destructive" : "muted"}
|
||||||
|
href={abnormalHref}
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("finance.pendingConfirmBills")}
|
label={t("finance.pendingConfirmBills")}
|
||||||
@@ -151,6 +146,7 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
|||||||
icon={<ClipboardList className="size-4" />}
|
icon={<ClipboardList className="size-4" />}
|
||||||
hint={t("finance.pendingConfirmHint")}
|
hint={t("finance.pendingConfirmHint")}
|
||||||
accent={overview.pending_confirm_bill_count > 0 ? "primary" : "muted"}
|
accent={overview.pending_confirm_bill_count > 0 ? "primary" : "muted"}
|
||||||
|
href={settlementCenterScopeHref(site?.id, "pending_confirm")}
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("finance.payableBills")}
|
label={t("finance.payableBills")}
|
||||||
@@ -160,85 +156,43 @@ export function SiteFinanceDashboardConsole(): ReactElement {
|
|||||||
amount: formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency),
|
amount: formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency),
|
||||||
})}
|
})}
|
||||||
accent={overview.payable_bill_count > 0 ? "destructive" : "muted"}
|
accent={overview.payable_bill_count > 0 ? "destructive" : "muted"}
|
||||||
|
href={settlementCenterScopeHref(site?.id, "awaiting_payment")}
|
||||||
/>
|
/>
|
||||||
<DashboardKpiCard
|
<DashboardKpiCard
|
||||||
label={t("finance.walletPlayers")}
|
label={t("finance.walletPlayers")}
|
||||||
value={overview.wallet_player_count}
|
value={overview.wallet_player_count}
|
||||||
icon={<Users className="size-4" />}
|
icon={<Users className="size-4" />}
|
||||||
hint={t("finance.creditPlayersHint", { count: overview.credit_player_count })}
|
hint={t("finance.creditPlayersHint", { count: overview.credit_player_count })}
|
||||||
|
href="/admin/players"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-semibold">{t("finance.settlementTitle")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
<DashboardStatRow
|
|
||||||
label={t("finance.pendingConfirmBills")}
|
|
||||||
value={String(overview.pending_confirm_bill_count)}
|
|
||||||
/>
|
|
||||||
<DashboardStatRow
|
|
||||||
label={t("finance.payableBills")}
|
|
||||||
value={String(overview.payable_bill_count)}
|
|
||||||
/>
|
|
||||||
<DashboardStatRow
|
|
||||||
label={t("finance.payableUnpaidLabel")}
|
|
||||||
value={formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<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>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
|
<CardTitle className="text-sm font-semibold">{t("finance.reconcileTitle")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
<CardContent className="space-y-3">
|
||||||
{quickLinks.map((link) => (
|
<AbnormalTransferPanelFooter
|
||||||
<Link
|
total={abnormalCount}
|
||||||
key={link.href}
|
walletPermission={walletPermission}
|
||||||
href={link.href}
|
/>
|
||||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
<Link
|
||||||
>
|
href={abnormalHref}
|
||||||
{link.label}
|
className={buttonVariants({ variant: "outline", size: "sm", className: "w-full" })}
|
||||||
</Link>
|
>
|
||||||
))}
|
<Wallet className="size-3.5" />
|
||||||
|
{t("viewTransferOrders")}
|
||||||
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
|
||||||
{t("finance.overviewEmpty")}
|
{t("finance.overviewEmpty")}
|
||||||
</AdminNoResourceState>
|
</AdminNoResourceState>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DashboardCurrentDrawCard
|
|
||||||
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
|
|
||||||
hall={hall}
|
|
||||||
drawId={drawId}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,9 @@ import Link from "next/link";
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAdminDraw,
|
|
||||||
getAdminDrawFinanceSummary,
|
getAdminDrawFinanceSummary,
|
||||||
postAdminCancelDraw,
|
postAdminCancelDraw,
|
||||||
postAdminManualCloseDraw,
|
postAdminManualCloseDraw,
|
||||||
@@ -17,23 +15,20 @@ import {
|
|||||||
} from "@/api/admin-draws";
|
} from "@/api/admin-draws";
|
||||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
|
||||||
import { canManageDrawResults } from "@/lib/draw-access";
|
import { canManageDrawResults } from "@/lib/draw-access";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
|
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||||
|
|
||||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
|
|
||||||
import { DrawStatusBadge } from "./draw-status-badge";
|
|
||||||
import {
|
import {
|
||||||
PRD_DRAW_REOPEN_MANAGE,
|
PRD_DRAW_REOPEN_MANAGE,
|
||||||
PRD_PAYOUT_MANAGE,
|
PRD_PAYOUT_MANAGE,
|
||||||
@@ -50,28 +45,20 @@ function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
|
|||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ol className="grid gap-3 sm:grid-cols-3">
|
<ol className="grid gap-2 sm:grid-cols-3">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step) => (
|
||||||
<li
|
<li key={step.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||||
key={step.key}
|
<p className="text-xs text-muted-foreground">{step.label}</p>
|
||||||
className={cn(
|
<p className="mt-0.5 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
|
||||||
"relative rounded-lg border bg-muted/20 px-3 py-2.5",
|
|
||||||
index < steps.length - 1 &&
|
|
||||||
"sm:after:absolute sm:after:top-1/2 sm:after:left-full sm:after:h-px sm:after:w-3 sm:after:-translate-y-1/2 sm:after:bg-border",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">{step.label}</p>
|
|
||||||
<p className="mt-1 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
const tRef = useTranslationRef(["draws", "common"]);
|
const { draw: data, loading, error, refresh, drawId: idNum } = useDrawDetail();
|
||||||
const idNum = Number(drawId);
|
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
const canManageDraw = canManageDrawResults(profile?.permissions);
|
||||||
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
||||||
@@ -79,41 +66,29 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
PRD_PAYOUT_MANAGE,
|
PRD_PAYOUT_MANAGE,
|
||||||
PRD_PAYOUT_REVIEW,
|
PRD_PAYOUT_REVIEW,
|
||||||
]);
|
]);
|
||||||
const [data, setData] = useState<AdminDrawShowData | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [acting, setActing] = useState<string | null>(null);
|
const [acting, setActing] = useState<string | null>(null);
|
||||||
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
|
const [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const loadFinance = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum) || !data) {
|
||||||
setError(tRef.current("invalidDrawId"));
|
setFinanceSummary(null);
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
if (data.capabilities?.can_view_draw_finance === false) {
|
||||||
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);
|
|
||||||
setFinanceSummary(null);
|
setFinanceSummary(null);
|
||||||
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
return;
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [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> {
|
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
||||||
if (!Number.isFinite(idNum)) return;
|
if (!Number.isFinite(idNum)) return;
|
||||||
@@ -121,7 +96,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
toast.success(t("actionSuccess", { name }));
|
toast.success(t("actionSuccess", { name }));
|
||||||
await load();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name }));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name }));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -129,10 +104,6 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [idNum]);
|
|
||||||
|
|
||||||
const scheduleSteps = useMemo((): ScheduleStep[] => {
|
const scheduleSteps = useMemo((): ScheduleStep[] => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
const steps: ScheduleStep[] = [
|
const steps: ScheduleStep[] = [
|
||||||
@@ -241,181 +212,115 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|||||||
const pendingReview = batch.pending_review ?? 0;
|
const pendingReview = batch.pending_review ?? 0;
|
||||||
const totalBatches = batch.total ?? batch.published;
|
const totalBatches = batch.total ?? batch.published;
|
||||||
const financeCurrency = financeSummary?.currency_code ?? "NPR";
|
const financeCurrency = financeSummary?.currency_code ?? "NPR";
|
||||||
const hasResultActivity =
|
const hasResultActivity = totalBatches > 0 || pendingReview > 0 || batch.published > 0;
|
||||||
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
|
|
||||||
const showActions =
|
|
||||||
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<CardHeader className="pb-4">
|
<div className="rounded-lg border border-border/60 px-3 py-2.5">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<p className="text-xs text-muted-foreground">{t("overviewBetTotal")}</p>
|
||||||
<div className="min-w-0">
|
<p className="mt-1 font-mono text-sm tabular-nums">
|
||||||
<div className="mb-3">
|
{formatAdminMinorUnits(
|
||||||
<Link
|
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
|
||||||
href="/admin/draws"
|
financeCurrency,
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</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 ? (
|
<ScheduleTimeline steps={scheduleSteps} />
|
||||||
<section className="space-y-3 border-t pt-6">
|
|
||||||
<h3 className="text-sm font-medium">{t("drawActions")}</h3>
|
{hasResultActivity ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
{availableActions.map((action) => (
|
{canManageDraw && totalBatches > 0 ? (
|
||||||
<Button
|
<span className="rounded-md bg-muted px-2 py-1 tabular-nums">
|
||||||
key={action.key}
|
{t("batchSummaryTotal", { count: totalBatches })}
|
||||||
type="button"
|
</span>
|
||||||
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>
|
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
{canManageDraw && pendingReview > 0 ? (
|
||||||
</Card>
|
<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 />
|
<ConfirmDialog />
|
||||||
</div>
|
</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 { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
@@ -37,7 +37,7 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
|
|
||||||
import { drawStatusLabel, settlementBatchStatusLabel } from "./draw-display";
|
import { settlementBatchStatusLabel } from "./draw-display";
|
||||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
||||||
|
|
||||||
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||||
@@ -109,44 +109,25 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
|
const formatMoney = (minor: number) => formatAdminMinorUnits(minor, currencyCode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<Card>
|
<div className="grid gap-3 text-sm sm:grid-cols-3">
|
||||||
<CardHeader>
|
<div className="rounded-lg border border-border/60 px-3 py-2">
|
||||||
<CardTitle className="text-lg">{t("financeOverview")}</CardTitle>
|
<p className="text-xs text-muted-foreground">{t("orderAndItemCount")}</p>
|
||||||
</CardHeader>
|
<p className="mt-0.5 tabular-nums font-medium">
|
||||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
{data.order_count} / {data.ticket_item_count}
|
||||||
<div>
|
</p>
|
||||||
<span className="text-muted-foreground">{t("drawNo")}</span>
|
</div>
|
||||||
<p className="font-mono font-semibold">{data.draw_no}</p>
|
<div className="rounded-lg border border-border/60 px-3 py-2">
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">{t("actualBet")}</p>
|
||||||
<div>
|
<p className="mt-0.5 tabular-nums font-medium">{formatMoney(data.total_bet_minor)}</p>
|
||||||
<span className="text-muted-foreground">{t("status")}</span>
|
</div>
|
||||||
<p className="mt-1">
|
<div className="rounded-lg border border-border/60 px-3 py-2">
|
||||||
<DrawStatusBadge status={data.draw_status} label={drawStatusLabel(data.draw_status, t)} />
|
<p className="text-xs text-muted-foreground">{t("grossProfit")}</p>
|
||||||
</p>
|
<p className={cn("mt-0.5 tabular-nums font-semibold", signedMoneyClass(data.approx_house_gross_minor, true))}>
|
||||||
</div>
|
{formatMoney(data.approx_house_gross_minor)}
|
||||||
<div>
|
</p>
|
||||||
<span className="text-muted-foreground">{t("orderAndItemCount")}</span>
|
</div>
|
||||||
<p className="tabular-nums">
|
</div>
|
||||||
{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="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||||
@@ -174,11 +155,11 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="rounded-lg border border-border/60">
|
||||||
<CardHeader>
|
<div className="border-b border-border/60 px-3 py-2.5">
|
||||||
<CardTitle className="text-base">{t("relatedSettlementBatches")}</CardTitle>
|
<h2 className="text-sm font-semibold">{t("relatedSettlementBatches")}</h2>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="p-3">
|
||||||
{data.settlement_batches.length === 0 ? (
|
{data.settlement_batches.length === 0 ? (
|
||||||
<AdminNoResourceState className="py-4" />
|
<AdminNoResourceState className="py-4" />
|
||||||
) : (
|
) : (
|
||||||
@@ -232,8 +213,8 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,57 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import {
|
import { getAdminDrawResultBatches } from "@/api/admin-draws";
|
||||||
deleteAdminPendingResultBatch,
|
|
||||||
getAdminDrawResultBatches,
|
|
||||||
postAdminPublishResultBatch,
|
|
||||||
} from "@/api/admin-draws";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
|
||||||
Table,
|
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||||
|
|
||||||
import { drawBatchStatusLabel, drawPrizeTypeLabel, drawStatusLabel } from "./draw-display";
|
/** 深链兼容:/publish/[batchId] 直接打开发布弹窗 */
|
||||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }): React.ReactElement {
|
||||||
|
|
||||||
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
|
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
const tRef = useTranslationRef(["draws", "common"]);
|
const tRef = useTranslationRef(["draws", "common"]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const profile = useAdminProfile();
|
const { refresh: refreshDraw } = useDrawDetail();
|
||||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
|
||||||
PRD_DRAW_RESULT_MANAGE,
|
|
||||||
]);
|
|
||||||
const idNum = Number(drawId);
|
const idNum = Number(drawId);
|
||||||
const batchNum = Number(batchId);
|
const batchNum = Number(batchId);
|
||||||
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [publishing, setPublishing] = useState(false);
|
|
||||||
const [discarding, setDiscarding] = useState(false);
|
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum)) {
|
||||||
@@ -69,53 +42,17 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [idNum]);
|
}, [idNum, tRef]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [idNum]);
|
}, [load]);
|
||||||
|
|
||||||
const batch: AdminDrawBatchRow | undefined = useMemo(() => {
|
const batch = useMemo(() => {
|
||||||
if (!Number.isFinite(batchNum)) return undefined;
|
if (!Number.isFinite(batchNum)) return undefined;
|
||||||
return data?.batches.find((b) => b.id === batchNum);
|
return data?.batches.find((b) => b.id === batchNum);
|
||||||
}, [batchNum, data]);
|
}, [batchNum, data]);
|
||||||
|
|
||||||
async function discardBatch(): Promise<void> {
|
|
||||||
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
|
|
||||||
setDiscarding(true);
|
|
||||||
try {
|
|
||||||
await deleteAdminPendingResultBatch(idNum, batchNum);
|
|
||||||
toast.success(t("discardPendingBatchSuccess"));
|
|
||||||
router.replace(`/admin/draws/${drawId}/review`);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setDiscarding(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publish(): Promise<void> {
|
|
||||||
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
|
|
||||||
setPublishing(true);
|
|
||||||
try {
|
|
||||||
const res = await postAdminPublishResultBatch(idNum, batchNum);
|
|
||||||
toast.success(
|
|
||||||
t("publishSuccess", {
|
|
||||||
drawNo: res.draw_no,
|
|
||||||
status: drawStatusLabel(res.status, t),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof LotteryApiBizError ? e.message : t("publishFailed");
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setPublishing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||||
}
|
}
|
||||||
@@ -123,131 +60,29 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
|
|||||||
if (error) {
|
if (error) {
|
||||||
return <p className="text-sm text-destructive">{error}</p>;
|
return <p className="text-sm text-destructive">{error}</p>;
|
||||||
}
|
}
|
||||||
if (!data) {
|
|
||||||
return <AdminNoResourceState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!batch) {
|
if (!batch) {
|
||||||
return (
|
return <AdminNoResourceState message={t("batchNotFound")} />;
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>{t("batchNotFound")}</AlertTitle>
|
|
||||||
<AlertDescription>{t("batchNotFoundDesc")}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const canPublish =
|
|
||||||
canManageDraw && batch.status === "pending_review";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<DrawPublishDialog
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
open
|
||||||
<Link href={`/admin/draws/${drawId}/review`} className={buttonVariants({ variant: "ghost", size: "sm" })}>
|
onOpenChange={(open) => {
|
||||||
← {t("backToReviewQueue")}
|
if (!open) {
|
||||||
</Link>
|
router.replace(`/admin/draws/${drawId}/review`);
|
||||||
</div>
|
}
|
||||||
|
}}
|
||||||
<Card>
|
drawId={idNum}
|
||||||
<CardHeader>
|
batch={batch}
|
||||||
<CardTitle className="text-lg">{t("publishTitle")}</CardTitle>
|
onPublished={() => {
|
||||||
</CardHeader>
|
void refreshDraw();
|
||||||
<CardContent className="space-y-4">
|
router.replace(`/admin/draws/${drawId}/results`);
|
||||||
{!canManageDraw ? (
|
}}
|
||||||
<Alert variant="destructive">
|
onDiscarded={() => {
|
||||||
<AlertTitle>{t("noPublishPermission")}</AlertTitle>
|
void refreshDraw();
|
||||||
<AlertDescription>{t("noPublishPermission")}</AlertDescription>
|
router.replace(`/admin/draws/${drawId}/review`);
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { canManageDrawResults } from "@/lib/draw-access";
|
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||||
|
|
||||||
import { drawPrizeTypeLabel } from "./draw-display";
|
import { drawPrizeTypeLabel } from "./draw-display";
|
||||||
import { DrawStatusBadge } from "./draw-status-badge";
|
|
||||||
|
|
||||||
export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
const tRef = useTranslationRef(["draws", "common"]);
|
const tRef = useTranslationRef(["draws", "common"]);
|
||||||
const profile = useAdminProfile();
|
|
||||||
const canManageDraw = canManageDrawResults(profile?.permissions);
|
|
||||||
const idNum = Number(drawId);
|
const idNum = Number(drawId);
|
||||||
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -72,55 +67,28 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
|||||||
const published = data.batches.filter((b) => b.status === "published");
|
const published = data.batches.filter((b) => b.status === "published");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">{t("resultsTitle")}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("drawNo")} {data.draw_no} · <DrawStatusBadge status={data.draw_status} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{published.length === 0 ? (
|
{published.length === 0 ? (
|
||||||
<Card>
|
<AdminNoResourceState message={t("noPublishedBatch")} />
|
||||||
<CardContent className="py-4">
|
|
||||||
<AdminNoResourceState />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
published.map((batch) => (
|
published.map((batch) => (
|
||||||
<BatchTable key={batch.id} batch={batch} showOperationalMeta={canManageDraw} />
|
<BatchTable key={batch.id} batch={batch} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BatchTable({
|
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
|
||||||
batch,
|
|
||||||
showOperationalMeta,
|
|
||||||
}: {
|
|
||||||
batch: AdminDrawBatchRow;
|
|
||||||
showOperationalMeta: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("draws");
|
const { t } = useTranslation("draws");
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-base">{t("version", { version: batch.result_version })}</CardTitle>
|
<CardTitle className="text-sm">{t("version", { version: batch.result_version })}</CardTitle>
|
||||||
{showOperationalMeta ? (
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
<p className="font-mono text-xs text-muted-foreground">
|
{formatDt(batch.confirmed_at)}
|
||||||
{t("sourceType", {
|
</p>
|
||||||
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>
|
</CardHeader>
|
||||||
<CardContent className="overflow-x-auto pt-0">
|
<CardContent className="overflow-x-auto pt-0">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Dices, Rocket, Trash2 } from "lucide-react";
|
import { ChevronDown, ChevronRight, Dices, Rocket, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
@@ -14,10 +14,9 @@ import {
|
|||||||
} from "@/api/admin-draws";
|
} from "@/api/admin-draws";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -28,13 +27,14 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
|
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||||
|
import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
|
||||||
|
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||||
|
|
||||||
import { drawStatusLabel } from "./draw-display";
|
|
||||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||||
import { DrawStatusBadge } from "./draw-status-badge";
|
|
||||||
|
|
||||||
const RESULT_SLOTS = [
|
const RESULT_SLOTS = [
|
||||||
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" },
|
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" },
|
||||||
@@ -58,23 +58,22 @@ function randomDrawNumber4d(): string {
|
|||||||
return String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
|
return String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||||
const { t } = useTranslation(["draws", "common"]);
|
const { t } = useTranslation(["draws", "common"]);
|
||||||
const tRef = useTranslationRef(["draws", "common"]);
|
const tRef = useTranslationRef(["draws", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
|
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||||
PRD_DRAW_RESULT_MANAGE,
|
const { refresh: refreshDraw } = useDrawDetail();
|
||||||
]);
|
|
||||||
const idNum = Number(drawId);
|
const idNum = Number(drawId);
|
||||||
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingManual, setSavingManual] = useState(false);
|
const [savingManual, setSavingManual] = useState(false);
|
||||||
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null);
|
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null);
|
||||||
|
const [manualOpen, setManualOpen] = useState(false);
|
||||||
|
const [publishBatch, setPublishBatch] = useState<AdminDrawBatchRow | null>(null);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const [manualNumbers, setManualNumbers] = useState<string[]>(
|
const [manualNumbers, setManualNumbers] = useState<string[]>(() => RESULT_SLOTS.map(() => ""));
|
||||||
() => RESULT_SLOTS.map(() => ""),
|
|
||||||
);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!Number.isFinite(idNum)) {
|
if (!Number.isFinite(idNum)) {
|
||||||
@@ -92,15 +91,13 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [idNum]);
|
}, [idNum, tRef]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [idNum]);
|
}, [load]);
|
||||||
|
|
||||||
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
|
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [data]);
|
||||||
data,
|
|
||||||
]);
|
|
||||||
|
|
||||||
function fillRandomManualNumbers(): void {
|
function fillRandomManualNumbers(): void {
|
||||||
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d()));
|
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d()));
|
||||||
@@ -113,10 +110,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
await deleteAdminPendingResultBatch(idNum, batchId);
|
await deleteAdminPendingResultBatch(idNum, batchId);
|
||||||
toast.success(t("discardPendingBatchSuccess"));
|
toast.success(t("discardPendingBatchSuccess"));
|
||||||
await load();
|
await load();
|
||||||
|
await refreshDraw();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"));
|
||||||
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setDiscardingBatchId(null);
|
setDiscardingBatchId(null);
|
||||||
}
|
}
|
||||||
@@ -141,7 +137,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
});
|
});
|
||||||
toast.success(t("draftSaved", { version: res.batch.result_version }));
|
toast.success(t("draftSaved", { version: res.batch.result_version }));
|
||||||
setManualNumbers(RESULT_SLOTS.map(() => ""));
|
setManualNumbers(RESULT_SLOTS.map(() => ""));
|
||||||
|
setManualOpen(false);
|
||||||
await load();
|
await load();
|
||||||
|
await refreshDraw();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,6 +147,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onPublishFlowDone(): Promise<void> {
|
||||||
|
await load();
|
||||||
|
await refreshDraw();
|
||||||
|
}
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||||
}
|
}
|
||||||
@@ -161,137 +164,159 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<Card>
|
<div className="rounded-lg border border-border/60">
|
||||||
<CardHeader>
|
<div className="border-b border-border/60 px-3 py-2.5">
|
||||||
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle>
|
<h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
|
||||||
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
</div>
|
||||||
<span>{t("currentStatusLabel")}</span>
|
<div className="p-3">
|
||||||
<DrawStatusBadge
|
{pending.length === 0 ? (
|
||||||
status={data.draw_status}
|
<AdminNoResourceState className="py-6" message={t("noPendingBatches")} />
|
||||||
label={drawStatusLabel(data.draw_status, t)}
|
) : (
|
||||||
/>
|
<Table>
|
||||||
<span>· {t("currentStatusDraftHint")}</span>
|
<TableHeader>
|
||||||
</p>
|
<TableRow>
|
||||||
</CardHeader>
|
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
||||||
<CardContent className="space-y-4">
|
<TableHead>{t("numberCount")}</TableHead>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<TableHead className="w-14 text-center">{t("table.actions", { ns: "common" })}</TableHead>
|
||||||
{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>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{pending.map((b) => (
|
||||||
)}
|
<TableRow key={b.id}>
|
||||||
</CardContent>
|
<TableCell>v{b.result_version}</TableCell>
|
||||||
</Card>
|
<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 />
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
|
import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
|
||||||
|
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
@@ -62,6 +64,8 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
|||||||
const canManageDraw = canManageDrawResults(perms);
|
const canManageDraw = canManageDrawResults(perms);
|
||||||
const canViewFinance = canViewDrawFinance(perms);
|
const canViewFinance = canViewDrawFinance(perms);
|
||||||
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
|
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
|
||||||
|
const { draw } = useDrawDetail();
|
||||||
|
const pendingReview = draw?.result_batch_counts.pending_review ?? 0;
|
||||||
|
|
||||||
const visibleSegments = useMemo(
|
const visibleSegments = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -85,7 +89,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSubnav aria-label={t("subnav.aria", { defaultValue: "期号导航" })} className="mb-6">
|
<AdminSubnav aria-label={t("subnav.aria", { defaultValue: "期号导航" })} className="mb-4">
|
||||||
{visibleSegments.map(({ suffix, key, label }) => {
|
{visibleSegments.map(({ suffix, key, label }) => {
|
||||||
const href = `${base}${suffix}`;
|
const href = `${base}${suffix}`;
|
||||||
const active =
|
const active =
|
||||||
@@ -99,7 +103,14 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSubnavLink key={key} href={href} active={active}>
|
<AdminSubnavLink key={key} href={href} active={active}>
|
||||||
{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>
|
</AdminSubnavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
postAdminIntegrationSiteRotateSecrets,
|
postAdminIntegrationSiteRotateSecrets,
|
||||||
putAdminIntegrationSite,
|
putAdminIntegrationSite,
|
||||||
} from "@/api/admin-integration-sites";
|
} from "@/api/admin-integration-sites";
|
||||||
|
import { validateAdminPassword } from "@/lib/admin-input-validation";
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
@@ -337,7 +338,7 @@ export function IntegrationSitesConsole({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "create" && form.admin_password.trim().length < 8) {
|
if (mode === "create" && validateAdminPassword(form.admin_password) === "too_short") {
|
||||||
toast.error(t("integrationSites.form.adminPasswordRequired"));
|
toast.error(t("integrationSites.form.adminPasswordRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -771,7 +772,7 @@ export function IntegrationSitesConsole({
|
|||||||
type="password"
|
type="password"
|
||||||
value={form.admin_password}
|
value={form.admin_password}
|
||||||
placeholder={t("integrationSites.placeholders.adminPassword", {
|
placeholder={t("integrationSites.placeholders.adminPassword", {
|
||||||
defaultValue: "至少 8 位",
|
defaultValue: "至少 6 位",
|
||||||
})}
|
})}
|
||||||
onChange={(e) => updateForm("admin_password", e.target.value)}
|
onChange={(e) => updateForm("admin_password", e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,52 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Info } from "lucide-react";
|
|
||||||
|
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||||
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
|
||||||
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
|
type JackpotTab = "config" | "records";
|
||||||
|
|
||||||
|
function readTabFromHash(): JackpotTab {
|
||||||
|
return window.location.hash === "#records" ? "records" : "config";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 奖池:配置与流水分 Tab,避免单页堆叠过长。 */
|
||||||
export function JackpotConfigScreen() {
|
export function JackpotConfigScreen() {
|
||||||
const { t } = useTranslation("jackpot");
|
const { t } = useTranslation("jackpot");
|
||||||
|
const [tab, setTab] = useState<JackpotTab>("config");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollToRecords = () => {
|
const sync = () => setTab(readTabFromHash());
|
||||||
if (window.location.hash !== "#records") {
|
sync();
|
||||||
return;
|
window.addEventListener("hashchange", sync);
|
||||||
}
|
return () => window.removeEventListener("hashchange", sync);
|
||||||
document.getElementById("jackpot-records")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
}, []);
|
||||||
};
|
|
||||||
scrollToRecords();
|
const switchTab = useCallback((next: JackpotTab) => {
|
||||||
window.addEventListener("hashchange", scrollToRecords);
|
setTab(next);
|
||||||
return () => window.removeEventListener("hashchange", scrollToRecords);
|
const path = window.location.pathname;
|
||||||
|
if (next === "records") {
|
||||||
|
window.history.replaceState(null, "", `${path}#records`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.history.replaceState(null, "", path);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<div className="flex w-full max-w-none flex-col gap-4">
|
||||||
<AdminPageCard title={t("poolsSectionTitle")}>
|
<AdminSubnav aria-label={t("pageTabs")}>
|
||||||
<Alert className="mb-4 border-primary/20 bg-primary/5 text-foreground">
|
<AdminSubnavButton active={tab === "config"} onClick={() => switchTab("config")}>
|
||||||
<Info className="size-4" aria-hidden />
|
{t("tabConfig")}
|
||||||
<AlertTitle>{t("rulesTitle")}</AlertTitle>
|
</AdminSubnavButton>
|
||||||
<AlertDescription className="space-y-1 text-xs leading-5">
|
<AdminSubnavButton active={tab === "records"} onClick={() => switchTab("records")}>
|
||||||
<p>{t("rulesJoin")}</p>
|
{t("tabRecords")}
|
||||||
<p>{t("rulesBurst")}</p>
|
</AdminSubnavButton>
|
||||||
<p>{t("rulesManual")}</p>
|
</AdminSubnav>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<JackpotPoolsConsole embedded />
|
|
||||||
</AdminPageCard>
|
|
||||||
|
|
||||||
<AdminPageCard
|
{tab === "config" ? <JackpotPoolsConsole embedded /> : <JackpotRecordsConsole embedded />}
|
||||||
id="jackpot-records"
|
|
||||||
title={t("recordsSectionTitle")}
|
|
||||||
description={t("recordsSectionDescription")}
|
|
||||||
>
|
|
||||||
<JackpotRecordsConsole embedded />
|
|
||||||
</AdminPageCard>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,9 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
|
||||||
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
|
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
|
||||||
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -44,12 +44,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import { percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
|
||||||
formatRatioAsPercent,
|
|
||||||
percentUiToRatio,
|
|
||||||
ratioToPercentUi,
|
|
||||||
} from "@/lib/admin-rate-percent";
|
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
contribution_rate: string;
|
contribution_rate: string;
|
||||||
@@ -82,7 +78,6 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JackpotPoolsConsoleProps = {
|
type JackpotPoolsConsoleProps = {
|
||||||
/** 嵌入运营配置单页时去掉外层脚手架与重复标题 */
|
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,6 +97,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
const [adjustmentDrafts, setAdjustmentDrafts] = useState<Record<number, AdjustmentDraft>>({});
|
const [adjustmentDrafts, setAdjustmentDrafts] = useState<Record<number, AdjustmentDraft>>({});
|
||||||
const [adjustmentRows, setAdjustmentRows] = useState<Record<number, AdminJackpotPoolAdjustmentRow[]>>({});
|
const [adjustmentRows, setAdjustmentRows] = useState<Record<number, AdminJackpotPoolAdjustmentRow[]>>({});
|
||||||
const [adjustingId, setAdjustingId] = useState<number | null>(null);
|
const [adjustingId, setAdjustingId] = useState<number | null>(null);
|
||||||
|
const [adjustmentOpenId, setAdjustmentOpenId] = useState<number | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -110,31 +106,32 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
setItems(res.items);
|
setItems(res.items);
|
||||||
const d: Record<number, Draft> = {};
|
const d: Record<number, Draft> = {};
|
||||||
const adjDrafts: Record<number, AdjustmentDraft> = {};
|
const adjDrafts: Record<number, AdjustmentDraft> = {};
|
||||||
const adjRows: Record<number, AdminJackpotPoolAdjustmentRow[]> = {};
|
|
||||||
for (const p of res.items) {
|
for (const p of res.items) {
|
||||||
d[p.id] = toDraft(p);
|
d[p.id] = toDraft(p);
|
||||||
adjDrafts[p.id] = { direction: "increase", amount: "", reason: "" };
|
adjDrafts[p.id] = { direction: "increase", amount: "", reason: "" };
|
||||||
try {
|
|
||||||
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
|
|
||||||
adjRows[p.id] = ledger.items;
|
|
||||||
} catch {
|
|
||||||
adjRows[p.id] = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setDrafts(d);
|
setDrafts(d);
|
||||||
setAdjustmentDrafts(adjDrafts);
|
setAdjustmentDrafts(adjDrafts);
|
||||||
setAdjustmentRows(adjRows);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [tRef]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadAdjustments = useCallback(async (poolId: number) => {
|
||||||
|
try {
|
||||||
|
const ledger = await getAdminJackpotPoolAdjustments(poolId, { per_page: 5 });
|
||||||
|
setAdjustmentRows((prev) => ({ ...prev, [poolId]: ledger.items }));
|
||||||
|
} catch {
|
||||||
|
setAdjustmentRows((prev) => ({ ...prev, [poolId]: [] }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateDraft = (id: number, patch: Partial<Draft>) => {
|
const updateDraft = (id: number, patch: Partial<Draft>) => {
|
||||||
setDrafts((prev) => ({
|
setDrafts((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -149,6 +146,13 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleAdjustment = (poolId: number, open: boolean) => {
|
||||||
|
setAdjustmentOpenId(open ? poolId : null);
|
||||||
|
if (open && adjustmentRows[poolId] === undefined) {
|
||||||
|
void loadAdjustments(poolId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const save = async (p: AdminJackpotPoolRow) => {
|
const save = async (p: AdminJackpotPoolRow) => {
|
||||||
const d = drafts[p.id];
|
const d = drafts[p.id];
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
@@ -203,8 +207,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
updateAdjustmentDraft(p.id, { amount: "", reason: "" });
|
updateAdjustmentDraft(p.id, { amount: "", reason: "" });
|
||||||
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
|
await loadAdjustments(p.id);
|
||||||
setAdjustmentRows((prev) => ({ ...prev, [p.id]: ledger.items }));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("adjustmentFailed"));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("adjustmentFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -237,121 +240,183 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const poolList = (
|
const poolList = (
|
||||||
<div className={embedded ? "space-y-4" : "space-y-8"}>
|
<div className={embedded ? "space-y-4" : "space-y-6"}>
|
||||||
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
|
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
|
||||||
{!loading && items.length === 0 ? (
|
{!loading && items.length === 0 ? <AdminNoResourceState /> : null}
|
||||||
<AdminNoResourceState />
|
|
||||||
) : null}
|
|
||||||
{items.map((p) => {
|
{items.map((p) => {
|
||||||
const d = drafts[p.id] ?? toDraft(p);
|
const d = drafts[p.id] ?? toDraft(p);
|
||||||
const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" };
|
const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" };
|
||||||
const ledger = adjustmentRows[p.id] ?? [];
|
const ledger = adjustmentRows[p.id] ?? [];
|
||||||
const currentAmount = formatAdminMinorDecimal(p.current_amount, p.currency_code);
|
const currentAmount = formatAdminMinorDecimal(p.current_amount, p.currency_code);
|
||||||
const triggerThreshold = formatAdminMinorDecimal(p.trigger_threshold, p.currency_code);
|
|
||||||
const minBetAmount = formatAdminMinorDecimal(p.min_bet_amount, p.currency_code);
|
|
||||||
const statusOn = d.status === "1";
|
const statusOn = d.status === "1";
|
||||||
|
const adjustmentOpen = adjustmentOpenId === p.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AdminPageCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="space-y-3 rounded-xl border border-border/60 bg-background p-3 shadow-sm"
|
title={p.currency_code}
|
||||||
>
|
actions={
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-base font-semibold">{p.currency_code}</h3>
|
<Label htmlFor={`status-${p.id}`} className="text-sm text-muted-foreground">
|
||||||
<p className="text-muted-foreground text-xs">{t("configTitle")}</p>
|
{t("status")}
|
||||||
</div>
|
</Label>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<Switch
|
||||||
{t("displayBalance", { amount: currentAmount })}
|
id={`status-${p.id}`}
|
||||||
</p>
|
checked={statusOn}
|
||||||
</div>
|
disabled={!canManageJackpot}
|
||||||
|
aria-label={t("status")}
|
||||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
onCheckedChange={(checked) => updateDraft(p.id, { status: checked ? "1" : "0" })}
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
/>
|
||||||
<p className="text-muted-foreground text-xs">{t("currentAmount")}</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold leading-none tracking-tight">
|
|
||||||
{currentAmount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
|
||||||
<p className="text-muted-foreground text-xs">{t("status")}</p>
|
|
||||||
<div className="mt-1">
|
|
||||||
<AdminStatusBadge status={statusOn ? "enabled" : "disabled"}>
|
|
||||||
{statusOn ? t("enabled") : t("disabled")}
|
|
||||||
</AdminStatusBadge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
|
||||||
<p className="text-muted-foreground text-xs">{t("payoutRate")}</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold">
|
|
||||||
{formatRatioAsPercent(percentUiToRatio(d.payout_rate))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
|
||||||
<p className="text-muted-foreground text-xs">{t("forceTriggerGap")}</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold">{d.force_trigger_draw_gap}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 xl:grid-cols-12">
|
|
||||||
<div className="space-y-3 xl:col-span-8">
|
|
||||||
{canManageJackpot ? (
|
{canManageJackpot ? (
|
||||||
<div className="space-y-2 rounded-lg border border-border/60 bg-background p-3">
|
<Button
|
||||||
<p className="text-sm font-medium">{t("balanceAdjustmentTitle")}</p>
|
type="button"
|
||||||
<p className="text-muted-foreground text-xs">{t("balanceAdjustmentHint")}</p>
|
size="sm"
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
disabled={savingId === p.id}
|
||||||
<div className="space-y-1.5">
|
onClick={() =>
|
||||||
<Label>{t("adjustmentDirection")}</Label>
|
requestConfirm({
|
||||||
<Select
|
title: t("confirmSavePoolTitle"),
|
||||||
value={adj.direction}
|
description: t("confirmSavePoolDescription"),
|
||||||
onValueChange={(value: "increase" | "decrease" | null) => {
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
if (value === null) return;
|
onConfirm: () => save(p),
|
||||||
updateAdjustmentDraft(p.id, { direction: value });
|
})
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full min-w-0 sm:max-w-[12rem]">
|
{savingId === p.id ? t("saving") : t("save")}
|
||||||
<SelectValue>
|
</Button>
|
||||||
{(value) =>
|
) : null}
|
||||||
value === "increase"
|
</div>
|
||||||
? t("adjustmentIncrease")
|
}
|
||||||
: value === "decrease"
|
>
|
||||||
? t("adjustmentDecrease")
|
<p className="mb-4 text-2xl font-semibold tabular-nums tracking-tight">{currentAmount}</p>
|
||||||
: value != null
|
|
||||||
? String(value)
|
<fieldset disabled={!canManageJackpot} className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
: t("adjustmentIncrease")
|
<div className="space-y-1.5">
|
||||||
}
|
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
|
||||||
</SelectValue>
|
<Input
|
||||||
</SelectTrigger>
|
id={`cr-${p.id}`}
|
||||||
<SelectContent>
|
type="number"
|
||||||
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
|
min={0}
|
||||||
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
|
max={100}
|
||||||
</SelectContent>
|
step="0.01"
|
||||||
</Select>
|
className="font-mono"
|
||||||
</div>
|
value={d.contribution_rate}
|
||||||
<div className="space-y-1.5">
|
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||||
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
|
/>
|
||||||
<Input
|
</div>
|
||||||
id={`adj-amt-${p.id}`}
|
<div className="space-y-1.5">
|
||||||
className="font-mono"
|
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
|
||||||
value={adj.amount}
|
<Input
|
||||||
placeholder={t("adjustmentAmountPlaceholder")}
|
id={`th-${p.id}`}
|
||||||
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
|
className="font-mono"
|
||||||
/>
|
value={d.trigger_threshold}
|
||||||
</div>
|
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||||
<div className="space-y-1.5 sm:col-span-2">
|
/>
|
||||||
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
|
</div>
|
||||||
<Textarea
|
<div className="space-y-1.5">
|
||||||
id={`adj-reason-${p.id}`}
|
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
|
||||||
rows={1}
|
<Input
|
||||||
value={adj.reason}
|
id={`pr-${p.id}`}
|
||||||
placeholder={t("adjustmentReasonPlaceholder")}
|
type="number"
|
||||||
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
|
min={0}
|
||||||
/>
|
max={100}
|
||||||
</div>
|
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>
|
||||||
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
disabled={adjustingId === p.id}
|
disabled={adjustingId === p.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestConfirm({
|
requestConfirm({
|
||||||
@@ -365,177 +430,50 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
{adjustingId === p.id ? t("processing") : t("submitAdjustment")}
|
{adjustingId === p.id ? t("processing") : t("submitAdjustment")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{ledger.length > 0 ? (
|
||||||
) : null}
|
<ul className="space-y-1.5 sm:col-span-2">
|
||||||
|
{ledger.map((row) => (
|
||||||
<fieldset
|
<li
|
||||||
disabled={!canManageJackpot}
|
key={row.id}
|
||||||
className="grid gap-2 rounded-lg border border-border/60 bg-background p-3 sm:grid-cols-2"
|
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5">
|
<span className="truncate text-muted-foreground">{row.reason}</span>
|
||||||
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
|
<span className="shrink-0 font-mono font-medium">
|
||||||
<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">
|
|
||||||
{row.amount_delta > 0 ? "+" : ""}
|
{row.amount_delta > 0 ? "+" : ""}
|
||||||
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
|
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</li>
|
||||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{row.reason}</p>
|
))}
|
||||||
</li>
|
</ul>
|
||||||
))}
|
) : null}
|
||||||
</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 })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
) : null}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { AdminTableExportButton } from "@/components/admin/admin-table-export-bu
|
|||||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
@@ -167,13 +167,15 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
return translated === key ? value : translated;
|
return translated === key ? value : translated;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterFields = (
|
const filterBlock = (
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<div className="flex w-full min-w-0 max-w-sm flex-col gap-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
<Label htmlFor="jk-draw" className="text-xs text-muted-foreground">
|
||||||
|
{t("drawNo")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="jk-draw"
|
id="jk-draw"
|
||||||
className="font-mono"
|
className="h-9 w-40 font-mono"
|
||||||
value={drawNo}
|
value={drawNo}
|
||||||
onChange={(e) => setDrawNo(e.target.value)}
|
onChange={(e) => setDrawNo(e.target.value)}
|
||||||
placeholder={t("optional")}
|
placeholder={t("optional")}
|
||||||
@@ -184,23 +186,12 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" className="shrink-0 sm:self-end" onClick={applyDraw}>
|
<Button type="button" size="sm" className="h-9" onClick={applyDraw}>
|
||||||
{t("apply")}
|
{t("apply")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const filterBlock = embedded ? (
|
|
||||||
filterFields
|
|
||||||
) : (
|
|
||||||
<Card className="mb-6">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">{t("filter")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>{filterFields}</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
const payoutFooter = payouts ? (
|
const payoutFooter = payouts ? (
|
||||||
<AdminListPaginationFooter
|
<AdminListPaginationFooter
|
||||||
selectId="jk-payout-per"
|
selectId="jk-payout-per"
|
||||||
@@ -325,10 +316,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{filterBlock}
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
<AdminSubnav aria-label={t("pageTabs")}>
|
||||||
|
|
||||||
<AdminSubnav aria-label={t("recordTabs", { defaultValue: "奖池记录" })}>
|
|
||||||
<AdminSubnavButton
|
<AdminSubnavButton
|
||||||
active={recordTab === "payout"}
|
active={recordTab === "payout"}
|
||||||
onClick={() => setRecordTab("payout")}
|
onClick={() => setRecordTab("payout")}
|
||||||
@@ -341,7 +330,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
>
|
>
|
||||||
{t("contributionRecords")}
|
{t("contributionRecords")}
|
||||||
</AdminSubnavButton>
|
</AdminSubnavButton>
|
||||||
</AdminSubnav>
|
</AdminSubnav>
|
||||||
|
{filterBlock}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{recordTab === "payout" ? payoutTable : contributionTable}
|
{recordTab === "payout" ? payoutTable : contributionTable}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye, ShieldAlert } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { ArrowUpRight, ChevronDown, ChevronRight, RefreshCw, Search } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
@@ -17,19 +18,18 @@ import {
|
|||||||
manuallyProcessTransferOrder,
|
manuallyProcessTransferOrder,
|
||||||
reverseTransferOrder,
|
reverseTransferOrder,
|
||||||
} from "@/api/admin-wallet";
|
} from "@/api/admin-wallet";
|
||||||
import { ReconcileItemActions } from "@/modules/reconcile/reconcile-item-actions";
|
|
||||||
import { getAdminPlayers } from "@/api/admin-player";
|
import { getAdminPlayers } from "@/api/admin-player";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
|
import { ReconcileJobDetailPanel, type ReconcileItemsFilter } from "@/modules/reconcile/reconcile-job-detail-panel";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -51,103 +51,15 @@ import type {
|
|||||||
AdminReconcileItemsData,
|
AdminReconcileItemsData,
|
||||||
AdminReconcileJobListData,
|
AdminReconcileJobListData,
|
||||||
} from "@/types/api/admin-reconcile";
|
} from "@/types/api/admin-reconcile";
|
||||||
|
import {
|
||||||
|
getJobSummaryValue,
|
||||||
|
jobStatusLabel,
|
||||||
|
renderPeriodRange,
|
||||||
|
} from "@/modules/reconcile/reconcile-labels";
|
||||||
|
|
||||||
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
|
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
|
||||||
|
|
||||||
/** 与后端 reconcile_type 对齐;扩展时在 API 与下拉同步增加 */
|
|
||||||
const RECONCILE_TYPE = "wallet_transfer" as const;
|
const RECONCILE_TYPE = "wallet_transfer" as const;
|
||||||
|
|
||||||
function jobStatusLabel(status: string, t: (key: string) => string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "completed":
|
|
||||||
return t("statusCompleted");
|
|
||||||
case "running":
|
|
||||||
return t("statusRunning");
|
|
||||||
case "failed":
|
|
||||||
return t("statusFailed");
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemStatusLabel(status: string, t: (key: string) => string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "mismatch":
|
|
||||||
return t("itemMismatch");
|
|
||||||
case "matched":
|
|
||||||
return t("itemMatched");
|
|
||||||
case "pending_check":
|
|
||||||
return t("itemPendingCheck");
|
|
||||||
case "stale_processing":
|
|
||||||
return t("itemStaleProcessing");
|
|
||||||
case "pending_reconcile":
|
|
||||||
return t("itemPendingReconcile");
|
|
||||||
case "missing_wallet_txn":
|
|
||||||
return t("itemMissingWalletTxn");
|
|
||||||
case "unexpected_wallet_txn":
|
|
||||||
return t("itemUnexpectedWalletTxn");
|
|
||||||
case "missing_refund":
|
|
||||||
return t("itemMissingRefund");
|
|
||||||
case "missing_reversal":
|
|
||||||
return t("itemMissingReversal");
|
|
||||||
case "main_site_record_missing":
|
|
||||||
return t("itemMainSiteRecordMissing");
|
|
||||||
case "main_site_failed":
|
|
||||||
return t("itemMainSiteFailed");
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
|
||||||
switch (type) {
|
|
||||||
case "wallet_transfer":
|
|
||||||
return t("reconcileTypeFixed");
|
|
||||||
default:
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mainSiteCheckLabel(status: string | null | undefined, t: (key: string) => string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "matched":
|
|
||||||
return t("mainSiteMatched");
|
|
||||||
case "not_found":
|
|
||||||
return t("mainSiteNotFound");
|
|
||||||
case "failed_on_main":
|
|
||||||
return t("mainSiteFailed");
|
|
||||||
case "unavailable":
|
|
||||||
return t("mainSiteUnavailable");
|
|
||||||
default:
|
|
||||||
return t("mainSiteSkipped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemResolutionLabel(
|
|
||||||
row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">,
|
|
||||||
t: (key: string) => string,
|
|
||||||
): string {
|
|
||||||
return row.is_resolved === true || row.resolved_at ? t("itemResolved") : t("itemUnresolved");
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemResolutionTone(row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">): "success" | "warning" {
|
|
||||||
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
|
||||||
const raw = summary?.[key];
|
|
||||||
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPeriodRange(
|
|
||||||
row: Pick<AdminReconcileJobRow, "period_start" | "period_end">,
|
|
||||||
formatTs: (value: string | null | undefined) => string,
|
|
||||||
): string {
|
|
||||||
const from = row.period_start ? formatTs(row.period_start) : "—";
|
|
||||||
const to = row.period_end ? formatTs(row.period_end) : "—";
|
|
||||||
return `${from} ~ ${to}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReconcileConsole(): React.ReactElement {
|
export function ReconcileConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["reconcile", "common"]);
|
const { t } = useTranslation(["reconcile", "common"]);
|
||||||
const tRef = useTranslationRef(["reconcile", "common"]);
|
const tRef = useTranslationRef(["reconcile", "common"]);
|
||||||
@@ -163,18 +75,20 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedJob, setSelectedJob] = useState<AdminReconcileJobRow | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
|
||||||
const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
|
const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
|
||||||
const [itemsPage, setItemsPage] = useState(1);
|
const [itemsPage, setItemsPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||||
const [itemsLoading, setItemsLoading] = useState(false);
|
const [itemsLoading, setItemsLoading] = useState(false);
|
||||||
|
const [itemsFilter, setItemsFilter] = useState<ReconcileItemsFilter>("open");
|
||||||
|
|
||||||
|
const [scanOpen, setScanOpen] = useState(false);
|
||||||
const [dateFrom, setDateFrom] = useState("");
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
const [dateTo, setDateTo] = useState("");
|
const [dateTo, setDateTo] = useState("");
|
||||||
const [playerSearch, setPlayerSearch] = useState("");
|
const [playerSearch, setPlayerSearch] = useState("");
|
||||||
const [playerResults, setPlayerResults] = useState<AdminPlayerRow[]>([]);
|
const [playerResults, setPlayerResults] = useState<AdminPlayerRow[]>([]);
|
||||||
const [playerLoading, setPlayerLoading] = useState(false);
|
const [playerLoading, setPlayerLoading] = useState(false);
|
||||||
|
const [playerPickerOpen, setPlayerPickerOpen] = useState(false);
|
||||||
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
|
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
@@ -191,20 +105,20 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setJobsLoading(false);
|
setJobsLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage]);
|
}, [page, perPage, tRef]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void loadJobs();
|
void loadJobs();
|
||||||
}, [page, perPage]);
|
}, [page, perPage]);
|
||||||
|
|
||||||
const loadItems = useCallback(async () => {
|
const loadItems = useCallback(async () => {
|
||||||
if (selectedId == null) {
|
if (selectedJob == null) {
|
||||||
setItems(null);
|
setItems(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setItemsLoading(true);
|
setItemsLoading(true);
|
||||||
try {
|
try {
|
||||||
const d = await getAdminReconcileJobItems(selectedId, {
|
const d = await getAdminReconcileJobItems(selectedJob.id, {
|
||||||
page: itemsPage,
|
page: itemsPage,
|
||||||
per_page: itemsPerPage,
|
per_page: itemsPerPage,
|
||||||
});
|
});
|
||||||
@@ -215,11 +129,11 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setItemsLoading(false);
|
setItemsLoading(false);
|
||||||
}
|
}
|
||||||
}, [selectedId, itemsPage, itemsPerPage]);
|
}, [selectedJob, itemsPage, itemsPerPage, tRef]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
void loadItems();
|
void loadItems();
|
||||||
}, [selectedId, itemsPage, itemsPerPage]);
|
}, [selectedJob, itemsPage, itemsPerPage]);
|
||||||
|
|
||||||
const loadPlayers = useCallback(async (keyword: string) => {
|
const loadPlayers = useCallback(async (keyword: string) => {
|
||||||
const q = keyword.trim();
|
const q = keyword.trim();
|
||||||
@@ -239,15 +153,14 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = playerSearch.trim();
|
if (!playerPickerOpen) {
|
||||||
if (q === "") {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
void loadPlayers(q);
|
void loadPlayers(playerSearch);
|
||||||
}, 250);
|
}, 250);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [loadPlayers, playerSearch]);
|
}, [loadPlayers, playerPickerOpen, playerSearch]);
|
||||||
|
|
||||||
async function onCreate(): Promise<void> {
|
async function onCreate(): Promise<void> {
|
||||||
if (!dateFrom.trim() || !dateTo.trim()) {
|
if (!dateFrom.trim() || !dateTo.trim()) {
|
||||||
@@ -273,6 +186,7 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
: resp.item_count ?? 0;
|
: resp.item_count ?? 0;
|
||||||
toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty"));
|
toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty"));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
setScanOpen(false);
|
||||||
setDateFrom("");
|
setDateFrom("");
|
||||||
setDateTo("");
|
setDateTo("");
|
||||||
setPlayerSearch("");
|
setPlayerSearch("");
|
||||||
@@ -328,36 +242,50 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openJobDetail(job: AdminReconcileJobRow): void {
|
||||||
|
setSelectedJob(job);
|
||||||
|
setItemsPage(1);
|
||||||
|
setItemsFilter("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeJobDetail(): void {
|
||||||
|
setSelectedJob(null);
|
||||||
|
setItems(null);
|
||||||
|
}
|
||||||
|
|
||||||
const jm = jobs?.meta;
|
const jm = jobs?.meta;
|
||||||
const im = items?.meta;
|
|
||||||
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
|
|
||||||
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
|
|
||||||
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
|
|
||||||
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
<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 ? (
|
{canCreate ? (
|
||||||
<Card className="admin-list-card">
|
<div className="rounded-lg border border-border/60">
|
||||||
<CardHeader className="admin-list-header">
|
<button
|
||||||
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
type="button"
|
||||||
<p className="text-sm text-muted-foreground">{t("createHint")}</p>
|
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-medium"
|
||||||
</CardHeader>
|
onClick={() => setScanOpen((open) => !open)}
|
||||||
<CardContent className="admin-list-content">
|
>
|
||||||
<div className="admin-list-toolbar">
|
{t("createTitle")}
|
||||||
<div className="admin-list-field">
|
{scanOpen ? <ChevronDown className="size-4 text-muted-foreground" /> : <ChevronRight className="size-4 text-muted-foreground" />}
|
||||||
<span className="text-sm font-medium leading-none sm:shrink-0">{t("reconcileType")}</span>
|
</button>
|
||||||
<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">
|
{scanOpen ? (
|
||||||
{t("reconcileTypeFixed")}
|
<div className="space-y-3 border-t border-border/60 px-3 py-3">
|
||||||
</span>
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
</div>
|
<div className="min-w-[14rem] flex-1">
|
||||||
<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">
|
|
||||||
<AdminDateRangeField
|
<AdminDateRangeField
|
||||||
id="rc-date-range"
|
id="rc-date-range"
|
||||||
|
label={t("dateRange")}
|
||||||
from={dateFrom}
|
from={dateFrom}
|
||||||
to={dateTo}
|
to={dateTo}
|
||||||
onRangeChange={({ from, to }) => {
|
onRangeChange={({ from, to }) => {
|
||||||
@@ -366,23 +294,66 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="min-w-[12rem] flex-1">
|
||||||
<div className="admin-list-field min-w-0 flex-1">
|
<Label htmlFor="rc-player-search" className="mb-1.5 block text-sm">
|
||||||
<Label htmlFor="rc-player-search" className="sm:shrink-0">
|
{t("playerSearch")}
|
||||||
{t("playerSearch")}
|
</Label>
|
||||||
</Label>
|
<Popover open={playerPickerOpen} onOpenChange={setPlayerPickerOpen} modal={false}>
|
||||||
<Input
|
<div className="flex gap-1.5">
|
||||||
id="rc-player-search"
|
<Input
|
||||||
className="w-full sm:w-52"
|
id="rc-player-search"
|
||||||
value={playerSearch}
|
className="h-8"
|
||||||
onChange={(e) => setPlayerSearch(e.target.value)}
|
value={playerSearch}
|
||||||
placeholder={t("playerSearchPlaceholder")}
|
onChange={(e) => setPlayerSearch(e.target.value)}
|
||||||
/>
|
placeholder={t("playerSearchPlaceholder")}
|
||||||
</div>
|
/>
|
||||||
<div className="admin-list-actions">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full sm:w-auto"
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
requestConfirm({
|
requestConfirm({
|
||||||
@@ -399,352 +370,160 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
{submitting ? t("submitting") : t("createTask")}
|
{submitting ? t("submitting") : t("createTask")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
) : null}
|
||||||
{selectedPlayer ? (
|
</div>
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">{t("noCreatePermission")}</p>
|
<p className="text-sm text-muted-foreground">{t("viewOnlyHint")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="admin-list-card">
|
<div className="rounded-lg border border-border/60">
|
||||||
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-3 py-2.5">
|
||||||
<div>
|
<h2 className="text-sm font-semibold">{t("jobsTitle")}</h2>
|
||||||
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
<Button type="button" variant="ghost" size="sm" className="h-8" disabled={jobsLoading} onClick={() => void loadJobs()}>
|
||||||
</div>
|
<RefreshCw className={cn("size-3.5", jobsLoading && "animate-spin")} />
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
|
||||||
{t("refresh")}
|
{t("refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="admin-list-content pt-4">
|
<div className="p-3 pt-2">
|
||||||
{jobsErr ? <p className="text-sm text-destructive">{jobsErr}</p> : null}
|
{jobsErr ? <p className="mb-2 text-sm text-destructive">{jobsErr}</p> : null}
|
||||||
{jobs ? (
|
{jobs ? (
|
||||||
<>
|
<>
|
||||||
<div className="admin-table-shell">
|
<Table id="reconcile-jobs-table">
|
||||||
<Table id="reconcile-jobs-table">
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead>{t("jobNo")}</TableHead>
|
||||||
<TableHead className="sticky left-0 z-20 w-24 bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
<TableHead>{t("status")}</TableHead>
|
||||||
{t("table.id", { ns: "common" })}
|
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
|
||||||
</TableHead>
|
<TableHead>{t("period")}</TableHead>
|
||||||
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
<TableHead>{t("finishedAt")}</TableHead>
|
||||||
{t("jobNo")}
|
<TableHead className="w-[6.5rem] text-center">{t("operate")}</TableHead>
|
||||||
</TableHead>
|
</TableRow>
|
||||||
<TableHead>{t("type")}</TableHead>
|
</TableHeader>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableBody>
|
||||||
<TableHead className="text-center">{t("itemCount")}</TableHead>
|
{jobsLoading && jobs.items.length === 0 ? (
|
||||||
<TableHead className="text-center">{t("mismatchCount")}</TableHead>
|
<AdminTableLoadingRow colSpan={6} />
|
||||||
<TableHead>{t("period")}</TableHead>
|
) : jobs.items.length === 0 ? (
|
||||||
<TableHead>{t("finishedAt")}</TableHead>
|
<AdminTableNoResourceRow colSpan={6} />
|
||||||
<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)]">
|
jobs.items.map((row) => {
|
||||||
{t("operate")}
|
const selected = selectedJob?.id === row.id;
|
||||||
</TableHead>
|
const mismatchCount = getJobSummaryValue(row.summary_json, "mismatch_count");
|
||||||
</TableRow>
|
return (
|
||||||
</TableHeader>
|
<TableRow key={row.id} className={cn(selected && "bg-primary/[0.04]")}>
|
||||||
<TableBody>
|
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
|
||||||
{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>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<AdminStatusBadge status={row.status}>
|
<AdminStatusBadge status={row.status}>
|
||||||
{jobStatusLabel(row.status, t)}
|
{jobStatusLabel(row.status, t)}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center tabular-nums">
|
|
||||||
{getJobSummaryValue(row.summary_json, "item_count")}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center tabular-nums">
|
<TableCell className="text-center tabular-nums">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
getJobSummaryValue(row.summary_json, "mismatch_count") > 0
|
mismatchCount > 0 ? "font-medium text-amber-700" : "text-muted-foreground",
|
||||||
? "font-medium text-amber-700"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getJobSummaryValue(row.summary_json, "mismatch_count")}
|
{mismatchCount}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
|
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||||||
<span className="line-clamp-2">
|
{renderPeriodRange(row, formatTs)}
|
||||||
{renderPeriodRange(row, formatTs)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
{formatTs(row.finished_at)}
|
{formatTs(row.finished_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
<TableCell className="text-center">
|
||||||
{formatTs(row.created_at)}
|
<Button
|
||||||
</TableCell>
|
type="button"
|
||||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(226,232,240,0.9)]">
|
size="sm"
|
||||||
<AdminRowActionsMenu
|
variant={selected ? "default" : "outline"}
|
||||||
actions={[
|
className="h-8"
|
||||||
{
|
onClick={() => openJobDetail(row)}
|
||||||
key: "view-details",
|
>
|
||||||
label: t("viewDetails"),
|
{t("viewDetails")}
|
||||||
icon: Eye,
|
</Button>
|
||||||
onClick: () => {
|
|
||||||
setSelectedId(row.id);
|
|
||||||
setItemsPage(1);
|
|
||||||
setDetailOpen(true);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
);
|
||||||
)}
|
})
|
||||||
</TableBody>
|
)}
|
||||||
</Table>
|
</TableBody>
|
||||||
</div>
|
</Table>
|
||||||
{jm ? (
|
{jm ? (
|
||||||
<AdminListPaginationFooter
|
<div className="mt-3 border-t border-border/60 pt-2">
|
||||||
selectId="reconcile-jobs-per-page"
|
<AdminListPaginationFooter
|
||||||
total={jm.total}
|
selectId="reconcile-jobs-per-page"
|
||||||
page={jm.current_page}
|
total={jm.total}
|
||||||
lastPage={Math.max(1, jm.last_page)}
|
page={jm.current_page}
|
||||||
perPage={jm.per_page}
|
lastPage={Math.max(1, jm.last_page)}
|
||||||
loading={jobsLoading}
|
perPage={jm.per_page}
|
||||||
onPerPageChange={(n) => {
|
loading={jobsLoading}
|
||||||
setPerPage(n);
|
onPerPageChange={(n) => {
|
||||||
setPage(1);
|
setPerPage(n);
|
||||||
}}
|
setPage(1);
|
||||||
onPageChange={setPage}
|
}}
|
||||||
/>
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
) : jobsLoading ? (
|
||||||
|
<AdminNoResourceState message={t("loadFailed")} />
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={detailOpen}
|
open={selectedJob != null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setDetailOpen(open);
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedId(null);
|
closeJobDetail();
|
||||||
setItems(null);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
className="flex max-h-[min(90vh,48rem)] w-full max-w-[min(72rem,calc(100%-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,calc(100%-2rem))]"
|
||||||
showCloseButton
|
showCloseButton
|
||||||
className="flex h-[min(86vh,780px)] !max-w-[min(920px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0"
|
|
||||||
>
|
>
|
||||||
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
{selectedJob ? (
|
||||||
<DialogTitle className="text-base">{t("detailsTitle")}</DialogTitle>
|
<ReconcileJobDetailPanel
|
||||||
<DialogDescription className="font-mono text-xs">
|
job={selectedJob}
|
||||||
{selectedJob ? `${selectedJob.job_no} · #${selectedJob.id}` : selectedId != null ? `#${selectedId}` : ""}
|
items={items}
|
||||||
</DialogDescription>
|
itemsLoading={itemsLoading}
|
||||||
</DialogHeader>
|
itemsFilter={itemsFilter}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
onItemsFilterChange={setItemsFilter}
|
||||||
{itemsLoading && !items ? (
|
canWriteWallet={canWriteWallet}
|
||||||
<AdminLoadingState minHeight="6rem" className="py-6" />
|
actionBusy={actionBusy}
|
||||||
) : null}
|
onCompleteCredit={(transferNo) => void runTransferAction(transferNo, "complete_credit")}
|
||||||
{items ? (
|
onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")}
|
||||||
<>
|
onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")}
|
||||||
<div className="mb-4 grid gap-3 md:grid-cols-3">
|
onItemsPageChange={setItemsPage}
|
||||||
<div className="rounded-lg border bg-background px-4 py-3">
|
onItemsPerPageChange={setItemsPerPage}
|
||||||
<div className="text-xs text-muted-foreground">{t("itemCount")}</div>
|
/>
|
||||||
<div className="mt-1 text-xl font-semibold tabular-nums">{selectedJobItemCount}</div>
|
) : null}
|
||||||
</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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { RotateCcw, Wrench } from "lucide-react";
|
import { ExternalLink, RotateCcw, Wrench } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
@@ -19,6 +19,7 @@ type ReconcileItemActionsProps = {
|
|||||||
row: AdminReconcileItemRow;
|
row: AdminReconcileItemRow;
|
||||||
canWriteWallet: boolean;
|
canWriteWallet: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
compact?: boolean;
|
||||||
onCompleteCredit: (transferNo: string) => void;
|
onCompleteCredit: (transferNo: string) => void;
|
||||||
onReverse: (transferNo: string) => void;
|
onReverse: (transferNo: string) => void;
|
||||||
onManualProcess: (transferNo: string) => void;
|
onManualProcess: (transferNo: string) => void;
|
||||||
@@ -28,6 +29,7 @@ export function ReconcileItemActions({
|
|||||||
row,
|
row,
|
||||||
canWriteWallet,
|
canWriteWallet,
|
||||||
busy,
|
busy,
|
||||||
|
compact = false,
|
||||||
onCompleteCredit,
|
onCompleteCredit,
|
||||||
onReverse,
|
onReverse,
|
||||||
onManualProcess,
|
onManualProcess,
|
||||||
@@ -45,36 +47,60 @@ export function ReconcileItemActions({
|
|||||||
can_manually_process: row.can_manually_process,
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet) ? (
|
{hasWalletAction ? <AdminRowActionsMenu busy={busy} actions={menuActions.filter((a) => a.key !== "transfer" && a.key !== "txn")} /> : null}
|
||||||
<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}
|
|
||||||
{transferNo !== "" ? (
|
{transferNo !== "" ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`}
|
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`}
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { ArrowUpRight, Database, FileDown, FileSpreadsheet, Search } from "lucide-react";
|
||||||
CalendarDays,
|
|
||||||
Database,
|
|
||||||
FileDown,
|
|
||||||
FileSpreadsheet,
|
|
||||||
ListFilter,
|
|
||||||
Search,
|
|
||||||
ShieldAlert,
|
|
||||||
ShieldCheck,
|
|
||||||
Ticket,
|
|
||||||
Users,
|
|
||||||
WalletCards,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||||
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||||
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||||
import { drawStatusLabel } from "@/modules/draws/draw-display";
|
import { drawStatusLabel } from "@/modules/draws/draw-display";
|
||||||
import { getAdminPlayers } from "@/api/admin-player";
|
import { getAdminPlayers } from "@/api/admin-player";
|
||||||
@@ -42,25 +30,17 @@ import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
|
|||||||
import { getAdminUsers } from "@/api/admin-users";
|
import { getAdminUsers } from "@/api/admin-users";
|
||||||
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import {
|
import { PRD_REPORT_EXPORT } from "@/lib/admin-prd";
|
||||||
PRD_AUDIT_VIEW,
|
|
||||||
PRD_REPORT_EXPORT,
|
|
||||||
PRD_REPORT_VIEW,
|
|
||||||
PRD_RISK_ACCESS_ANY,
|
|
||||||
PRD_WALLET_TRANSFER_ACCESS_ANY,
|
|
||||||
} from "@/lib/admin-prd";
|
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
|
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -68,559 +48,87 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
|
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
|
||||||
import { formatAdminInstant } from "@/lib/admin-datetime";
|
|
||||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
|
||||||
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
|
|
||||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminAuditLogRow } from "@/types/api/admin-audit";
|
import { getAdminDraws } from "@/api/admin-draws";
|
||||||
import type { AdminDrawListItem } from "@/types/api/admin-draws";
|
|
||||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
|
||||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
|
||||||
import type { AdminRiskPoolRow, AdminRiskPoolShowData } from "@/types/api/admin-risk";
|
|
||||||
import type { AdminUserPermissionRow } from "@/types/api/admin-user";
|
|
||||||
import type { AdminTransferOrderItem } from "@/types/api/admin-wallet";
|
|
||||||
import type {
|
|
||||||
AdminReportDailyProfitRow,
|
|
||||||
AdminReportPlayDimensionRow,
|
|
||||||
AdminReportPlayerWinLossRow,
|
|
||||||
} from "@/types/api/admin-reports";
|
|
||||||
|
|
||||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
import { ReportPreviewTable } from "@/modules/reports/report-preview-tables";
|
||||||
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
|
import {
|
||||||
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
CATEGORY_SHORTCUT_HREF,
|
||||||
type ExportFormat = "csv" | "excel";
|
REPORT_CATEGORY_ORDER,
|
||||||
type ExportCell = string | number | null;
|
REPORT_DEFINITIONS,
|
||||||
type ExportRow = Record<string, ExportCell>;
|
type FieldKey,
|
||||||
type SearchKind = "draw" | "player" | "operator";
|
type ReportCategory,
|
||||||
|
type ReportKey,
|
||||||
|
} from "@/modules/reports/reports-definitions";
|
||||||
|
import {
|
||||||
|
buildDailyProfitRowsAndSummary,
|
||||||
|
buildPlayDimensionRowsAndSummary,
|
||||||
|
buildPlayerTransferRowsAndSummary,
|
||||||
|
createDefaultFilters,
|
||||||
|
downloadBlob,
|
||||||
|
drawRowsFromSummary,
|
||||||
|
emptyFilters,
|
||||||
|
emptySearch,
|
||||||
|
formatExportInstant,
|
||||||
|
formatPlainMoney,
|
||||||
|
formatUsagePercent,
|
||||||
|
headlineStats,
|
||||||
|
metaFromList,
|
||||||
|
normalizeFilenamePart,
|
||||||
|
optionText,
|
||||||
|
parsePositiveInteger,
|
||||||
|
reportHasPeriodField,
|
||||||
|
reportListParams,
|
||||||
|
resolveDisplayCurrency,
|
||||||
|
resolveDraw,
|
||||||
|
type ExportFormat,
|
||||||
|
type ReportFilters,
|
||||||
|
type ReportResult,
|
||||||
|
type SearchKind,
|
||||||
|
type SearchState,
|
||||||
|
} from "@/modules/reports/reports-utils";
|
||||||
|
|
||||||
type ReportKey =
|
export type { ReportCategory } from "@/modules/reports/reports-definitions";
|
||||||
| "draw_profit"
|
|
||||||
| "daily_profit"
|
|
||||||
| "player_win_loss"
|
|
||||||
| "player_transfer"
|
|
||||||
| "hot_number_risk"
|
|
||||||
| "play_dimension"
|
|
||||||
| "sold_out_number"
|
|
||||||
| "admin_audit";
|
|
||||||
|
|
||||||
type ReportDefinition = {
|
type ReportsConsoleProps = {
|
||||||
key: ReportKey;
|
initialCategory?: ReportCategory;
|
||||||
category: ReportCategory;
|
|
||||||
icon: typeof FileSpreadsheet;
|
|
||||||
filterKind: FilterKind;
|
|
||||||
scope: string;
|
|
||||||
fields: FieldKey[];
|
|
||||||
connected: boolean;
|
|
||||||
requiredAny: readonly string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
|
export function ReportsConsole({ initialCategory = "profit" }: ReportsConsoleProps) {
|
||||||
|
const { t } = useTranslation(["reports", "common"]);
|
||||||
const REPORTS: ReportDefinition[] = [
|
|
||||||
{ key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
|
||||||
{ key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
|
||||||
{ key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
|
||||||
{ key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_WALLET_TRANSFER_ACCESS_ANY },
|
|
||||||
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
|
|
||||||
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
|
||||||
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
|
|
||||||
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true, requiredAny: [PRD_AUDIT_VIEW] },
|
|
||||||
];
|
|
||||||
|
|
||||||
type PreviewColumns = {
|
|
||||||
primary: string;
|
|
||||||
secondary: string;
|
|
||||||
metricA: string;
|
|
||||||
metricB: string;
|
|
||||||
metricC: string;
|
|
||||||
status: string;
|
|
||||||
extra: string;
|
|
||||||
time: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReportFilters = {
|
|
||||||
drawNo: string;
|
|
||||||
drawId: number | null;
|
|
||||||
number: string;
|
|
||||||
player: string;
|
|
||||||
playerId: number | null;
|
|
||||||
play: string;
|
|
||||||
operator: string;
|
|
||||||
operatorId: number | null;
|
|
||||||
dateFrom: string;
|
|
||||||
dateTo: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReportMeta = {
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
perPage: number;
|
|
||||||
lastPage: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReportResult =
|
|
||||||
| { key: "draw_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminDrawFinanceSummaryData }
|
|
||||||
| { key: "daily_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportDailyProfitRow[] }
|
|
||||||
| { key: "player_win_loss"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayerWinLossRow[] }
|
|
||||||
| { key: "player_transfer"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminTransferOrderItem[] }
|
|
||||||
| { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData }
|
|
||||||
| { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] }
|
|
||||||
| { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] }
|
|
||||||
| { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
|
|
||||||
|
|
||||||
type StatCard = {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
tone?: "default" | "good" | "warn" | "bad";
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchState = {
|
|
||||||
open: SearchKind | null;
|
|
||||||
query: string;
|
|
||||||
loading: boolean;
|
|
||||||
draws: AdminDrawListItem[];
|
|
||||||
players: AdminPlayerRow[];
|
|
||||||
operators: AdminUserPermissionRow[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type PlayOption = {
|
|
||||||
code: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyFilters: ReportFilters = {
|
|
||||||
drawNo: "",
|
|
||||||
drawId: null,
|
|
||||||
number: "",
|
|
||||||
player: "",
|
|
||||||
playerId: null,
|
|
||||||
play: "",
|
|
||||||
operator: "",
|
|
||||||
operatorId: null,
|
|
||||||
dateFrom: "",
|
|
||||||
dateTo: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
function isoDateLocal(date: Date): string {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultReportPeriod(): Pick<ReportFilters, "dateFrom" | "dateTo"> {
|
|
||||||
const to = new Date();
|
|
||||||
const from = new Date();
|
|
||||||
from.setDate(from.getDate() - 29);
|
|
||||||
return { dateFrom: isoDateLocal(from), dateTo: isoDateLocal(to) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultFilters(): ReportFilters {
|
|
||||||
return { ...emptyFilters, ...defaultReportPeriod() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportHasPeriodField(report: ReportDefinition): boolean {
|
|
||||||
return report.fields.includes("period");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDisplayCurrency(apiCode?: string | null): string {
|
|
||||||
const trimmed = apiCode?.trim();
|
|
||||||
if (trimmed) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
const fallback = getCachedAdminCurrencies().find((row) => row.is_enabled && row.is_bettable)?.code;
|
|
||||||
return fallback?.trim() || "NPR";
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptySearch: SearchState = {
|
|
||||||
open: null,
|
|
||||||
query: "",
|
|
||||||
loading: false,
|
|
||||||
draws: [],
|
|
||||||
players: [],
|
|
||||||
operators: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
function categoryTone(category: ReportCategory): string {
|
|
||||||
switch (category) {
|
|
||||||
case "wallet":
|
|
||||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
|
||||||
case "risk":
|
|
||||||
return "border-red-200 bg-red-50 text-red-700";
|
|
||||||
case "audit":
|
|
||||||
return "border-slate-200 bg-slate-50 text-slate-700";
|
|
||||||
default:
|
|
||||||
return "border-blue-200 bg-blue-50 text-blue-700";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statTone(tone: StatCard["tone"]): string {
|
|
||||||
switch (tone) {
|
|
||||||
case "good":
|
|
||||||
return "border-emerald-200 bg-emerald-50/70 text-emerald-900";
|
|
||||||
case "warn":
|
|
||||||
return "border-amber-200 bg-amber-50/70 text-amber-950";
|
|
||||||
case "bad":
|
|
||||||
return "border-red-200 bg-red-50/70 text-red-950";
|
|
||||||
default:
|
|
||||||
return "border-border/70 bg-card text-foreground";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatKind(kind: FilterKind, t: (key: string) => string): string {
|
|
||||||
return t(`filters.${kind}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadBlob(blob: Blob, filename: string): void {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement("a");
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFilenamePart(value: string): string {
|
|
||||||
return value.trim().replace(/[\\/:*?"<>|\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExportInstant(iso: string | null | undefined): ExportCell {
|
|
||||||
return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDailyProfitRowsAndSummary(
|
|
||||||
items: AdminReportDailyProfitRow[],
|
|
||||||
total: number,
|
|
||||||
t: (key: string) => string,
|
|
||||||
pageScopedLabel: (statKey: string) => string,
|
|
||||||
currencyCode: string,
|
|
||||||
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "rows" | "summary"> {
|
|
||||||
let totalBet = 0;
|
|
||||||
let totalPayout = 0;
|
|
||||||
let totalGross = 0;
|
|
||||||
|
|
||||||
const rows = items.map((item) => {
|
|
||||||
totalBet += item.total_bet_minor;
|
|
||||||
totalPayout += item.total_payout_minor;
|
|
||||||
totalGross += item.approx_house_gross_minor;
|
|
||||||
return {
|
|
||||||
business_date: item.business_date,
|
|
||||||
total_bet_minor: item.total_bet_minor,
|
|
||||||
total_payout_minor: item.total_payout_minor,
|
|
||||||
approx_house_gross_minor: item.approx_house_gross_minor,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows,
|
|
||||||
summary: [
|
|
||||||
{ label: t("preview.stats.records"), value: String(total) },
|
|
||||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
|
|
||||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
|
|
||||||
{
|
|
||||||
label: pageScopedLabel("houseGross"),
|
|
||||||
value: formatPlainMoney(totalGross, currencyCode),
|
|
||||||
tone: totalGross >= 0 ? "good" : "bad",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlayerTransferRowsAndSummary(
|
|
||||||
items: AdminTransferOrderItem[],
|
|
||||||
total: number,
|
|
||||||
page: number,
|
|
||||||
perPage: number,
|
|
||||||
t: (key: string) => string,
|
|
||||||
pageScopedLabel: (statKey: string) => string,
|
|
||||||
): Pick<Extract<ReportResult, { key: "player_transfer" }>, "rows" | "summary" | "meta"> {
|
|
||||||
let transferInCount = 0;
|
|
||||||
let transferOutCount = 0;
|
|
||||||
|
|
||||||
const rows = items.map((item) => {
|
|
||||||
if (item.direction === "in") {
|
|
||||||
transferInCount += 1;
|
|
||||||
} else if (item.direction === "out") {
|
|
||||||
transferOutCount += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
transfer_no: item.transfer_no,
|
|
||||||
player_id: item.player_id,
|
|
||||||
username: item.username,
|
|
||||||
nickname: item.nickname,
|
|
||||||
direction: item.direction,
|
|
||||||
currency_code: item.currency_code,
|
|
||||||
amount: item.amount,
|
|
||||||
status: item.status,
|
|
||||||
external_ref_no: item.external_ref_no,
|
|
||||||
fail_reason: item.fail_reason,
|
|
||||||
created_at: formatExportInstant(item.created_at),
|
|
||||||
finished_at: formatExportInstant(item.finished_at),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows,
|
|
||||||
meta: { total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
|
||||||
summary: [
|
|
||||||
{ label: t("preview.stats.records"), value: String(total) },
|
|
||||||
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
|
||||||
{ label: pageScopedLabel("transferIn"), value: String(transferInCount), tone: "good" },
|
|
||||||
{ label: pageScopedLabel("transferOut"), value: String(transferOutCount), tone: "warn" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlayDimensionRowsAndSummary(
|
|
||||||
items: AdminReportPlayDimensionRow[],
|
|
||||||
total: number,
|
|
||||||
t: (key: string) => string,
|
|
||||||
pageScopedLabel: (statKey: string) => string,
|
|
||||||
currencyCode: string,
|
|
||||||
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
|
|
||||||
let totalBet = 0;
|
|
||||||
let totalPayout = 0;
|
|
||||||
|
|
||||||
const rows = items.map((item) => {
|
|
||||||
totalBet += item.total_bet_minor;
|
|
||||||
totalPayout += item.total_payout_minor;
|
|
||||||
return {
|
|
||||||
play_code: item.play_code,
|
|
||||||
dimension: item.dimension,
|
|
||||||
total_bet_minor: item.total_bet_minor,
|
|
||||||
total_payout_minor: item.total_payout_minor,
|
|
||||||
approx_house_gross_minor: item.approx_house_gross_minor,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows,
|
|
||||||
summary: [
|
|
||||||
{ label: t("preview.stats.records"), value: String(total) },
|
|
||||||
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
|
||||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
|
|
||||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
|
|
||||||
return {
|
|
||||||
total: meta.total,
|
|
||||||
page: meta.current_page,
|
|
||||||
perPage: meta.per_page,
|
|
||||||
lastPage: meta.last_page,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPlainMoney(value: number, currencyCode: string | null | undefined): string {
|
|
||||||
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
|
||||||
}
|
|
||||||
|
|
||||||
function signedProfitCell(amount: number, currencyCode: string | null | undefined): string {
|
|
||||||
return cn("text-center tabular-nums", signedMoneyClass(amount, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUsagePercent(ratio: number | null | undefined): string {
|
|
||||||
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionText(...parts: Array<string | number | null | undefined>): string {
|
|
||||||
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportListParams(
|
|
||||||
filters: ReportFilters,
|
|
||||||
page: number,
|
|
||||||
perPage: number,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
per_page: perPage,
|
|
||||||
date_from: filters.dateFrom || undefined,
|
|
||||||
date_to: filters.dateTo || undefined,
|
|
||||||
player_id: filters.playerId ?? undefined,
|
|
||||||
play_code: filters.play.trim() || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePositiveInteger(value: string): number | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!/^\d+$/.test(trimmed)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = Number(trimmed);
|
|
||||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveDraw(
|
|
||||||
filters: ReportFilters,
|
|
||||||
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
|
|
||||||
): Promise<{ id: number; draw_no: string }> {
|
|
||||||
if (filters.drawId != null && filters.drawId > 0) {
|
|
||||||
const drawNo = filters.drawNo.trim();
|
|
||||||
return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const drawNo = filters.drawNo.trim();
|
|
||||||
if (!drawNo) {
|
|
||||||
throw new LotteryApiBizError(messages.drawNoRequired, -1, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
|
||||||
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
|
||||||
if (!matched) {
|
|
||||||
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
|
|
||||||
}
|
|
||||||
return { id: matched.id, draw_no: matched.draw_no };
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawRowsFromSummary(summary: AdminDrawFinanceSummaryData): ExportRow[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
row_type: "summary",
|
|
||||||
draw_id: summary.draw_id,
|
|
||||||
draw_no: summary.draw_no,
|
|
||||||
draw_status: summary.draw_status,
|
|
||||||
currency_code: summary.currency_code,
|
|
||||||
order_count: summary.order_count,
|
|
||||||
ticket_item_count: summary.ticket_item_count,
|
|
||||||
total_bet_minor: summary.total_bet_minor,
|
|
||||||
total_win_payout_minor: summary.total_win_payout_minor,
|
|
||||||
total_jackpot_win_minor: summary.total_jackpot_win_minor,
|
|
||||||
total_payout_minor: summary.total_payout_minor,
|
|
||||||
approx_house_gross_minor: summary.approx_house_gross_minor,
|
|
||||||
},
|
|
||||||
...summary.settlement_batches.map((batch) => ({
|
|
||||||
row_type: "settlement_batch",
|
|
||||||
draw_id: summary.draw_id,
|
|
||||||
draw_no: summary.draw_no,
|
|
||||||
settlement_batch_id: batch.id,
|
|
||||||
settlement_status: batch.status,
|
|
||||||
total_ticket_count: batch.total_ticket_count,
|
|
||||||
total_win_count: batch.total_win_count,
|
|
||||||
total_payout_amount: batch.total_payout_amount,
|
|
||||||
total_jackpot_payout_amount: batch.total_jackpot_payout_amount,
|
|
||||||
finished_at: formatExportInstant(batch.finished_at),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultRowCount(result: ReportResult | null): number {
|
|
||||||
return result?.rows.length ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultSummaryCards(
|
|
||||||
reportKey: ReportKey,
|
|
||||||
filters: ReportFilters,
|
|
||||||
t: (key: string) => string,
|
|
||||||
): StatCard[] {
|
|
||||||
const periodLabel =
|
|
||||||
filters.dateFrom && filters.dateTo
|
|
||||||
? `${filters.dateFrom} ~ ${filters.dateTo}`
|
|
||||||
: filters.dateFrom || filters.dateTo || t("preview.stats.notQueried");
|
|
||||||
|
|
||||||
switch (reportKey) {
|
|
||||||
case "draw_profit":
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
|
||||||
];
|
|
||||||
case "daily_profit":
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("fields.period"), value: periodLabel },
|
|
||||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
|
||||||
];
|
|
||||||
case "player_win_loss":
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
|
|
||||||
{ label: t("preview.stats.players"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") },
|
|
||||||
];
|
|
||||||
case "player_transfer":
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("fields.player"), value: filters.player || t("preview.stats.notSet") },
|
|
||||||
{ label: t("preview.stats.transferIn"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.transferOut"), value: t("preview.stats.notQueried") },
|
|
||||||
];
|
|
||||||
case "hot_number_risk":
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
|
||||||
{ label: t("fields.number"), value: filters.number || t("preview.stats.notSet") },
|
|
||||||
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.logs"), value: t("preview.stats.notQueried") },
|
|
||||||
];
|
|
||||||
case "play_dimension":
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("fields.play"), value: filters.play || t("filterAll") },
|
|
||||||
{ label: t("preview.stats.bet"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.payout"), value: t("preview.stats.notQueried") },
|
|
||||||
];
|
|
||||||
case "sold_out_number":
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
|
||||||
{ label: t("preview.stats.currency"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
|
|
||||||
];
|
|
||||||
case "admin_audit":
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") },
|
|
||||||
{ label: t("preview.stats.modules"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.operators"), value: t("preview.stats.notQueried") },
|
|
||||||
];
|
|
||||||
default:
|
|
||||||
return [
|
|
||||||
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") },
|
|
||||||
{ label: t("preview.stats.exportRows"), value: "0" },
|
|
||||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
|
|
||||||
const { t, i18n } = useTranslation(["reports", "common"]);
|
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
|
|
||||||
const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]);
|
|
||||||
const permissionSlugs = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
|
const permissionSlugs = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
|
||||||
|
const canExportReports = adminHasAnyPermission(permissionSlugs, [PRD_REPORT_EXPORT]);
|
||||||
|
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
useAdminPlayTypeCatalog();
|
useAdminPlayTypeCatalog();
|
||||||
const playCodeLabel = useAdminPlayCodeLabel();
|
const playOptions = useCachedPlayTypeOptions();
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const tRef = useTranslationRef(["reports", "common"]);
|
||||||
const filteredReports = useMemo(() => {
|
const searchParams = useSearchParams();
|
||||||
const visible = REPORTS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny));
|
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
||||||
return initialCategory ? visible.filter((report) => report.category === initialCategory) : visible;
|
|
||||||
}, [initialCategory, permissionSlugs]);
|
const visibleReports = useMemo(
|
||||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(
|
() => REPORT_DEFINITIONS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny)),
|
||||||
filteredReports[0]?.key ?? REPORTS[0].key,
|
[permissionSlugs],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const categoriesWithReports = useMemo(
|
||||||
|
() => REPORT_CATEGORY_ORDER.filter((cat) => visibleReports.some((report) => report.category === cat)),
|
||||||
|
[visibleReports],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCategory = categoriesWithReports.includes(initialCategory)
|
||||||
|
? initialCategory
|
||||||
|
: (categoriesWithReports[0] ?? "profit");
|
||||||
|
|
||||||
|
const categoryReports = useMemo(
|
||||||
|
() => visibleReports.filter((report) => report.category === activeCategory),
|
||||||
|
[activeCategory, visibleReports],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedKey, setSelectedKey] = useState<ReportKey>(categoryReports[0]?.key ?? "daily_profit");
|
||||||
const [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
|
const [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
|
||||||
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
|
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
|
||||||
const [result, setResult] = useState<ReportResult | null>(null);
|
const [result, setResult] = useState<ReportResult | null>(null);
|
||||||
@@ -630,128 +138,26 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
const [perPage, setPerPage] = useState(20);
|
const [perPage, setPerPage] = useState(20);
|
||||||
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
||||||
const [search, setSearch] = useState<SearchState>(emptySearch);
|
const [search, setSearch] = useState<SearchState>(emptySearch);
|
||||||
const playOptions = useCachedPlayTypeOptions();
|
|
||||||
const tRef = useTranslationRef(["reports", "common"]);
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
|
||||||
|
|
||||||
const selectedReport = filteredReports.find((report) => report.key === selectedKey) ?? filteredReports[0] ?? REPORTS[0];
|
const selectedReport =
|
||||||
|
categoryReports.find((report) => report.key === selectedKey) ?? categoryReports[0] ?? REPORT_DEFINITIONS[0];
|
||||||
|
|
||||||
|
const canQuerySelected = adminHasAnyPermission(permissionSlugs, selectedReport.requiredAny);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filteredReports.some((report) => report.key === selectedKey)) {
|
if (categoryReports.length === 0) {
|
||||||
setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key);
|
return;
|
||||||
}
|
}
|
||||||
}, [filteredReports, selectedKey]);
|
if (!categoryReports.some((report) => report.key === selectedKey)) {
|
||||||
|
setSelectedKey(categoryReports[0].key);
|
||||||
|
}
|
||||||
|
}, [categoryReports, selectedKey]);
|
||||||
|
|
||||||
const pageScopedLabel = useCallback(
|
const pageScopedLabel = useCallback(
|
||||||
(statKey: string) => t(`preview.stats.${statKey}`),
|
(statKey: string) => t(`preview.stats.${statKey}`),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const previewColumns = useMemo<PreviewColumns>(() => {
|
|
||||||
switch (selectedReport.key) {
|
|
||||||
case "draw_profit":
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.drawProfit.primary"),
|
|
||||||
secondary: t("preview.columns.drawProfit.secondary"),
|
|
||||||
metricA: t("preview.columns.drawProfit.metricA"),
|
|
||||||
metricB: t("preview.columns.drawProfit.metricB"),
|
|
||||||
metricC: t("preview.columns.drawProfit.metricC"),
|
|
||||||
status: t("preview.columns.drawProfit.status"),
|
|
||||||
extra: t("preview.columns.drawProfit.extra"),
|
|
||||||
time: t("preview.columns.drawProfit.time"),
|
|
||||||
};
|
|
||||||
case "daily_profit":
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.dailyProfit.primary"),
|
|
||||||
secondary: t("preview.columns.dailyProfit.secondary"),
|
|
||||||
metricA: t("preview.columns.dailyProfit.metricA"),
|
|
||||||
metricB: t("preview.columns.dailyProfit.metricB"),
|
|
||||||
metricC: t("preview.columns.dailyProfit.metricC"),
|
|
||||||
status: t("preview.columns.dailyProfit.status"),
|
|
||||||
extra: t("preview.columns.dailyProfit.extra"),
|
|
||||||
time: t("preview.columns.dailyProfit.time"),
|
|
||||||
};
|
|
||||||
case "player_win_loss":
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.playerWinLoss.primary"),
|
|
||||||
secondary: t("agentColumns.agent", { ns: "common" }),
|
|
||||||
metricA: t("preview.columns.playerWinLoss.metricA"),
|
|
||||||
metricB: t("preview.columns.playerWinLoss.metricB"),
|
|
||||||
metricC: t("preview.columns.playerWinLoss.metricC"),
|
|
||||||
status: t("preview.columns.playerWinLoss.status"),
|
|
||||||
extra: t("preview.columns.playerWinLoss.extra"),
|
|
||||||
time: t("preview.columns.playerWinLoss.time"),
|
|
||||||
};
|
|
||||||
case "player_transfer":
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.playerTransfer.primary"),
|
|
||||||
secondary: t("preview.columns.playerTransfer.secondary"),
|
|
||||||
metricA: t("preview.columns.playerTransfer.metricA"),
|
|
||||||
metricB: t("preview.columns.playerTransfer.metricB"),
|
|
||||||
metricC: t("preview.columns.playerTransfer.metricC"),
|
|
||||||
status: t("preview.columns.playerTransfer.status"),
|
|
||||||
extra: t("preview.columns.playerTransfer.extra"),
|
|
||||||
time: t("preview.columns.playerTransfer.time"),
|
|
||||||
};
|
|
||||||
case "hot_number_risk":
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.hotNumberRisk.primary"),
|
|
||||||
secondary: t("preview.columns.hotNumberRisk.secondary"),
|
|
||||||
metricA: t("preview.columns.hotNumberRisk.metricA"),
|
|
||||||
metricB: t("preview.columns.hotNumberRisk.metricB"),
|
|
||||||
metricC: t("preview.columns.hotNumberRisk.metricC"),
|
|
||||||
status: t("preview.columns.hotNumberRisk.status"),
|
|
||||||
extra: t("preview.columns.hotNumberRisk.extra"),
|
|
||||||
time: t("preview.columns.hotNumberRisk.time"),
|
|
||||||
};
|
|
||||||
case "play_dimension":
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.playDimension.primary"),
|
|
||||||
secondary: t("preview.columns.playDimension.secondary"),
|
|
||||||
metricA: t("preview.columns.playDimension.metricA"),
|
|
||||||
metricB: t("preview.columns.playDimension.metricB"),
|
|
||||||
metricC: t("preview.columns.playDimension.metricC"),
|
|
||||||
status: t("preview.columns.playDimension.status"),
|
|
||||||
extra: t("preview.columns.playDimension.extra"),
|
|
||||||
time: t("preview.columns.playDimension.time"),
|
|
||||||
};
|
|
||||||
case "sold_out_number":
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.soldOut.primary"),
|
|
||||||
secondary: t("preview.columns.soldOut.secondary"),
|
|
||||||
metricA: t("preview.columns.soldOut.metricA"),
|
|
||||||
metricB: t("preview.columns.soldOut.metricB"),
|
|
||||||
metricC: t("preview.columns.soldOut.metricC"),
|
|
||||||
status: t("preview.columns.soldOut.status"),
|
|
||||||
extra: t("preview.columns.soldOut.extra"),
|
|
||||||
time: t("preview.columns.soldOut.time"),
|
|
||||||
};
|
|
||||||
case "admin_audit":
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.adminAudit.primary"),
|
|
||||||
secondary: t("preview.columns.adminAudit.secondary"),
|
|
||||||
metricA: t("preview.columns.adminAudit.metricA"),
|
|
||||||
metricB: t("preview.columns.adminAudit.metricB"),
|
|
||||||
metricC: t("preview.columns.adminAudit.metricC"),
|
|
||||||
status: t("preview.columns.adminAudit.status"),
|
|
||||||
extra: t("preview.columns.adminAudit.extra"),
|
|
||||||
time: t("preview.columns.adminAudit.time"),
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
primary: t("preview.columns.primary"),
|
|
||||||
secondary: t("preview.columns.secondary"),
|
|
||||||
metricA: t("preview.columns.metricA"),
|
|
||||||
metricB: t("preview.columns.metricB"),
|
|
||||||
metricC: t("preview.columns.metricC"),
|
|
||||||
status: t("preview.columns.status"),
|
|
||||||
extra: t("preview.columns.extra"),
|
|
||||||
time: t("preview.columns.time"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [selectedReport.key, t]);
|
|
||||||
|
|
||||||
const exportFileBase = useMemo(() => {
|
const exportFileBase = useMemo(() => {
|
||||||
const segments: string[] = [selectedReport.key];
|
const segments: string[] = [selectedReport.key];
|
||||||
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
||||||
@@ -795,7 +201,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
}, [search.open, search.query, loadSearchOptions]);
|
}, [search.open, search.query, loadSearchOptions]);
|
||||||
|
|
||||||
const queryReport = useCallback(async () => {
|
const queryReport = useCallback(async () => {
|
||||||
if (!canViewReports) {
|
if (!canQuerySelected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -816,8 +222,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
rows: drawRowsFromSummary(summary),
|
rows: drawRowsFromSummary(summary),
|
||||||
meta: null,
|
meta: null,
|
||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(summary.total_bet_minor, summary.currency_code) },
|
|
||||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(summary.total_payout_minor, summary.currency_code) },
|
|
||||||
{
|
{
|
||||||
label: t("preview.stats.houseGross"),
|
label: t("preview.stats.houseGross"),
|
||||||
value: formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code),
|
value: formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code),
|
||||||
@@ -829,12 +233,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "daily_profit": {
|
case "daily_profit": {
|
||||||
const payload = await getAdminReportDailyProfit(
|
const payload = await getAdminReportDailyProfit(reportListParams(filters, page, perPage));
|
||||||
reportListParams(filters, page, perPage),
|
|
||||||
);
|
|
||||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||||
setDisplayCurrency(currencyCode);
|
setDisplayCurrency(currencyCode);
|
||||||
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
const next = buildDailyProfitRowsAndSummary(
|
||||||
|
payload.items,
|
||||||
|
payload.meta.total,
|
||||||
|
t,
|
||||||
|
pageScopedLabel,
|
||||||
|
currencyCode,
|
||||||
|
);
|
||||||
setResult({
|
setResult({
|
||||||
key: "daily_profit",
|
key: "daily_profit",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
@@ -845,38 +253,28 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "player_win_loss": {
|
case "player_win_loss": {
|
||||||
const payload = await getAdminReportPlayerWinLoss(
|
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
|
||||||
reportListParams(filters, page, perPage),
|
|
||||||
);
|
|
||||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||||
setDisplayCurrency(currencyCode);
|
setDisplayCurrency(currencyCode);
|
||||||
const rows = payload.items.map((item) => ({
|
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
|
||||||
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,
|
|
||||||
}));
|
|
||||||
setResult({
|
setResult({
|
||||||
key: "player_win_loss",
|
key: "player_win_loss",
|
||||||
raw: payload.items,
|
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),
|
meta: metaFromList(payload.meta),
|
||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
|
||||||
{
|
{
|
||||||
label: pageScopedLabel("houseGross"),
|
label: pageScopedLabel("houseGross"),
|
||||||
value: formatPlainMoney(
|
value: formatPlainMoney(houseGross, currencyCode),
|
||||||
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
tone: houseGross >= 0 ? "good" : "bad",
|
||||||
currencyCode,
|
|
||||||
),
|
|
||||||
tone: (() => {
|
|
||||||
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
|
|
||||||
return houseGross >= 0 ? "good" : "bad";
|
|
||||||
})(),
|
|
||||||
},
|
},
|
||||||
{ label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -897,7 +295,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
payload.page,
|
payload.page,
|
||||||
payload.per_page,
|
payload.per_page,
|
||||||
t,
|
t,
|
||||||
pageScopedLabel,
|
|
||||||
);
|
);
|
||||||
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
|
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
|
||||||
setResult({
|
setResult({
|
||||||
@@ -920,45 +317,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
});
|
});
|
||||||
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
||||||
setDisplayCurrency(resolveDisplayCurrency(detail.currency_code));
|
setDisplayCurrency(resolveDisplayCurrency(detail.currency_code));
|
||||||
const rows: ExportRow[] = [
|
|
||||||
{
|
|
||||||
row_type: "risk_pool",
|
|
||||||
draw_id: detail.draw_id,
|
|
||||||
draw_no: detail.draw_no,
|
|
||||||
number: detail.pool.normalized_number,
|
|
||||||
total_cap_amount: detail.pool.total_cap_amount,
|
|
||||||
locked_amount: detail.pool.locked_amount,
|
|
||||||
remaining_amount: detail.pool.remaining_amount,
|
|
||||||
sold_out_status: detail.pool.sold_out_status,
|
|
||||||
is_sold_out: detail.pool.is_sold_out ? 1 : 0,
|
|
||||||
usage_ratio: detail.pool.usage_ratio,
|
|
||||||
version: detail.pool.version,
|
|
||||||
},
|
|
||||||
...detail.logs.items.map((item) => ({
|
|
||||||
row_type: "lock_log",
|
|
||||||
draw_id: detail.draw_id,
|
|
||||||
draw_no: detail.draw_no,
|
|
||||||
number: detail.pool.normalized_number,
|
|
||||||
log_id: item.id,
|
|
||||||
action_type: item.action_type,
|
|
||||||
amount: item.amount,
|
|
||||||
source_reason: item.source_reason,
|
|
||||||
ticket_item_id: item.ticket_item_id,
|
|
||||||
ticket_no: item.ticket_no,
|
|
||||||
play_code: item.play_code,
|
|
||||||
player_id: item.player_id,
|
|
||||||
created_at: formatExportInstant(item.created_at),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
setResult({
|
setResult({
|
||||||
key: "hot_number_risk",
|
key: "hot_number_risk",
|
||||||
raw: detail,
|
raw: detail,
|
||||||
rows,
|
rows: [],
|
||||||
meta: metaFromList(detail.logs.meta),
|
meta: metaFromList(detail.logs.meta),
|
||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
|
{
|
||||||
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
|
label: t("preview.stats.usage"),
|
||||||
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
value: formatUsagePercent(detail.pool.usage_ratio),
|
||||||
|
tone: detail.pool.is_sold_out ? "bad" : "warn",
|
||||||
|
},
|
||||||
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -970,42 +339,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
drawNoNotFound: (drawNo) =>
|
drawNoNotFound: (drawNo) =>
|
||||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||||
});
|
});
|
||||||
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
const payload = await getAdminRiskPools(draw.id, {
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
sold_out_only: true,
|
||||||
|
sort: "number_asc",
|
||||||
|
});
|
||||||
setDisplayCurrency(resolveDisplayCurrency(payload.currency_code));
|
setDisplayCurrency(resolveDisplayCurrency(payload.currency_code));
|
||||||
const rows = payload.items.map((item) => ({
|
|
||||||
draw_id: payload.draw_id,
|
|
||||||
draw_no: payload.draw_no,
|
|
||||||
currency_code: payload.currency_code,
|
|
||||||
normalized_number: item.normalized_number,
|
|
||||||
total_cap_amount: item.total_cap_amount,
|
|
||||||
locked_amount: item.locked_amount,
|
|
||||||
remaining_amount: item.remaining_amount,
|
|
||||||
sold_out_status: item.sold_out_status,
|
|
||||||
is_sold_out: item.is_sold_out ? 1 : 0,
|
|
||||||
usage_ratio: item.usage_ratio,
|
|
||||||
version: item.version,
|
|
||||||
}));
|
|
||||||
setResult({
|
setResult({
|
||||||
key: "sold_out_number",
|
key: "sold_out_number",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
rows,
|
rows: payload.items.map((item) => ({ normalized_number: item.normalized_number })),
|
||||||
meta: metaFromList(payload.meta),
|
meta: metaFromList(payload.meta),
|
||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total), tone: payload.meta.total > 0 ? "bad" : "good" },
|
{
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
label: t("preview.stats.records"),
|
||||||
|
value: String(payload.meta.total),
|
||||||
|
tone: payload.meta.total > 0 ? "bad" : "good",
|
||||||
|
},
|
||||||
{ label: t("preview.stats.drawNo"), value: payload.draw_no },
|
{ label: t("preview.stats.drawNo"), value: payload.draw_no },
|
||||||
{ label: t("preview.stats.currency"), value: payload.currency_code || "-" },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "play_dimension": {
|
case "play_dimension": {
|
||||||
const payload = await getAdminReportPlayDimension(
|
const payload = await getAdminReportPlayDimension(reportListParams(filters, page, perPage));
|
||||||
reportListParams(filters, page, perPage),
|
|
||||||
);
|
|
||||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||||
setDisplayCurrency(currencyCode);
|
setDisplayCurrency(currencyCode);
|
||||||
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, currencyCode);
|
||||||
setResult({
|
setResult({
|
||||||
key: "play_dimension",
|
key: "play_dimension",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
@@ -1025,28 +386,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
start_date: filters.dateFrom || undefined,
|
start_date: filters.dateFrom || undefined,
|
||||||
end_date: filters.dateTo || undefined,
|
end_date: filters.dateTo || undefined,
|
||||||
});
|
});
|
||||||
const rows = payload.items.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
operator_type: item.operator_type,
|
|
||||||
operator_id: item.operator_id,
|
|
||||||
module_code: item.module_code,
|
|
||||||
action_code: item.action_code,
|
|
||||||
target_type: item.target_type,
|
|
||||||
target_id: item.target_id,
|
|
||||||
ip: item.ip,
|
|
||||||
user_agent: item.user_agent,
|
|
||||||
created_at: formatExportInstant(item.created_at),
|
|
||||||
}));
|
|
||||||
setResult({
|
setResult({
|
||||||
key: "admin_audit",
|
key: "admin_audit",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
rows,
|
rows: payload.items.map((item) => ({ id: item.id })),
|
||||||
meta: metaFromList(payload.meta),
|
meta: metaFromList(payload.meta),
|
||||||
summary: [
|
summary: [
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
{
|
||||||
{ label: t("preview.stats.modules"), value: String(new Set(payload.items.map((item) => item.module_code)).size) },
|
label: t("preview.stats.operators"),
|
||||||
{ label: t("preview.stats.operators"), value: String(new Set(payload.items.map((item) => item.operator_id)).size) },
|
value: String(new Set(payload.items.map((item) => item.operator_id)).size),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -1061,14 +411,12 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [canViewReports, filters, page, perPage, selectedReport]);
|
}, [canQuerySelected, filters, page, perPage, pageScopedLabel, selectedReport.key, t, tRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
setResult(null);
|
||||||
setResult(null);
|
setError(null);
|
||||||
setError(null);
|
setPage(1);
|
||||||
setPage(1);
|
|
||||||
});
|
|
||||||
}, [selectedKey]);
|
}, [selectedKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1076,25 +424,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
...prev,
|
...prev,
|
||||||
drawNo: drawNoFromUrl || prev.drawNo,
|
drawNo: drawNoFromUrl || prev.drawNo,
|
||||||
}));
|
}));
|
||||||
if (drawNoFromUrl && filteredReports.some((report) => report.key === "draw_profit")) {
|
if (drawNoFromUrl && visibleReports.some((report) => report.key === "draw_profit")) {
|
||||||
setSelectedKey("draw_profit");
|
setSelectedKey("draw_profit");
|
||||||
}
|
}
|
||||||
}, [drawNoFromUrl, filteredReports]);
|
}, [drawNoFromUrl, visibleReports]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
if (!result || result.key !== selectedReport.key || !selectedReport.connected) {
|
||||||
setResult(null);
|
return;
|
||||||
setError(null);
|
|
||||||
setPage(1);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (result && result.key === selectedReport.key && selectedReport.connected) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
void queryReport();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
void queryReport();
|
||||||
}, [page, perPage]);
|
}, [page, perPage]);
|
||||||
|
|
||||||
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
|
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
|
||||||
@@ -1152,42 +491,48 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
const open = search.open === kind;
|
const open = search.open === kind;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1.5">
|
<div className="min-w-[12rem] flex-1">
|
||||||
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label>
|
<Label className="sr-only" htmlFor={`report-${kind}`}>
|
||||||
|
{t(`fields.${labelKey}`)}
|
||||||
|
</Label>
|
||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
setSearch((prev) =>
|
setSearch((prev) =>
|
||||||
nextOpen
|
nextOpen ? { ...prev, open: kind, query: value } : emptySearch,
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
open: kind,
|
|
||||||
query: value,
|
|
||||||
}
|
|
||||||
: emptySearch,
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
modal={false}
|
modal={false}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1.5">
|
||||||
<Input
|
<Input
|
||||||
id={`report-${kind}`}
|
id={`report-${kind}`}
|
||||||
value={value}
|
className="h-8"
|
||||||
onChange={(e) => {
|
value={value}
|
||||||
const next = e.target.value;
|
onChange={(e) => {
|
||||||
if (kind === "draw") {
|
const next = e.target.value;
|
||||||
setFilters((prev) => ({ ...prev, drawNo: next, drawId: null }));
|
if (kind === "draw") {
|
||||||
} else if (kind === "player") {
|
setFilters((prev) => ({ ...prev, drawNo: next, drawId: null }));
|
||||||
setFilters((prev) => ({ ...prev, player: next, playerId: null }));
|
} else if (kind === "player") {
|
||||||
} else {
|
setFilters((prev) => ({ ...prev, player: next, playerId: null }));
|
||||||
setFilters((prev) => ({ ...prev, operator: next, operatorId: 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}`)}
|
<Search className="size-3.5" />
|
||||||
/>
|
|
||||||
<PopoverTrigger render={<Button type="button" variant="outline" className="shrink-0" aria-label={t("searchPicker.open")} />}>
|
|
||||||
<Search data-icon="inline-start" />
|
|
||||||
{t("searchPicker.select")}
|
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</div>
|
</div>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
@@ -1202,65 +547,70 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))}
|
onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 max-h-64 overflow-auto">
|
<div className="mt-2 max-h-64 overflow-auto">
|
||||||
{search.loading ? (
|
{search.loading ? <AdminLoadingInline className="py-2" /> : null}
|
||||||
<AdminLoadingInline className="py-2" />
|
{!search.loading && kind === "draw"
|
||||||
) : null}
|
? search.draws.map((item) => (
|
||||||
{!search.loading && kind === "draw" ? (
|
<button
|
||||||
search.draws.map((item) => (
|
key={item.id}
|
||||||
<button
|
type="button"
|
||||||
key={item.id}
|
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||||
type="button"
|
onClick={() => {
|
||||||
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
setFilters((prev) => ({ ...prev, drawNo: item.draw_no, drawId: item.id }));
|
||||||
onClick={() => {
|
setSearch(emptySearch);
|
||||||
setFilters((prev) => ({ ...prev, drawNo: item.draw_no, drawId: item.id }));
|
}}
|
||||||
setSearch(emptySearch);
|
>
|
||||||
}}
|
<span className="font-medium">{item.draw_no}</span>
|
||||||
>
|
<DrawStatusBadge
|
||||||
<span className="font-medium">{item.draw_no}</span>
|
status={item.status}
|
||||||
<DrawStatusBadge status={item.status} label={drawStatusLabel(item.status, t)} />
|
label={drawStatusLabel(item.status, t)}
|
||||||
</button>
|
/>
|
||||||
))
|
</button>
|
||||||
) : null}
|
))
|
||||||
{!search.loading && kind === "player" ? (
|
: null}
|
||||||
search.players.map((item) => (
|
{!search.loading && kind === "player"
|
||||||
<button
|
? search.players.map((item) => (
|
||||||
key={item.id}
|
<button
|
||||||
type="button"
|
key={item.id}
|
||||||
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
type="button"
|
||||||
onClick={() => {
|
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||||
setFilters((prev) => ({
|
onClick={() => {
|
||||||
...prev,
|
setFilters((prev) => ({
|
||||||
player: optionText(item.username, item.site_player_id, `ID ${item.id}`),
|
...prev,
|
||||||
playerId: item.id,
|
player: optionText(item.username, item.site_player_id, `ID ${item.id}`),
|
||||||
}));
|
playerId: item.id,
|
||||||
setSearch(emptySearch);
|
}));
|
||||||
}}
|
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>
|
<span className="min-w-0 truncate font-medium">
|
||||||
</button>
|
{optionText(item.username, item.nickname, item.site_player_id)}
|
||||||
))
|
</span>
|
||||||
) : null}
|
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
||||||
{!search.loading && kind === "operator" ? (
|
</button>
|
||||||
search.operators.map((item) => (
|
))
|
||||||
<button
|
: null}
|
||||||
key={item.id}
|
{!search.loading && kind === "operator"
|
||||||
type="button"
|
? search.operators.map((item) => (
|
||||||
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
<button
|
||||||
onClick={() => {
|
key={item.id}
|
||||||
setFilters((prev) => ({
|
type="button"
|
||||||
...prev,
|
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||||
operator: optionText(item.username, item.nickname, `ID ${item.id}`),
|
onClick={() => {
|
||||||
operatorId: item.id,
|
setFilters((prev) => ({
|
||||||
}));
|
...prev,
|
||||||
setSearch(emptySearch);
|
operator: optionText(item.username, item.nickname, `ID ${item.id}`),
|
||||||
}}
|
operatorId: item.id,
|
||||||
>
|
}));
|
||||||
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname)}</span>
|
setSearch(emptySearch);
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
}}
|
||||||
</button>
|
>
|
||||||
))
|
<span className="min-w-0 truncate font-medium">
|
||||||
) : null}
|
{optionText(item.username, item.nickname)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -1294,15 +644,21 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
}
|
}
|
||||||
if (field === "play") {
|
if (field === "play") {
|
||||||
return (
|
return (
|
||||||
<div key={field} className="grid gap-1.5">
|
<div key={field} className="min-w-[10rem] flex-1">
|
||||||
<Label htmlFor="report-play">{t("fields.play")}</Label>
|
<Label className="sr-only" htmlFor="report-play">
|
||||||
|
{t("fields.play")}
|
||||||
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
modal={false}
|
modal={false}
|
||||||
value={filters.play || "__none__"}
|
value={filters.play || "__none__"}
|
||||||
onValueChange={(value) => updateFilter("play", value === "__none__" ? "" : String(value))}
|
onValueChange={(value) => updateFilter("play", value === "__none__" ? "" : String(value))}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="report-play" className="h-8 w-full">
|
<SelectTrigger id="report-play" className="h-8 w-full">
|
||||||
<SelectValue>{filters.play ? playOptions.find((item) => item.code === filters.play)?.label ?? filters.play : t("placeholders.play")}</SelectValue>
|
<SelectValue>
|
||||||
|
{filters.play
|
||||||
|
? (playOptions.find((item) => item.code === filters.play)?.label ?? filters.play)
|
||||||
|
: t("filterAll")}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="start" sideOffset={6}>
|
<SelectContent align="start" sideOffset={6}>
|
||||||
<SelectItem value="__none__">{t("filterAll")}</SelectItem>
|
<SelectItem value="__none__">{t("filterAll")}</SelectItem>
|
||||||
@@ -1316,12 +672,14 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field} className="grid gap-1.5">
|
<div key={field} className="min-w-[8rem] flex-1">
|
||||||
<Label htmlFor={`report-${field}`}>{t(`fields.${field}`)}</Label>
|
<Label className="sr-only" htmlFor={`report-${field}`}>
|
||||||
|
{t(`fields.${field}`)}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`report-${field}`}
|
id={`report-${field}`}
|
||||||
|
className="h-8"
|
||||||
value={filters.number}
|
value={filters.number}
|
||||||
onChange={(e) => updateFilter("number", e.target.value)}
|
onChange={(e) => updateFilter("number", e.target.value)}
|
||||||
placeholder={t(`placeholders.${field}`)}
|
placeholder={t(`placeholders.${field}`)}
|
||||||
@@ -1330,321 +688,141 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTable = () => {
|
const stats = headlineStats(result, selectedReport.key);
|
||||||
if (!selectedReport.connected) {
|
const shortcutHref = CATEGORY_SHORTCUT_HREF[activeCategory];
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} className="text-muted-foreground">
|
|
||||||
{t("backendPending")}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<AdminTableLoadingRow colSpan={8} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} className="text-destructive">
|
|
||||||
{error}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!result || result.rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<AdminTableNoResourceRow colSpan={8} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.key === "draw_profit") {
|
if (visibleReports.length === 0) {
|
||||||
const summary = result.raw;
|
return <AdminNoResourceState message={t("empty")} />;
|
||||||
return (
|
}
|
||||||
<>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell className="font-medium">{summary.draw_no}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DrawStatusBadge status={summary.draw_status} label={drawStatusLabel(summary.draw_status, t)} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">{summary.order_count}</TableCell>
|
|
||||||
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
|
|
||||||
<TableCell className={signedProfitCell(summary.approx_house_gross_minor, summary.currency_code)}>
|
|
||||||
{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{summary.settlement_batches.length}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{summary.settlement_batches.map((batch) => (
|
|
||||||
<TableRow key={batch.id} className="bg-muted/15">
|
|
||||||
<TableCell>#{batch.id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<AdminStatusBadge status={batch.status}>{batch.status}</AdminStatusBadge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">{batch.total_ticket_count}</TableCell>
|
|
||||||
<TableCell className="text-center">{batch.total_win_count}</TableCell>
|
|
||||||
<TableCell className="text-center">-</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell>
|
|
||||||
<TableCell>{formatTs(batch.finished_at)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.key === "player_transfer") {
|
|
||||||
return result.raw.map((item) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="font-mono text-xs">{item.transfer_no}</TableCell>
|
|
||||||
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
|
|
||||||
<TableCell>{item.direction}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<AdminStatusBadge status={item.status}>{item.status}</AdminStatusBadge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">{item.currency_code} {item.amount}</TableCell>
|
|
||||||
<TableCell>{item.external_ref_no || "-"}</TableCell>
|
|
||||||
<TableCell>{item.fail_reason || "-"}</TableCell>
|
|
||||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.key === "hot_number_risk") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
|
|
||||||
<TableCell>{result.raw.draw_no}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
|
||||||
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
|
||||||
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
|
|
||||||
<TableCell>v{result.raw.pool.version}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{result.raw.logs.items.map((item) => (
|
|
||||||
<TableRow key={item.id} className="bg-muted/15">
|
|
||||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
|
||||||
<TableCell>{item.action_type}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
|
|
||||||
<TableCell>{playCodeLabel(item.play_code)}</TableCell>
|
|
||||||
<TableCell>{item.ticket_no || "-"}</TableCell>
|
|
||||||
<TableCell>{item.player_id || "-"}</TableCell>
|
|
||||||
<TableCell>{item.source_reason || "-"}</TableCell>
|
|
||||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.key === "sold_out_number") {
|
|
||||||
return result.raw.map((item) => (
|
|
||||||
<TableRow key={item.normalized_number}>
|
|
||||||
<TableCell className="font-medium">{item.normalized_number}</TableCell>
|
|
||||||
<TableCell>{filters.drawNo}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_cap_amount, displayCurrency)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, displayCurrency)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, displayCurrency)}</TableCell>
|
|
||||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
|
||||||
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
|
|
||||||
<TableCell>v{item.version}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.key === "daily_profit") {
|
|
||||||
return result.raw.map((item) => (
|
|
||||||
<TableRow key={item.business_date}>
|
|
||||||
<TableCell className="font-medium">{item.business_date}</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
|
||||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
|
|
||||||
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.key === "player_win_loss") {
|
|
||||||
return result.raw.map((item) => (
|
|
||||||
<TableRow key={item.player_id}>
|
|
||||||
<TableCell className="font-medium">{item.username}</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{adminAgentDisplayLabel(item)}
|
|
||||||
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
|
||||||
<TableCell className={signedProfitCell(item.net_win_loss_minor, displayCurrency)}>
|
|
||||||
{formatPlainMoney(item.net_win_loss_minor, displayCurrency)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.key === "play_dimension") {
|
|
||||||
return result.raw.map((item) => (
|
|
||||||
<TableRow key={`${item.play_code}-${item.dimension}`}>
|
|
||||||
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
|
|
||||||
<TableCell>{item.dimension}D</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
|
|
||||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
|
||||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
|
|
||||||
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.key === "admin_audit") {
|
|
||||||
return result.raw.map((item) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
|
||||||
<TableCell>{item.operator_type}</TableCell>
|
|
||||||
<TableCell>{item.operator_id}</TableCell>
|
|
||||||
<TableCell>{item.module_code}</TableCell>
|
|
||||||
<TableCell>{item.action_code}</TableCell>
|
|
||||||
<TableCell>{item.target_type || "-"}</TableCell>
|
|
||||||
<TableCell>{item.ip || "-"}</TableCell>
|
|
||||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
{filteredReports.length === 0 ? (
|
<AdminSubnav aria-label={t("categories.all")}>
|
||||||
<AdminNoResourceState message={t("empty")} />
|
{categoriesWithReports.map((category) => (
|
||||||
) : (
|
<AdminSubnavLink
|
||||||
<>
|
key={category}
|
||||||
<Card className="admin-list-card">
|
href={`/admin/reports/${category}`}
|
||||||
<CardHeader className="admin-list-header pb-3">
|
active={activeCategory === category}
|
||||||
<div className="flex flex-col gap-3">
|
>
|
||||||
<div className="flex flex-wrap gap-2">
|
{t(`categories.${category}`)}
|
||||||
{filteredReports.map((report) => {
|
</AdminSubnavLink>
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card className="admin-list-card">
|
<div className="rounded-lg border border-border/60 p-3">
|
||||||
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<div>
|
{selectedReport.fields.map(renderField)}
|
||||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
<div className="flex flex-wrap items-center gap-1.5 sm:ml-auto">
|
||||||
</div>
|
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={resetFilters}>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
{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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
disabled={!canExportReports || exporting !== null}
|
disabled={!canExportReports || exporting !== null}
|
||||||
onClick={() => void exportReport("csv")}
|
onClick={() => void exportReport("csv")}
|
||||||
>
|
>
|
||||||
<FileDown data-icon="inline-start" />
|
<FileDown className="size-3.5" data-icon="inline-start" />
|
||||||
{t("formats.csv")}
|
{t("formats.csv")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
disabled={!canExportReports || exporting !== null}
|
disabled={!canExportReports || exporting !== null}
|
||||||
onClick={() => void exportReport("excel")}
|
onClick={() => void exportReport("excel")}
|
||||||
>
|
>
|
||||||
<FileSpreadsheet data-icon="inline-start" />
|
<FileSpreadsheet className="size-3.5" data-icon="inline-start" />
|
||||||
{t("formats.excel")}
|
{t("formats.excel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-3 pt-3">
|
</div>
|
||||||
<Table id="reports-preview-table">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{previewColumns.primary}</TableHead>
|
|
||||||
<TableHead>{previewColumns.secondary}</TableHead>
|
|
||||||
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
|
||||||
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
|
||||||
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
|
||||||
<TableHead>{previewColumns.status}</TableHead>
|
|
||||||
<TableHead>{previewColumns.extra}</TableHead>
|
|
||||||
<TableHead>{previewColumns.time}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>{renderTable()}</TableBody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
{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
|
<AdminListPaginationFooter
|
||||||
selectId="reports-preview-per-page"
|
selectId="reports-preview-per-page"
|
||||||
total={result.meta.total}
|
total={result.meta.total}
|
||||||
@@ -1658,11 +836,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
}}
|
}}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</div>
|
||||||
</CardContent>
|
) : null}
|
||||||
</Card>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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 (
|
return (
|
||||||
<RulesPageShell>
|
<RulesPageShell>
|
||||||
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
||||||
<ConfigDocPage
|
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="pt-2">
|
||||||
title={t("nav.rulesOddsTitle")}
|
|
||||||
description={t("nav.rulesOddsDescriptionShort")}
|
|
||||||
contentClassName="pt-2"
|
|
||||||
>
|
|
||||||
<OddsConfigDocScreen embedded mergedLayout workspace={workspace} />
|
<OddsConfigDocScreen embedded mergedLayout workspace={workspace} />
|
||||||
</ConfigDocPage>
|
</ConfigDocPage>
|
||||||
</AdminPermissionGate>
|
</AdminPermissionGate>
|
||||||
|
|||||||
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 = {
|
type CurrencyFormState = {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
decimal_places: string;
|
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
is_bettable: boolean;
|
is_bettable: boolean;
|
||||||
};
|
};
|
||||||
@@ -54,7 +53,6 @@ type CurrencyFormState = {
|
|||||||
const EMPTY_FORM: CurrencyFormState = {
|
const EMPTY_FORM: CurrencyFormState = {
|
||||||
code: "",
|
code: "",
|
||||||
name: "",
|
name: "",
|
||||||
decimal_places: "2",
|
|
||||||
is_enabled: true,
|
is_enabled: true,
|
||||||
is_bettable: false,
|
is_bettable: false,
|
||||||
};
|
};
|
||||||
@@ -63,7 +61,6 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
|
|||||||
return {
|
return {
|
||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
decimal_places: String(row.decimal_places),
|
|
||||||
is_enabled: row.is_enabled,
|
is_enabled: row.is_enabled,
|
||||||
is_bettable: row.is_enabled && row.is_bettable,
|
is_bettable: row.is_enabled && row.is_bettable,
|
||||||
};
|
};
|
||||||
@@ -138,7 +135,6 @@ export function CurrencySettingsPanel() {
|
|||||||
async function handleSubmit(): Promise<void> {
|
async function handleSubmit(): Promise<void> {
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
decimal_places: Number.parseInt(form.decimal_places || "0", 10),
|
|
||||||
is_enabled: form.is_enabled,
|
is_enabled: form.is_enabled,
|
||||||
is_bettable: form.is_enabled && form.is_bettable,
|
is_bettable: form.is_enabled && form.is_bettable,
|
||||||
};
|
};
|
||||||
@@ -150,11 +146,6 @@ export function CurrencySettingsPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(payload.decimal_places) || payload.decimal_places < 0) {
|
|
||||||
toast.error(t("currencies.form.decimalInvalid", { ns: "config" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
@@ -229,7 +220,6 @@ export function CurrencySettingsPanel() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="whitespace-nowrap">{t("currencies.table.code", { ns: "config" })}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("currencies.table.code", { ns: "config" })}</TableHead>
|
||||||
<TableHead>{t("currencies.table.name", { ns: "config" })}</TableHead>
|
<TableHead>{t("currencies.table.name", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("currencies.table.decimals", { ns: "config" })}</TableHead>
|
|
||||||
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("currencies.table.actions", { ns: "config" })}</TableHead>
|
<TableHead className="sticky right-0 z-20 bg-muted w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("currencies.table.actions", { ns: "config" })}</TableHead>
|
||||||
@@ -238,18 +228,17 @@ export function CurrencySettingsPanel() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
|
||||||
{t("currencies.loading", { ns: "config" })}
|
{t("currencies.loading", { ns: "config" })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<AdminTableNoResourceRow colSpan={6} cellClassName="text-center" />
|
<AdminTableNoResourceRow colSpan={5} cellClassName="text-center" />
|
||||||
) : (
|
) : (
|
||||||
items.map((row) => (
|
items.map((row) => (
|
||||||
<TableRow key={row.code}>
|
<TableRow key={row.code}>
|
||||||
<TableCell className="font-mono">{row.code}</TableCell>
|
<TableCell className="font-mono">{row.code}</TableCell>
|
||||||
<TableCell>{row.name}</TableCell>
|
<TableCell>{row.name}</TableCell>
|
||||||
<TableCell>{row.decimal_places}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
|
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
|
||||||
{row.is_enabled
|
{row.is_enabled
|
||||||
@@ -329,21 +318,6 @@ export function CurrencySettingsPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="currency-decimals">{t("currencies.form.decimals", { ns: "config" })}</Label>
|
|
||||||
<Input
|
|
||||||
id="currency-decimals"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="12"
|
|
||||||
step="1"
|
|
||||||
value={form.decimal_places}
|
|
||||||
placeholder={t("currencies.form.decimalsPlaceholder", { ns: "config" })}
|
|
||||||
onChange={(e) => updateForm("decimal_places", e.target.value)}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
|
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">{t("currencies.form.enabled", { ns: "config" })}</p>
|
<p className="text-sm font-medium">{t("currencies.form.enabled", { ns: "config" })}</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const settingsModuleMeta = {
|
export const settingsModuleMeta = {
|
||||||
segment: "settings",
|
segment: "settings",
|
||||||
title: "系统设置",
|
title: "系统设置",
|
||||||
description: "管理影响钱包划转与跨模块行为的全局运行参数。",
|
description: "日常运营开关与限额在此调整。",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -6,43 +6,28 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||||
|
import { SettingsToggleRow } from "@/modules/settings/components/settings-toggle-row";
|
||||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||||
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
import { DRAW_KEYS } from "@/modules/settings/settings-keys";
|
||||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
|
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
interface DrawDraft {
|
interface DrawDraft {
|
||||||
defaultCurrency: string;
|
|
||||||
drawIntervalMinutes: string;
|
|
||||||
drawBettingWindowSeconds: string;
|
|
||||||
drawCloseBeforeDrawSeconds: string;
|
|
||||||
drawBufferDrawsAhead: string;
|
|
||||||
requireManualReview: boolean;
|
requireManualReview: boolean;
|
||||||
cooldownMinutes: string;
|
cooldownMinutes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL: DrawDraft = {
|
const INITIAL: DrawDraft = {
|
||||||
defaultCurrency: "NPR",
|
|
||||||
drawIntervalMinutes: "5",
|
|
||||||
drawBettingWindowSeconds: "270",
|
|
||||||
drawCloseBeforeDrawSeconds: "30",
|
|
||||||
drawBufferDrawsAhead: "8",
|
|
||||||
requireManualReview: false,
|
requireManualReview: false,
|
||||||
cooldownMinutes: "15",
|
cooldownMinutes: "15",
|
||||||
};
|
};
|
||||||
|
|
||||||
function fromKv(kv: Record<string, unknown>): DrawDraft {
|
function fromKv(kv: Record<string, unknown>): DrawDraft {
|
||||||
return {
|
return {
|
||||||
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
|
||||||
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
|
||||||
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
|
||||||
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
|
||||||
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
|
||||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||||
};
|
};
|
||||||
@@ -50,43 +35,16 @@ function fromKv(kv: Record<string, unknown>): DrawDraft {
|
|||||||
|
|
||||||
function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
|
function buildDirtyItems(draft: DrawDraft, saved: DrawDraft): AdminSettingBatchItem[] {
|
||||||
const items: AdminSettingBatchItem[] = [];
|
const items: AdminSettingBatchItem[] = [];
|
||||||
const push = (key: string, value: unknown, changed: boolean) => {
|
|
||||||
if (changed) {
|
|
||||||
items.push({ key, value });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
push(
|
if (draft.requireManualReview !== saved.requireManualReview) {
|
||||||
DRAW_KEYS.DEFAULT_CURRENCY,
|
items.push({ key: DRAW_KEYS.REQUIRE_MANUAL_REVIEW, value: draft.requireManualReview });
|
||||||
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
}
|
||||||
draft.defaultCurrency !== saved.defaultCurrency,
|
if (draft.cooldownMinutes !== saved.cooldownMinutes) {
|
||||||
);
|
items.push({
|
||||||
push(
|
key: DRAW_KEYS.COOLDOWN_MINUTES,
|
||||||
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
value: Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||||
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
});
|
||||||
draft.drawIntervalMinutes !== saved.drawIntervalMinutes,
|
}
|
||||||
);
|
|
||||||
push(
|
|
||||||
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
|
||||||
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
|
||||||
draft.drawBettingWindowSeconds !== saved.drawBettingWindowSeconds,
|
|
||||||
);
|
|
||||||
push(
|
|
||||||
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
|
||||||
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
|
||||||
draft.drawCloseBeforeDrawSeconds !== saved.drawCloseBeforeDrawSeconds,
|
|
||||||
);
|
|
||||||
push(
|
|
||||||
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
|
||||||
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
|
||||||
draft.drawBufferDrawsAhead !== saved.drawBufferDrawsAhead,
|
|
||||||
);
|
|
||||||
push(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview, draft.requireManualReview !== saved.requireManualReview);
|
|
||||||
push(
|
|
||||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
|
||||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
|
||||||
draft.cooldownMinutes !== saved.cooldownMinutes,
|
|
||||||
);
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -114,114 +72,34 @@ export function DrawSettingsPanel() {
|
|||||||
description={t("system.sections.drawDescription", { ns: "config" })}
|
description={t("system.sections.drawDescription", { ns: "config" })}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30">
|
<SettingsToggleRow
|
||||||
<Label className="font-medium cursor-pointer" onClick={() => updateField("requireManualReview", !draft.requireManualReview)}>{t("system.fields.manualReview", { ns: "config" })}</Label>
|
label={t("system.fields.manualReview", { ns: "config" })}
|
||||||
<Switch
|
hint={t("system.hints.manualReview", { ns: "config" })}
|
||||||
checked={draft.requireManualReview}
|
checked={draft.requireManualReview}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
onCheckedChange={(value) => updateField("requireManualReview", value)}
|
||||||
onCheckedChange={(value) => updateField("requireManualReview", value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="max-w-sm space-y-2">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="cooldown-minutes" className="text-muted-foreground">
|
||||||
<Label htmlFor="default-currency" className="text-muted-foreground">
|
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
</Label>
|
||||||
</Label>
|
<p className="text-sm text-muted-foreground">
|
||||||
<Input
|
{t("system.hints.cooldownMinutes", { ns: "config" })}
|
||||||
id="default-currency"
|
</p>
|
||||||
value={draft.defaultCurrency}
|
<Input
|
||||||
placeholder={t("system.placeholders.defaultCurrency", { ns: "config" })}
|
id="cooldown-minutes"
|
||||||
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
|
type="number"
|
||||||
disabled={!canManage || loading || saving}
|
min="0"
|
||||||
maxLength={16}
|
step="1"
|
||||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
value={draft.cooldownMinutes}
|
||||||
/>
|
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
|
||||||
</div>
|
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
||||||
<div className="space-y-2">
|
disabled={!canManage || loading || saving}
|
||||||
<Label htmlFor="draw-interval-minutes" className="text-muted-foreground">
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
<SettingsSectionActions
|
<SettingsSectionActions
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
import { SettingsSectionActions } from "@/modules/settings/components/settings-section-actions";
|
||||||
|
import { SettingsToggleRow } from "@/modules/settings/components/settings-toggle-row";
|
||||||
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
import { useSettingsSection } from "@/modules/settings/hooks/use-settings-section";
|
||||||
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
|
import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
|
||||||
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
import type { AdminSettingBatchItem } from "@/api/admin-settings";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_PAYOUT_MANAGE } from "@/lib/admin-prd";
|
import { PRD_PAYOUT_MANAGE } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
@@ -82,49 +81,35 @@ export function SettlementSettingsPanel() {
|
|||||||
description={t("system.sections.settlementDescription", { ns: "config" })}
|
description={t("system.sections.settlementDescription", { ns: "config" })}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-border/70 bg-card shadow-sm divide-y divide-border/50">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
|
<SettingsToggleRow
|
||||||
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoSettlement", !draft.autoSettlement)}>{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||||
<Switch
|
hint={t("system.hints.autoSettlement", { ns: "config" })}
|
||||||
checked={draft.autoSettlement}
|
checked={draft.autoSettlement}
|
||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
onCheckedChange={(value) => updateField("autoSettlement", value)}
|
||||||
onCheckedChange={(value) => updateField("autoSettlement", value)}
|
/>
|
||||||
/>
|
<SettingsToggleRow
|
||||||
</div>
|
label={t("system.fields.autoApprove", { ns: "config" })}
|
||||||
|
hint={t("system.hints.autoApprove", { ns: "config" })}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
|
checked={draft.autoApprove}
|
||||||
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoApprove", !draft.autoApprove)}>{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
disabled={loading || saving}
|
||||||
<Switch
|
onCheckedChange={(value) => updateField("autoApprove", value)}
|
||||||
checked={draft.autoApprove}
|
/>
|
||||||
disabled={loading || saving}
|
<SettingsToggleRow
|
||||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
label={t("system.fields.autoPayout", { ns: "config" })}
|
||||||
onCheckedChange={(value) => updateField("autoApprove", value)}
|
hint={t("system.hints.autoPayout", { ns: "config" })}
|
||||||
/>
|
checked={draft.autoPayout}
|
||||||
</div>
|
disabled={loading || saving}
|
||||||
|
onCheckedChange={(value) => updateField("autoPayout", value)}
|
||||||
<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>
|
<SettingsToggleRow
|
||||||
<Switch
|
label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||||
checked={draft.autoPayout}
|
hint={t("system.hints.applyRebateToPayout", { ns: "config" })}
|
||||||
disabled={loading || saving}
|
checked={draft.applyRebateToPayout}
|
||||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
disabled={loading || saving}
|
||||||
onCheckedChange={(value) => updateField("autoPayout", value)}
|
onCheckedChange={(value) => updateField("applyRebateToPayout", 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>
|
</div>
|
||||||
|
|
||||||
<SettingsSectionActions
|
<SettingsSectionActions
|
||||||
@@ -137,8 +122,8 @@ export function SettlementSettingsPanel() {
|
|||||||
description: t("system.confirmSaveSettlementDescription", { ns: "config" }),
|
description: t("system.confirmSaveSettlementDescription", { ns: "config" }),
|
||||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
void save();
|
void save();
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onDiscard={discard}
|
onDiscard={discard}
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ export const FRONTEND_GROUP = "frontend";
|
|||||||
export const WALLET_GROUP = "wallet";
|
export const WALLET_GROUP = "wallet";
|
||||||
|
|
||||||
export const DRAW_KEYS = {
|
export const DRAW_KEYS = {
|
||||||
DEFAULT_CURRENCY: "currency.default_code",
|
|
||||||
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
|
||||||
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
|
||||||
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
|
||||||
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
|
||||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ function SystemSettingsContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<div className="flex w-full max-w-none flex-col gap-6">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">{t("system.pageTitle")}</h1>
|
||||||
|
|
||||||
<DrawSettingsPanel />
|
<DrawSettingsPanel />
|
||||||
<SettlementSettingsPanel />
|
<SettlementSettingsPanel />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -37,6 +37,7 @@ type AgentBillDetailProps = {
|
|||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canFinanceAdjustments?: boolean;
|
canFinanceAdjustments?: boolean;
|
||||||
boundAgent?: { id: number } | null;
|
boundAgent?: { id: number } | null;
|
||||||
|
focusSection?: "confirm" | "payment" | null;
|
||||||
onUpdated?: () => void;
|
onUpdated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export function AgentBillDetail({
|
|||||||
canManage = true,
|
canManage = true,
|
||||||
canFinanceAdjustments = false,
|
canFinanceAdjustments = false,
|
||||||
boundAgent = null,
|
boundAgent = null,
|
||||||
|
focusSection = null,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
}: AgentBillDetailProps): React.ReactElement {
|
}: AgentBillDetailProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
|
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
|
||||||
@@ -62,6 +64,9 @@ export function AgentBillDetail({
|
|||||||
const [adjustReason, setAdjustReason] = useState("");
|
const [adjustReason, setAdjustReason] = useState("");
|
||||||
const [badDebtReason, setBadDebtReason] = useState("");
|
const [badDebtReason, setBadDebtReason] = useState("");
|
||||||
const [rebateDetailsOpen, setRebateDetailsOpen] = useState(false);
|
const [rebateDetailsOpen, setRebateDetailsOpen] = useState(false);
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const confirmRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paymentRef = useRef<HTMLDivElement>(null);
|
||||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -89,6 +94,21 @@ export function AgentBillDetail({
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !bill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target =
|
||||||
|
focusSection === "payment"
|
||||||
|
? paymentRef.current
|
||||||
|
: focusSection === "confirm"
|
||||||
|
? confirmRef.current
|
||||||
|
: null;
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
|
}, [bill, focusSection, loading]);
|
||||||
|
|
||||||
if (loading && !bill) {
|
if (loading && !bill) {
|
||||||
return <AdminLoadingState />;
|
return <AdminLoadingState />;
|
||||||
}
|
}
|
||||||
@@ -378,17 +398,13 @@ export function AgentBillDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canOperateBill && bill.status === "pending_confirm" ? (
|
{canOperateBill && bill.status === "pending_confirm" ? (
|
||||||
<div className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm">
|
<div
|
||||||
<div className="space-y-1">
|
ref={confirmRef}
|
||||||
<p className="font-semibold tracking-tight text-primary">
|
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm"
|
||||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
>
|
||||||
</p>
|
<p className="font-semibold tracking-tight text-primary">
|
||||||
<p className="text-xs text-muted-foreground/80">
|
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||||
{t("settlementCenter:billDisplay.confirmHint", {
|
</p>
|
||||||
defaultValue: "确认后才可以登记收款或付款。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full sm:w-auto sm:min-w-[10rem]"
|
className="w-full sm:w-auto sm:min-w-[10rem]"
|
||||||
@@ -401,7 +417,10 @@ export function AgentBillDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canOperateBill && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
{canOperateBill && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
||||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
<div
|
||||||
|
ref={paymentRef}
|
||||||
|
className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm"
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="font-semibold tracking-tight">{paymentTitle}</p>
|
<p className="font-semibold tracking-tight">{paymentTitle}</p>
|
||||||
@@ -461,87 +480,94 @@ export function AgentBillDetail({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canWriteOff ? (
|
{canWriteOff || (canFinanceAdjustments && locked) ? (
|
||||||
<div className="space-y-4 rounded-xl border border-destructive/20 bg-destructive/5 p-5 shadow-sm">
|
<div className="rounded-xl border border-dashed border-border/70">
|
||||||
<div className="space-y-1">
|
<button
|
||||||
<p className="font-semibold tracking-tight text-destructive">
|
type="button"
|
||||||
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
|
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium"
|
||||||
</p>
|
onClick={() => setAdvancedOpen((open) => !open)}
|
||||||
<p className="text-xs text-destructive/80">
|
>
|
||||||
{t("settlementBills.badDebtHint", {
|
{t("settlementCenter:billDisplay.advancedActions", {
|
||||||
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
|
defaultValue: "调账 / 坏账",
|
||||||
})}
|
})}
|
||||||
</p>
|
<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>
|
||||||
<div className="space-y-1.5 mt-2">
|
) : null}
|
||||||
<Label className="text-destructive/90">{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
</div>
|
||||||
<Input
|
) : null}
|
||||||
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}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowRight, Eye } from "lucide-react";
|
import { ArrowRight, Banknote, Check, Eye } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
||||||
@@ -16,9 +16,8 @@ import {
|
|||||||
formatSignedSettlementMoney,
|
formatSignedSettlementMoney,
|
||||||
} from "@/modules/settlement/settlement-signed-money";
|
} from "@/modules/settlement/settlement-signed-money";
|
||||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||||
import {
|
import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display";
|
||||||
describeBillPaymentDirection,
|
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
|
||||||
} from "@/modules/settlement/settlement-bill-display";
|
|
||||||
import {
|
import {
|
||||||
formatPlatformPartyLabel,
|
formatPlatformPartyLabel,
|
||||||
SettlementDashCell,
|
SettlementDashCell,
|
||||||
@@ -44,7 +43,11 @@ type SettlementBillsTableProps = {
|
|||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
billTypeFilter?: BillTypeFilter;
|
billTypeFilter?: BillTypeFilter;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
canOperate?: boolean;
|
||||||
|
boundAgentId?: number | null;
|
||||||
onOpenDetail: (billId: number) => void;
|
onOpenDetail: (billId: number) => void;
|
||||||
|
onConfirmBill?: (row: SettlementBillRow) => void;
|
||||||
|
onPayBill?: (row: SettlementBillRow) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function billRowTone(row: SettlementBillRow): string {
|
function billRowTone(row: SettlementBillRow): string {
|
||||||
@@ -120,7 +123,11 @@ export function SettlementBillsTable({
|
|||||||
currencyCode,
|
currencyCode,
|
||||||
billTypeFilter = "all",
|
billTypeFilter = "all",
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
canOperate = false,
|
||||||
|
boundAgentId = null,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
|
onConfirmBill,
|
||||||
|
onPayBill,
|
||||||
}: SettlementBillsTableProps): React.ReactElement {
|
}: SettlementBillsTableProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||||
|
|
||||||
@@ -173,8 +180,44 @@ export function SettlementBillsTable({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const isPlayerBill = row.bill_type === "player";
|
|
||||||
const direction = describeBillPaymentDirection(row, t);
|
const direction = describeBillPaymentDirection(row, t);
|
||||||
|
const operable =
|
||||||
|
canOperate &&
|
||||||
|
settlementBillOperableByBoundAgent(row, boundAgentId ? { id: boundAgentId } : null);
|
||||||
|
const canConfirm = operable && row.status === "pending_confirm";
|
||||||
|
const canPay =
|
||||||
|
operable &&
|
||||||
|
["confirmed", "partial_paid", "overdue"].includes(row.status) &&
|
||||||
|
row.unpaid_amount > 0;
|
||||||
|
|
||||||
|
const rowActions = [
|
||||||
|
...(canConfirm && onConfirmBill
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "confirm",
|
||||||
|
label: t("billsPanel.confirmOneBtn", { defaultValue: "确认" }),
|
||||||
|
icon: Check,
|
||||||
|
onClick: () => onConfirmBill(row),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(canPay && onPayBill
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "pay",
|
||||||
|
label: t("billsPanel.payBtn", { defaultValue: "收付" }),
|
||||||
|
icon: Banknote,
|
||||||
|
onClick: () => onPayBill(row),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: "detail",
|
||||||
|
label: t("actions.detail", { defaultValue: "详情" }),
|
||||||
|
icon: Eye,
|
||||||
|
onClick: () => onOpenDetail(row.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={row.id} className={billRowTone(row)}>
|
<TableRow key={row.id} className={billRowTone(row)}>
|
||||||
@@ -268,16 +311,7 @@ export function SettlementBillsTable({
|
|||||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<AdminRowActionsMenu
|
<AdminRowActionsMenu actions={rowActions} />
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
key: "detail",
|
|
||||||
label: t("actions.detail", { defaultValue: "详情" }),
|
|
||||||
icon: Eye,
|
|
||||||
onClick: () => onOpenDetail(row.id),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
export type SettlementPeriodView = "bills" | "operations" | "ledger";
|
import type { SettlementBillListScope } from "@/api/admin-agent-settlement";
|
||||||
|
|
||||||
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "operations", "ledger"];
|
export type SettlementPeriodView = "bills" | "ledger";
|
||||||
|
|
||||||
|
export type SettlementBillScopeFilter = "all" | SettlementBillListScope;
|
||||||
|
|
||||||
|
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "ledger"];
|
||||||
|
|
||||||
|
const VALID_BILL_SCOPES: SettlementBillScopeFilter[] = [
|
||||||
|
"all",
|
||||||
|
"pending_confirm",
|
||||||
|
"awaiting_payment",
|
||||||
|
"settled",
|
||||||
|
"adjustment",
|
||||||
|
];
|
||||||
|
|
||||||
function parsePositiveInt(raw: string | null): number | null {
|
function parsePositiveInt(raw: string | null): number | null {
|
||||||
if (raw === null || raw === "") {
|
if (raw === null || raw === "") {
|
||||||
@@ -11,16 +23,30 @@ function parsePositiveInt(raw: string | null): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function settlementCenterListHref(adminSiteId?: number | null): string {
|
export function settlementCenterListHref(adminSiteId?: number | null): string {
|
||||||
|
return settlementCenterScopeHref(adminSiteId, "all");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结算中心列表深链,可带账单筛选 scope(待确认 / 待收付等)。 */
|
||||||
|
export function settlementCenterScopeHref(
|
||||||
|
adminSiteId?: number | null,
|
||||||
|
billScope: SettlementBillScopeFilter = "all",
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
if (adminSiteId != null && adminSiteId > 0) {
|
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(
|
export function settlementPeriodViewHref(
|
||||||
periodId: number,
|
periodId: number,
|
||||||
view: SettlementPeriodView = "bills",
|
view: SettlementPeriodView = "bills",
|
||||||
adminSiteId?: number | null,
|
adminSiteId?: number | null,
|
||||||
|
billScope?: SettlementBillScopeFilter | null,
|
||||||
): string {
|
): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
period: String(periodId),
|
period: String(periodId),
|
||||||
@@ -29,24 +55,57 @@ export function settlementPeriodViewHref(
|
|||||||
if (adminSiteId != null && adminSiteId > 0) {
|
if (adminSiteId != null && adminSiteId > 0) {
|
||||||
params.set("site", String(adminSiteId));
|
params.set("site", String(adminSiteId));
|
||||||
}
|
}
|
||||||
|
if (billScope != null && billScope !== "all") {
|
||||||
|
params.set("scope", billScope);
|
||||||
|
}
|
||||||
return `/admin/settlement-center?${params.toString()}`;
|
return `/admin/settlement-center?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function preferredBillScopeForPeriod(summary?: {
|
||||||
|
pending_confirm?: number;
|
||||||
|
awaiting_payment?: number;
|
||||||
|
}): SettlementBillScopeFilter {
|
||||||
|
const pending = summary?.pending_confirm ?? 0;
|
||||||
|
const awaiting = summary?.awaiting_payment ?? 0;
|
||||||
|
if (pending > 0) {
|
||||||
|
return "pending_confirm";
|
||||||
|
}
|
||||||
|
if (awaiting > 0) {
|
||||||
|
return "awaiting_payment";
|
||||||
|
}
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSettlementCenterView(
|
export function parseSettlementCenterView(
|
||||||
siteRaw: string | null,
|
siteRaw: string | null,
|
||||||
periodRaw: string | null,
|
periodRaw: string | null,
|
||||||
viewRaw: string | null,
|
viewRaw: string | null,
|
||||||
): { siteId: number | null; periodId: number | null; view: SettlementPeriodView } {
|
scopeRaw?: string | null,
|
||||||
const normalizedView = viewRaw === "reports" ? "bills" : viewRaw;
|
): {
|
||||||
|
siteId: number | null;
|
||||||
|
periodId: number | null;
|
||||||
|
view: SettlementPeriodView;
|
||||||
|
billScope: SettlementBillScopeFilter;
|
||||||
|
} {
|
||||||
|
const normalizedView =
|
||||||
|
viewRaw === "reports" || viewRaw === "operations" ? "bills" : viewRaw;
|
||||||
const view =
|
const view =
|
||||||
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
|
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
|
||||||
? (normalizedView as SettlementPeriodView)
|
? (normalizedView as SettlementPeriodView)
|
||||||
: "bills";
|
: "bills";
|
||||||
|
|
||||||
|
const billScope =
|
||||||
|
scopeRaw !== null &&
|
||||||
|
scopeRaw !== "" &&
|
||||||
|
VALID_BILL_SCOPES.includes(scopeRaw as SettlementBillScopeFilter)
|
||||||
|
? (scopeRaw as SettlementBillScopeFilter)
|
||||||
|
: "all";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
siteId: parsePositiveInt(siteRaw),
|
siteId: parsePositiveInt(siteRaw),
|
||||||
periodId: parsePositiveInt(periodRaw),
|
periodId: parsePositiveInt(periodRaw),
|
||||||
view,
|
view,
|
||||||
|
billScope,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
import type { SettlementPeriodCloseResult, SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
||||||
import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel";
|
import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel";
|
||||||
import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel";
|
import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel";
|
||||||
import { SettlementOperationsPanel } from "@/modules/settlement/settlement-operations-panel";
|
import { SettlementPeriodHeader } from "@/modules/settlement/settlement-period-header";
|
||||||
import {
|
import {
|
||||||
settlementCenterListHref,
|
|
||||||
settlementPeriodViewHref,
|
settlementPeriodViewHref,
|
||||||
|
type SettlementBillScopeFilter,
|
||||||
type SettlementPeriodView,
|
type SettlementPeriodView,
|
||||||
} from "@/modules/settlement/settlement-center-nav";
|
} from "@/modules/settlement/settlement-center-nav";
|
||||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
|
||||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
|
||||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type SettlementCenterPeriodDetailProps = {
|
type SettlementCenterPeriodDetailProps = {
|
||||||
period: SettlementPeriodRow;
|
period: SettlementPeriodRow;
|
||||||
view: SettlementPeriodView;
|
view: SettlementPeriodView;
|
||||||
|
billScope: SettlementBillScopeFilter;
|
||||||
adminSiteId: number;
|
adminSiteId: number;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
canOperateBills: boolean;
|
canOperateBills: boolean;
|
||||||
|
canManagePeriods: boolean;
|
||||||
boundAgentId?: number | null;
|
boundAgentId?: number | null;
|
||||||
refreshKey: number;
|
refreshKey: number;
|
||||||
onOpenBillDetail: (billId: number) => void;
|
onOpenBillDetail: (billId: number, focus?: "confirm" | "payment") => void;
|
||||||
|
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
|
||||||
|
onReloadPeriods?: () => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettlementCenterPeriodDetail({
|
export function SettlementCenterPeriodDetail({
|
||||||
period,
|
period,
|
||||||
view,
|
view,
|
||||||
|
billScope,
|
||||||
adminSiteId,
|
adminSiteId,
|
||||||
currencyCode,
|
currencyCode,
|
||||||
canOperateBills,
|
canOperateBills,
|
||||||
|
canManagePeriods,
|
||||||
boundAgentId = null,
|
boundAgentId = null,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
onOpenBillDetail,
|
onOpenBillDetail,
|
||||||
|
onPeriodClosed,
|
||||||
|
onReloadPeriods,
|
||||||
}: SettlementCenterPeriodDetailProps): React.ReactElement {
|
}: SettlementCenterPeriodDetailProps): React.ReactElement {
|
||||||
const { t } = useTranslation("settlementCenter");
|
const { t } = useTranslation("settlementCenter");
|
||||||
|
|
||||||
const subViews: { key: SettlementPeriodView; label: string }[] = [
|
const subViews: { key: SettlementPeriodView; label: string }[] = [
|
||||||
{ key: "bills", label: t("nav.bills", { defaultValue: "账单" }) },
|
{ key: "bills", label: t("nav.bills", { defaultValue: "账单" }) },
|
||||||
{ key: "operations", label: t("nav.operations", { defaultValue: "收付与调账" }) },
|
|
||||||
{ key: "ledger", label: t("nav.ledger", { defaultValue: "账务流水" }) },
|
{ key: "ledger", label: t("nav.ledger", { defaultValue: "账务流水" }) },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -54,31 +54,21 @@ export function SettlementCenterPeriodDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<SettlementPeriodHeader
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
period={period}
|
||||||
<Link
|
adminSiteId={adminSiteId}
|
||||||
href={settlementCenterListHref(adminSiteId)}
|
currencyCode={currencyCode}
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")}
|
billScope={billScope}
|
||||||
>
|
canManage={canManagePeriods}
|
||||||
<ArrowLeft className="size-4" aria-hidden />
|
onPeriodClosed={onPeriodClosed}
|
||||||
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
|
onReloadPeriods={onReloadPeriods}
|
||||||
</Link>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h2 className="text-base font-semibold tracking-tight">
|
|
||||||
{formatSettlementPeriodSpan(period.period_start, period.period_end)}
|
|
||||||
</h2>
|
|
||||||
<AdminStatusBadge status={period.status}>
|
|
||||||
{settlementPeriodStatusLabel(period.status, t)}
|
|
||||||
</AdminStatusBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AdminSubnav aria-label={t("nav.aria", { defaultValue: "账期视图" })}>
|
<AdminSubnav aria-label={t("nav.aria", { defaultValue: "账期视图" })}>
|
||||||
{subViews.map((item) => (
|
{subViews.map((item) => (
|
||||||
<AdminSubnavLink
|
<AdminSubnavLink
|
||||||
key={item.key}
|
key={item.key}
|
||||||
href={settlementPeriodViewHref(period.id, item.key, adminSiteId)}
|
href={settlementPeriodViewHref(period.id, item.key, adminSiteId, billScope)}
|
||||||
active={view === item.key}
|
active={view === item.key}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -88,12 +78,14 @@ export function SettlementCenterPeriodDetail({
|
|||||||
|
|
||||||
{view === "bills" ? (
|
{view === "bills" ? (
|
||||||
<SettlementMainPanel
|
<SettlementMainPanel
|
||||||
key={`${adminSiteId}-${period.id}-${refreshKey}`}
|
key={`${adminSiteId}-${period.id}-${refreshKey}-${billScope}`}
|
||||||
adminSiteId={adminSiteId}
|
adminSiteId={adminSiteId}
|
||||||
currencyCode={currencyCode}
|
currencyCode={currencyCode}
|
||||||
periodFilter={period.id}
|
periodFilter={period.id}
|
||||||
|
billScope={billScope}
|
||||||
onOpenBillDetail={onOpenBillDetail}
|
onOpenBillDetail={onOpenBillDetail}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
canOperateBills={canOperateBills}
|
||||||
pendingConfirm={pendingConfirm}
|
pendingConfirm={pendingConfirm}
|
||||||
awaitingPayment={awaitingPayment}
|
awaitingPayment={awaitingPayment}
|
||||||
selectedPeriodStatus={period.status}
|
selectedPeriodStatus={period.status}
|
||||||
@@ -101,17 +93,6 @@ export function SettlementCenterPeriodDetail({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{view === "operations" ? (
|
|
||||||
<SettlementOperationsPanel
|
|
||||||
key={`${adminSiteId}-${period.id}-${refreshKey}-ops`}
|
|
||||||
adminSiteId={adminSiteId}
|
|
||||||
settlementPeriodId={period.id}
|
|
||||||
currencyCode={currencyCode}
|
|
||||||
refreshKey={refreshKey}
|
|
||||||
onOpenBill={onOpenBillDetail}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{view === "ledger" ? (
|
{view === "ledger" ? (
|
||||||
<SettlementCreditLedgerPanel
|
<SettlementCreditLedgerPanel
|
||||||
key={`${adminSiteId}-${period.id}-${refreshKey}`}
|
key={`${adminSiteId}-${period.id}-${refreshKey}`}
|
||||||
@@ -121,7 +102,6 @@ export function SettlementCenterPeriodDetail({
|
|||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Check, ChevronDown, Search } from "lucide-react";
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -15,6 +15,7 @@ import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
|
|||||||
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
|
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
|
||||||
import {
|
import {
|
||||||
parseSettlementCenterView,
|
parseSettlementCenterView,
|
||||||
|
preferredBillScopeForPeriod,
|
||||||
settlementCenterListHref,
|
settlementCenterListHref,
|
||||||
settlementPeriodViewHref,
|
settlementPeriodViewHref,
|
||||||
type SettlementPeriodView,
|
type SettlementPeriodView,
|
||||||
@@ -45,11 +46,24 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const boundAgent = profile?.agent ?? null;
|
const boundAgent = profile?.agent ?? null;
|
||||||
|
|
||||||
const { siteId: siteFromUrl, periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
|
const {
|
||||||
|
siteId: siteFromUrl,
|
||||||
|
periodId: activePeriodId,
|
||||||
|
view: activeView,
|
||||||
|
billScope: activeBillScope,
|
||||||
|
} = parseSettlementCenterView(
|
||||||
searchParams.get("site"),
|
searchParams.get("site"),
|
||||||
searchParams.get("period"),
|
searchParams.get("period"),
|
||||||
searchParams.get("view"),
|
searchParams.get("view"),
|
||||||
|
searchParams.get("scope"),
|
||||||
);
|
);
|
||||||
|
const billIdFromUrl = Number.parseInt(searchParams.get("bill") ?? "", 10);
|
||||||
|
const billFocusFromUrl =
|
||||||
|
searchParams.get("focus") === "payment"
|
||||||
|
? "payment"
|
||||||
|
: searchParams.get("focus") === "confirm"
|
||||||
|
? "confirm"
|
||||||
|
: null;
|
||||||
|
|
||||||
const canOperateBills =
|
const canOperateBills =
|
||||||
profile?.is_super_admin === true ||
|
profile?.is_super_admin === true ||
|
||||||
@@ -66,6 +80,8 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
||||||
const [periodsReady, setPeriodsReady] = useState(false);
|
const [periodsReady, setPeriodsReady] = useState(false);
|
||||||
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
||||||
|
const [detailBillFocus, setDetailBillFocus] = useState<"confirm" | "payment" | null>(null);
|
||||||
|
const billDeepLinkHandledRef = useRef<number | null>(null);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [periodLookupDone, setPeriodLookupDone] = useState(false);
|
const [periodLookupDone, setPeriodLookupDone] = useState(false);
|
||||||
|
|
||||||
@@ -261,8 +277,15 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
const activePeriod =
|
const activePeriod =
|
||||||
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
|
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
|
||||||
|
|
||||||
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
|
const openPeriodView = (periodId: number, view: SettlementPeriodView = "bills"): void => {
|
||||||
router.push(settlementPeriodViewHref(periodId, view, siteId));
|
const period = periods.find((row) => row.id === periodId);
|
||||||
|
const scope = preferredBillScopeForPeriod(period?.summary);
|
||||||
|
router.push(settlementPeriodViewHref(periodId, view, siteId, scope));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBillDetail = (billId: number, focus?: "confirm" | "payment"): void => {
|
||||||
|
setDetailBillId(billId);
|
||||||
|
setDetailBillFocus(focus ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isListMode = activePeriodId === null;
|
const isListMode = activePeriodId === null;
|
||||||
@@ -279,14 +302,30 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activePeriodId !== null && siteFromUrl !== siteId) {
|
if (activePeriodId !== null && siteFromUrl !== siteId) {
|
||||||
router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId));
|
router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId, activeBillScope));
|
||||||
}
|
}
|
||||||
}, [activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]);
|
}, [activeBillScope, activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPeriodLookupDone(false);
|
setPeriodLookupDone(false);
|
||||||
}, [activePeriodId, siteId]);
|
}, [activePeriodId, siteId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!periodsReady ||
|
||||||
|
!Number.isFinite(billIdFromUrl) ||
|
||||||
|
billIdFromUrl <= 0 ||
|
||||||
|
activePeriodId === null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (billDeepLinkHandledRef.current === billIdFromUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
billDeepLinkHandledRef.current = billIdFromUrl;
|
||||||
|
openBillDetail(billIdFromUrl, billFocusFromUrl ?? undefined);
|
||||||
|
}, [activePeriodId, billFocusFromUrl, billIdFromUrl, periodsReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!periodsReady || activePeriodId === null || siteId === null) {
|
if (!periodsReady || activePeriodId === null || siteId === null) {
|
||||||
return;
|
return;
|
||||||
@@ -306,7 +345,9 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
const match = (data.items ?? []).find((row) => row.id === activePeriodId);
|
const match = (data.items ?? []).find((row) => row.id === activePeriodId);
|
||||||
if (match?.admin_site_id && match.admin_site_id !== siteId) {
|
if (match?.admin_site_id && match.admin_site_id !== siteId) {
|
||||||
setAdminSiteId(match.admin_site_id);
|
setAdminSiteId(match.admin_site_id);
|
||||||
router.replace(settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id));
|
router.replace(
|
||||||
|
settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id, activeBillScope),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,21 +422,51 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
<SettlementCenterPeriodDetail
|
<SettlementCenterPeriodDetail
|
||||||
period={activePeriod}
|
period={activePeriod}
|
||||||
view={activeView}
|
view={activeView}
|
||||||
|
billScope={activeBillScope}
|
||||||
adminSiteId={siteId}
|
adminSiteId={siteId}
|
||||||
currencyCode={currency}
|
currencyCode={currency}
|
||||||
canOperateBills={canOperateBills}
|
canOperateBills={canOperateBills}
|
||||||
|
canManagePeriods={canManagePeriods}
|
||||||
boundAgentId={boundAgent?.id ?? null}
|
boundAgentId={boundAgent?.id ?? null}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
onOpenBillDetail={setDetailBillId}
|
onOpenBillDetail={openBillDetail}
|
||||||
|
onPeriodClosed={(result) => {
|
||||||
|
setRefreshKey((n) => n + 1);
|
||||||
|
const n = result?.unsettled_ticket_count ?? 0;
|
||||||
|
if (n > 0) {
|
||||||
|
toast.warning(
|
||||||
|
t("toast.periodClosedUnsettled", {
|
||||||
|
defaultValue: "已关账,仍有 {{count}} 笔注单未结算。",
|
||||||
|
count: n,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onReloadPeriods={loadPeriods}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
|
<Dialog
|
||||||
|
open={detailBillId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDetailBillId(null);
|
||||||
|
setDetailBillFocus(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(640px,calc(100vw-2rem))] sm:!max-w-[640px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(720px,calc(100vw-2rem))] sm:!max-w-[720px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
||||||
>
|
>
|
||||||
<DialogHeader className="border-b px-6 py-4">
|
<DialogHeader className="border-b px-6 py-4">
|
||||||
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{detailBillId !== null
|
||||||
|
? t("actions.billDetailWithId", {
|
||||||
|
defaultValue: "账单 #{{id}}",
|
||||||
|
id: detailBillId,
|
||||||
|
})
|
||||||
|
: t("actions.billDetail", { defaultValue: "账单详情" })}
|
||||||
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{detailBillId !== null ? (
|
{detailBillId !== null ? (
|
||||||
<div className="min-h-0 overflow-y-auto px-6 py-5">
|
<div className="min-h-0 overflow-y-auto px-6 py-5">
|
||||||
@@ -405,6 +476,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
canManage={canOperateBills}
|
canManage={canOperateBills}
|
||||||
boundAgent={boundAgent}
|
boundAgent={boundAgent}
|
||||||
canFinanceAdjustments={canFinanceAdjustments}
|
canFinanceAdjustments={canFinanceAdjustments}
|
||||||
|
focusSection={detailBillFocus}
|
||||||
onUpdated={() => {
|
onUpdated={() => {
|
||||||
void loadPeriods();
|
void loadPeriods();
|
||||||
setRefreshKey((n) => n + 1);
|
setRefreshKey((n) => n + 1);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSettlementBills,
|
getSettlementBills,
|
||||||
|
postSettlementBillConfirm,
|
||||||
type SettlementBillListScope,
|
type SettlementBillListScope,
|
||||||
type SettlementBillRow,
|
type SettlementBillRow,
|
||||||
} from "@/api/admin-agent-settlement";
|
} from "@/api/admin-agent-settlement";
|
||||||
@@ -22,40 +24,45 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||||
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
|
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
|
||||||
|
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
|
||||||
|
import {
|
||||||
|
settlementPeriodViewHref,
|
||||||
|
type SettlementBillScopeFilter,
|
||||||
|
} from "@/modules/settlement/settlement-center-nav";
|
||||||
import { settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
|
import { settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type BillTypeFilter = "all" | "player" | "agent";
|
type BillTypeFilter = "all" | "player" | "agent";
|
||||||
type BillStatusFilter = "all" | SettlementBillListScope;
|
|
||||||
|
|
||||||
type BillFilters = {
|
type BillFilters = {
|
||||||
billId: string;
|
|
||||||
ownerKeyword: string;
|
ownerKeyword: string;
|
||||||
billType: BillTypeFilter;
|
billType: BillTypeFilter;
|
||||||
statusScope: BillStatusFilter;
|
statusScope: SettlementBillScopeFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
function filtersForPeriod(boundAgentId: number | null): BillFilters {
|
function filtersForPeriod(
|
||||||
|
boundAgentId: number | null,
|
||||||
|
billScope: SettlementBillScopeFilter,
|
||||||
|
): BillFilters {
|
||||||
return {
|
return {
|
||||||
billId: "",
|
|
||||||
ownerKeyword: "",
|
ownerKeyword: "",
|
||||||
billType: boundAgentId !== null ? "agent" : "all",
|
billType: boundAgentId !== null ? "agent" : "all",
|
||||||
statusScope: "all",
|
statusScope: billScope,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiQueryFromFilters(filters: BillFilters): {
|
function apiQueryFromFilters(filters: BillFilters): {
|
||||||
bill_type?: string;
|
bill_type?: string;
|
||||||
scope?: SettlementBillListScope;
|
scope?: SettlementBillListScope;
|
||||||
bill_id?: number;
|
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
} {
|
} {
|
||||||
const out: {
|
const out: {
|
||||||
bill_type?: string;
|
bill_type?: string;
|
||||||
scope?: SettlementBillListScope;
|
scope?: SettlementBillListScope;
|
||||||
bill_id?: number;
|
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
@@ -65,10 +72,6 @@ function apiQueryFromFilters(filters: BillFilters): {
|
|||||||
if (filters.statusScope !== "all") {
|
if (filters.statusScope !== "all") {
|
||||||
out.scope = filters.statusScope;
|
out.scope = filters.statusScope;
|
||||||
}
|
}
|
||||||
const id = Number(filters.billId.trim());
|
|
||||||
if (filters.billId.trim() !== "" && !Number.isNaN(id) && id > 0) {
|
|
||||||
out.bill_id = id;
|
|
||||||
}
|
|
||||||
const keyword = filters.ownerKeyword.trim();
|
const keyword = filters.ownerKeyword.trim();
|
||||||
if (keyword !== "") {
|
if (keyword !== "") {
|
||||||
out.keyword = keyword;
|
out.keyword = keyword;
|
||||||
@@ -81,8 +84,10 @@ export type SettlementMainPanelProps = {
|
|||||||
adminSiteId: number;
|
adminSiteId: number;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
periodFilter: AgentSettlementPeriodFilter;
|
periodFilter: AgentSettlementPeriodFilter;
|
||||||
onOpenBillDetail: (billId: number) => void;
|
billScope?: SettlementBillScopeFilter;
|
||||||
|
onOpenBillDetail: (billId: number, focus?: "confirm" | "payment") => void;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
|
canOperateBills?: boolean;
|
||||||
pendingConfirm: number;
|
pendingConfirm: number;
|
||||||
awaitingPayment: number;
|
awaitingPayment: number;
|
||||||
selectedPeriodStatus?: string | null;
|
selectedPeriodStatus?: string | null;
|
||||||
@@ -93,8 +98,10 @@ export function SettlementMainPanel({
|
|||||||
adminSiteId,
|
adminSiteId,
|
||||||
currencyCode,
|
currencyCode,
|
||||||
periodFilter,
|
periodFilter,
|
||||||
|
billScope = "all",
|
||||||
onOpenBillDetail,
|
onOpenBillDetail,
|
||||||
refreshKey = 0,
|
refreshKey = 0,
|
||||||
|
canOperateBills = false,
|
||||||
pendingConfirm,
|
pendingConfirm,
|
||||||
awaitingPayment,
|
awaitingPayment,
|
||||||
selectedPeriodStatus,
|
selectedPeriodStatus,
|
||||||
@@ -103,8 +110,12 @@ export function SettlementMainPanel({
|
|||||||
const { t } = useTranslation("settlementCenter");
|
const { t } = useTranslation("settlementCenter");
|
||||||
const periodId = periodFilter === "all" ? undefined : periodFilter;
|
const periodId = periodFilter === "all" ? undefined : periodFilter;
|
||||||
const periodOpen = selectedPeriodStatus === "open";
|
const periodOpen = selectedPeriodStatus === "open";
|
||||||
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
|
||||||
const initialFilters = useMemo(() => filtersForPeriod(boundAgentId), [boundAgentId]);
|
const initialFilters = useMemo(
|
||||||
|
() => filtersForPeriod(boundAgentId, billScope),
|
||||||
|
[boundAgentId, billScope],
|
||||||
|
);
|
||||||
|
|
||||||
const [draft, setDraft] = useState<BillFilters>(initialFilters);
|
const [draft, setDraft] = useState<BillFilters>(initialFilters);
|
||||||
const [applied, setApplied] = useState<BillFilters>(initialFilters);
|
const [applied, setApplied] = useState<BillFilters>(initialFilters);
|
||||||
@@ -129,7 +140,6 @@ export function SettlementMainPanel({
|
|||||||
settlement_period_id: periodId,
|
settlement_period_id: periodId,
|
||||||
bill_type: q.bill_type,
|
bill_type: q.bill_type,
|
||||||
scope: q.scope,
|
scope: q.scope,
|
||||||
bill_id: q.bill_id,
|
|
||||||
keyword: q.keyword,
|
keyword: q.keyword,
|
||||||
page,
|
page,
|
||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
@@ -164,7 +174,7 @@ export function SettlementMainPanel({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusOptionLabel = (value: BillStatusFilter): string => {
|
const statusOptionLabel = (value: SettlementBillScopeFilter): string => {
|
||||||
if (value === "all") {
|
if (value === "all") {
|
||||||
return t("billsPanel.filterAll", { defaultValue: "全部状态" });
|
return t("billsPanel.filterAll", { defaultValue: "全部状态" });
|
||||||
}
|
}
|
||||||
@@ -185,7 +195,7 @@ export function SettlementMainPanel({
|
|||||||
const emptyBillMessage = useMemo((): string | undefined => {
|
const emptyBillMessage = useMemo((): string | undefined => {
|
||||||
if (periodOpen) {
|
if (periodOpen) {
|
||||||
return t("empty.billsNeedClose", {
|
return t("empty.billsNeedClose", {
|
||||||
defaultValue: "账单在关账后生成。请返回账期列表,对本期执行「关账」后再查看。",
|
defaultValue: "账单在关账后生成。请对本账期执行「关账」后再查看。",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (applied.statusScope !== "all") {
|
if (applied.statusScope !== "all") {
|
||||||
@@ -213,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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-4">
|
||||||
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
<ConfirmDialog />
|
||||||
<div className="grid gap-x-5 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div className="grid gap-2">
|
{!periodOpen && periodId != null ? (
|
||||||
<Label htmlFor="sb-bill-id" className="text-muted-foreground">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
{scopeChips.map((scope) => {
|
||||||
id="sb-bill-id"
|
const active = applied.statusScope === scope;
|
||||||
inputMode="numeric"
|
const href = settlementPeriodViewHref(periodId, "bills", adminSiteId, scope);
|
||||||
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
|
const count =
|
||||||
value={draft.billId}
|
scope === "pending_confirm"
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
|
? pendingConfirm
|
||||||
onKeyDown={(e) => {
|
: scope === "awaiting_payment"
|
||||||
if (e.key === "Enter") {
|
? awaitingPayment
|
||||||
runSearch();
|
: undefined;
|
||||||
}
|
|
||||||
}}
|
return (
|
||||||
className="bg-background/50 transition-colors focus:bg-background"
|
<Link key={scope} href={href} className={scopeChipClass(active)}>
|
||||||
/>
|
{statusOptionLabel(scope)}
|
||||||
</div>
|
{count != null && count > 0 ? ` ${count}` : null}
|
||||||
<div className="grid gap-2">
|
</Link>
|
||||||
<Label htmlFor="sb-owner" className="text-muted-foreground">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
|
);
|
||||||
<Input
|
})}
|
||||||
id="sb-owner"
|
</div>
|
||||||
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
) : null}
|
||||||
value={draft.ownerKeyword}
|
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
<div className="admin-list-toolbar">
|
||||||
onKeyDown={(e) => {
|
<div className="admin-list-field min-w-[12rem] flex-1">
|
||||||
if (e.key === "Enter") {
|
<Label htmlFor="sb-owner" className="sm:shrink-0">
|
||||||
runSearch();
|
{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}
|
||||||
}
|
</Label>
|
||||||
}}
|
<Input
|
||||||
className="bg-background/50 transition-colors focus:bg-background"
|
id="sb-owner"
|
||||||
/>
|
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
||||||
</div>
|
value={draft.ownerKeyword}
|
||||||
<div className="grid gap-2">
|
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
||||||
<Label htmlFor="sb-status" className="text-muted-foreground">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
|
onKeyDown={(e) => {
|
||||||
<Select
|
if (e.key === "Enter") {
|
||||||
modal={false}
|
runSearch();
|
||||||
value={draft.statusScope}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setDraft((d) => ({
|
|
||||||
...d,
|
|
||||||
statusScope: (v ?? "all") as BillStatusFilter,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
>
|
}}
|
||||||
<SelectTrigger id="sb-status" className="w-full bg-background/50 transition-colors focus:bg-background">
|
/>
|
||||||
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
|
</div>
|
||||||
</SelectTrigger>
|
{billTypeOptions.length > 1 ? (
|
||||||
<SelectContent>
|
<div className="admin-list-field">
|
||||||
{(
|
<Label htmlFor="sb-type" className="sm:shrink-0">
|
||||||
[
|
{t("billsPanel.billType", { defaultValue: "账单类型" })}
|
||||||
"all",
|
</Label>
|
||||||
"pending_confirm",
|
|
||||||
"awaiting_payment",
|
|
||||||
"settled",
|
|
||||||
"adjustment",
|
|
||||||
] as BillStatusFilter[]
|
|
||||||
).map((value) => (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{statusOptionLabel(value)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="sb-type" className="text-muted-foreground">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
|
|
||||||
<Select
|
<Select
|
||||||
modal={false}
|
modal={false}
|
||||||
value={draft.billType}
|
value={draft.billType}
|
||||||
@@ -292,7 +322,7 @@ export function SettlementMainPanel({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="sb-type" className="w-full bg-background/50 transition-colors focus:bg-background">
|
<SelectTrigger id="sb-type" className="h-9 w-full sm:w-36">
|
||||||
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -304,16 +334,15 @@ export function SettlementMainPanel({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
<div className="admin-list-actions">
|
||||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||||
<Button type="button" onClick={() => runSearch()}>
|
|
||||||
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => resetFilters()}>
|
<Button type="button" size="sm" variant="secondary" onClick={() => resetFilters()}>
|
||||||
{t("billsPanel.reset", { defaultValue: "重置" })}
|
{t("billsPanel.reset", { defaultValue: "重置" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||||
{t("billsPanel.refresh", { defaultValue: "刷新" })}
|
{t("billsPanel.refresh", { defaultValue: "刷新" })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +358,23 @@ export function SettlementMainPanel({
|
|||||||
currencyCode={currencyCode}
|
currencyCode={currencyCode}
|
||||||
billTypeFilter={applied.billType}
|
billTypeFilter={applied.billType}
|
||||||
emptyMessage={emptyBillMessage}
|
emptyMessage={emptyBillMessage}
|
||||||
onOpenDetail={onOpenBillDetail}
|
canOperate={canOperateBills}
|
||||||
|
boundAgentId={boundAgentId}
|
||||||
|
onOpenDetail={(billId) => onOpenBillDetail(billId)}
|
||||||
|
onConfirmBill={(row) => {
|
||||||
|
if (canOperateRow(row) && row.status === "pending_confirm") {
|
||||||
|
requestConfirmBill(row);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPayBill={(row) => {
|
||||||
|
if (
|
||||||
|
canOperateRow(row) &&
|
||||||
|
["confirmed", "partial_paid", "overdue"].includes(row.status) &&
|
||||||
|
row.unpaid_amount > 0
|
||||||
|
) {
|
||||||
|
onOpenBillDetail(row.id, "payment");
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<AdminListPaginationFooter
|
<AdminListPaginationFooter
|
||||||
selectId="settlement-bills-per-page"
|
selectId="settlement-bills-per-page"
|
||||||
|
|||||||
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) => {
|
periods.map((row) => {
|
||||||
const canCloseRow = canManage && row.status === "open";
|
const canCloseRow = canManage && row.status === "open";
|
||||||
|
const todoCount =
|
||||||
|
(row.summary?.pending_confirm ?? 0) + (row.summary?.awaiting_payment ?? 0);
|
||||||
|
const detailLabel =
|
||||||
|
todoCount > 0
|
||||||
|
? t("periodTable.processBills", { defaultValue: "处理账单" })
|
||||||
|
: t("periodTable.viewDetail", { defaultValue: "查看详情" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
@@ -144,7 +150,7 @@ export function SettlementPeriodsTable({
|
|||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
key: "detail",
|
key: "detail",
|
||||||
label: t("periodTable.viewDetail", { defaultValue: "查看详情" }),
|
label: detailLabel,
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
onClick: () => onViewDetail(row.id),
|
onClick: () => onViewDetail(row.id),
|
||||||
},
|
},
|
||||||
|
|||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Copy, RotateCcw, Wrench } from "lucide-react";
|
import { RotateCcw, Wrench } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -12,7 +12,6 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
getAdminPlayerWallets,
|
getAdminPlayerWallets,
|
||||||
getAdminTransferOrders,
|
getAdminTransferOrders,
|
||||||
getAdminWalletTransactions,
|
|
||||||
reverseTransferOrder,
|
reverseTransferOrder,
|
||||||
manuallyProcessTransferOrder,
|
manuallyProcessTransferOrder,
|
||||||
completeTransferInCredit,
|
completeTransferInCredit,
|
||||||
@@ -58,204 +57,27 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
|||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
|
||||||
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
|
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
|
import { WalletCellMonoId } from "@/modules/wallet/wallet-cell-mono-id";
|
||||||
|
import {
|
||||||
|
TRANSFER_ORDER_STATUS_OPTIONS,
|
||||||
|
WALLET_FILTER_ALL,
|
||||||
|
emptyTransferFilters,
|
||||||
|
transferFiltersFromSearchParams,
|
||||||
|
walletAdminSelectDisplayedLabel,
|
||||||
|
type TransferFilters,
|
||||||
|
} from "@/modules/wallet/wallet-filter-utils";
|
||||||
|
import { walletStatusLabel } from "@/modules/wallet/wallet-labels";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type {
|
import type {
|
||||||
AdminPlayerWalletsData,
|
AdminPlayerWalletsData,
|
||||||
AdminTransferOrderItem,
|
AdminTransferOrderItem,
|
||||||
AdminTransferOrderListData,
|
AdminTransferOrderListData,
|
||||||
AdminWalletTxnListData,
|
|
||||||
} from "@/types/api/admin-wallet";
|
} from "@/types/api/admin-wallet";
|
||||||
|
|
||||||
/** 长单号/流水号:单行截断;点击复制全文,悬停可看全文 */
|
export { WalletTxnsPanel } from "@/modules/wallet/wallet-txns-panel";
|
||||||
function CellMonoId({
|
|
||||||
value,
|
|
||||||
empty = "—",
|
|
||||||
copyHint,
|
|
||||||
}: {
|
|
||||||
value: string | null | undefined;
|
|
||||||
empty?: string;
|
|
||||||
/** 用于 toast / 无障碍:如「流水号」「主站流水号」 */
|
|
||||||
copyHint?: string;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const { t } = useTranslation("wallet");
|
|
||||||
if (value == null || value === "") {
|
|
||||||
return <span className="text-muted-foreground">{empty}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const copy = async (e: React.MouseEvent): Promise<void> => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
toast.success(
|
|
||||||
copyHint
|
|
||||||
? t("copySuccess", { label: copyHint })
|
|
||||||
: t("copySuccess", { label: "" }).trim(),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
toast.error(t("copyFailed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
|
|
||||||
title={value}
|
|
||||||
aria-label={copyHint ?? t("copyTxnNo")}
|
|
||||||
onClick={(e) => void copy(e)}
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate">{value}</span>
|
|
||||||
<Copy
|
|
||||||
className="size-3.5 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function walletTxnBizTypeLabel(
|
|
||||||
bizType: string,
|
|
||||||
ledgerSource: string | null | undefined,
|
|
||||||
t: (key: string) => string,
|
|
||||||
tSettlement: TFunction<"settlementCenter">,
|
|
||||||
): string {
|
|
||||||
if (ledgerSource === "credit_ledger") {
|
|
||||||
return creditLedgerReasonLabel(bizType, tSettlement);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (bizType) {
|
|
||||||
case "transfer_in":
|
|
||||||
return t("transferIn");
|
|
||||||
case "transfer_out":
|
|
||||||
return t("transferOut");
|
|
||||||
case "transfer_out_refund":
|
|
||||||
return t("transferOutRefund");
|
|
||||||
case "bet_deduct":
|
|
||||||
return t("bizBetDeduct");
|
|
||||||
case "bet_reverse":
|
|
||||||
return t("bizBetReverse");
|
|
||||||
case "settle_payout":
|
|
||||||
return t("bizSettlePayout");
|
|
||||||
case "jackpot_manual_payout":
|
|
||||||
return t("bizJackpotPayout");
|
|
||||||
case "settlement_adjustment":
|
|
||||||
return t("bizSettlementAdjustment");
|
|
||||||
default:
|
|
||||||
return bizType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabelT(status: string, t: (key: string) => string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "processing":
|
|
||||||
return t("statusProcessing");
|
|
||||||
case "success":
|
|
||||||
return t("statusSuccess");
|
|
||||||
case "failed":
|
|
||||||
return t("statusFailed");
|
|
||||||
case "pending_reconcile":
|
|
||||||
return t("statusPendingReconcile");
|
|
||||||
case "reversed":
|
|
||||||
return t("statusReversed");
|
|
||||||
case "manually_processed":
|
|
||||||
return t("statusCaseClosed");
|
|
||||||
case "posted":
|
|
||||||
return t("statusPosted");
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type TransferFilters = {
|
|
||||||
playerId: string;
|
|
||||||
playerAccount: string;
|
|
||||||
transferNo: string;
|
|
||||||
externalRefNo: string;
|
|
||||||
createdFrom: string;
|
|
||||||
createdTo: string;
|
|
||||||
statusCsv: string;
|
|
||||||
abnormalOnly: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyTransferFilters: TransferFilters = {
|
|
||||||
playerId: "",
|
|
||||||
playerAccount: "",
|
|
||||||
transferNo: "",
|
|
||||||
externalRefNo: "",
|
|
||||||
createdFrom: "",
|
|
||||||
createdTo: "",
|
|
||||||
statusCsv: "",
|
|
||||||
abnormalOnly: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
type TxnFilters = {
|
|
||||||
playerId: string;
|
|
||||||
playerAccount: string;
|
|
||||||
txnNo: string;
|
|
||||||
externalRefNo: string;
|
|
||||||
bizType: string;
|
|
||||||
statusCsv: string;
|
|
||||||
createdFrom: string;
|
|
||||||
createdTo: string;
|
|
||||||
abnormalOnly: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyTxnFilters: TxnFilters = {
|
|
||||||
playerId: "",
|
|
||||||
playerAccount: "",
|
|
||||||
txnNo: "",
|
|
||||||
externalRefNo: "",
|
|
||||||
bizType: "",
|
|
||||||
statusCsv: "",
|
|
||||||
createdFrom: "",
|
|
||||||
createdTo: "",
|
|
||||||
abnormalOnly: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 下拉「不限」值;请求时转为空串不传参 */
|
|
||||||
const WALLET_FILTER_ALL = "__all__";
|
|
||||||
|
|
||||||
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
|
|
||||||
const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
|
|
||||||
{ value: "transfer_in", label: "transferIn" },
|
|
||||||
{ value: "transfer_out", label: "transferOut" },
|
|
||||||
{ value: "transfer_out_refund", label: "transferOutRefund" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
|
|
||||||
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
|
|
||||||
{ value: "posted", label: "statusPosted" },
|
|
||||||
{ value: "pending_reconcile", label: "statusPendingReconcile" },
|
|
||||||
{ value: "reversed", label: "statusReversed" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
|
|
||||||
const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
|
|
||||||
{ value: "processing", label: "statusProcessing" },
|
|
||||||
{ value: "success", label: "statusSuccess" },
|
|
||||||
{ value: "failed", label: "statusFailed" },
|
|
||||||
{ value: "pending_reconcile", label: "statusPendingReconcile" },
|
|
||||||
{ value: "reversed", label: "statusReversed" },
|
|
||||||
{ value: "manually_processed", label: "statusCaseClosed" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
|
|
||||||
function walletAdminSelectDisplayedLabel(
|
|
||||||
raw: unknown,
|
|
||||||
options: readonly { value: string; label: string }[],
|
|
||||||
t?: (key: string) => string,
|
|
||||||
): string {
|
|
||||||
const v = raw == null ? "" : String(raw);
|
|
||||||
if (v === "" || v === WALLET_FILTER_ALL) {
|
|
||||||
return t ? t("filterAll") : "All";
|
|
||||||
}
|
|
||||||
const key = options.find((o) => o.value === v)?.label;
|
|
||||||
return key ? (t ? t(key) : key) : v;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TransferOrderRowActionsProps = {
|
type TransferOrderRowActionsProps = {
|
||||||
row: AdminTransferOrderItem;
|
row: AdminTransferOrderItem;
|
||||||
@@ -317,24 +139,27 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
const urlFilterKey = searchParams.toString();
|
||||||
const transferNoFromUrl = (searchParams.get("transfer_no") ?? "").trim();
|
|
||||||
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
|
|
||||||
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
|
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
const initialTransferFilters: TransferFilters = {
|
const [draft, setDraft] = useState<TransferFilters>(() =>
|
||||||
...emptyTransferFilters,
|
transferFiltersFromSearchParams(searchParams),
|
||||||
playerId: playerIdFromUrl,
|
);
|
||||||
transferNo: transferNoFromUrl,
|
const [applied, setApplied] = useState<TransferFilters>(() =>
|
||||||
externalRefNo: externalRefNoFromUrl,
|
transferFiltersFromSearchParams(searchParams),
|
||||||
};
|
);
|
||||||
const [draft, setDraft] = useState<TransferFilters>(initialTransferFilters);
|
|
||||||
const [applied, setApplied] = useState<TransferFilters>(initialTransferFilters);
|
|
||||||
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
|
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = transferFiltersFromSearchParams(searchParams);
|
||||||
|
setDraft(next);
|
||||||
|
setApplied(next);
|
||||||
|
setPage(1);
|
||||||
|
}, [urlFilterKey, searchParams]);
|
||||||
|
|
||||||
const doAction = async (
|
const doAction = async (
|
||||||
transferNo: string,
|
transferNo: string,
|
||||||
fn: () => Promise<unknown>,
|
fn: () => Promise<unknown>,
|
||||||
@@ -399,7 +224,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
external_ref_no: applied.externalRefNo.trim() || undefined,
|
external_ref_no: applied.externalRefNo.trim() || undefined,
|
||||||
created_from: applied.createdFrom.trim() || undefined,
|
created_from: applied.createdFrom.trim() || undefined,
|
||||||
created_to: applied.createdTo.trim() || undefined,
|
created_to: applied.createdTo.trim() || undefined,
|
||||||
status: applied.statusCsv.trim() || undefined,
|
status: applied.abnormalOnly ? undefined : applied.statusCsv.trim() || undefined,
|
||||||
});
|
});
|
||||||
setData(d);
|
setData(d);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -432,6 +257,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
<CardTitle>{t("transferOrders")}</CardTitle>
|
<CardTitle>{t("transferOrders")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{applied.abnormalOnly ? (
|
||||||
|
<p className="rounded-md border border-amber-200/80 bg-amber-50 px-3 py-2 text-sm text-amber-950">
|
||||||
|
{t("abnormalFilterActive")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
|
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
|
||||||
@@ -571,10 +401,10 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
data.items.map((row) => (
|
data.items.map((row) => (
|
||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
|
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
|
||||||
<CellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
|
<WalletCellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
|
||||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
|
<WalletCellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<AdminAgentIdentityCells row={row} />
|
<AdminAgentIdentityCells row={row} />
|
||||||
<AdminPlayerIdentityCells row={row} />
|
<AdminPlayerIdentityCells row={row} />
|
||||||
@@ -585,7 +415,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
</AdminTableMoney>
|
</AdminTableMoney>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
|
<AdminStatusBadge status={row.status}>{walletStatusLabel(row.status, t)}</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
|
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
|
||||||
{row.fail_reason?.trim() ? row.fail_reason : "—"}
|
{row.fail_reason?.trim() ? row.fail_reason : "—"}
|
||||||
@@ -639,322 +469,6 @@ export function TransferOrdersPanel(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WalletTxnsPanel(): React.ReactElement {
|
|
||||||
const { t } = useTranslation(["wallet", "common"]);
|
|
||||||
const { t: tSettlement } = useTranslation("settlementCenter");
|
|
||||||
const tRef = useTranslationRef(["wallet", "common"]);
|
|
||||||
const exportLabels = useExportLabels("walletTransactions");
|
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
|
||||||
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [perPage, setPerPage] = useState(10);
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
|
|
||||||
const txnNoFromUrl = (searchParams.get("txn_no") ?? "").trim();
|
|
||||||
const externalRefNoFromUrl = (searchParams.get("external_ref_no") ?? "").trim();
|
|
||||||
const initialTxnFilters: TxnFilters = {
|
|
||||||
...emptyTxnFilters,
|
|
||||||
playerId: playerIdFromUrl,
|
|
||||||
txnNo: txnNoFromUrl,
|
|
||||||
externalRefNo: externalRefNoFromUrl,
|
|
||||||
};
|
|
||||||
const [draft, setDraft] = useState<TxnFilters>(initialTxnFilters);
|
|
||||||
const [applied, setApplied] = useState<TxnFilters>(initialTxnFilters);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setErr(null);
|
|
||||||
try {
|
|
||||||
const player_id =
|
|
||||||
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
|
|
||||||
const d = await getAdminWalletTransactions({
|
|
||||||
page,
|
|
||||||
per_page: perPage,
|
|
||||||
abnormal: applied.abnormalOnly || undefined,
|
|
||||||
player_id:
|
|
||||||
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
|
|
||||||
? player_id
|
|
||||||
: undefined,
|
|
||||||
player_account: applied.playerAccount.trim() || undefined,
|
|
||||||
txn_no: applied.txnNo.trim() || undefined,
|
|
||||||
external_ref_no: applied.externalRefNo.trim() || undefined,
|
|
||||||
created_from: applied.createdFrom.trim() || undefined,
|
|
||||||
created_to: applied.createdTo.trim() || undefined,
|
|
||||||
biz_type: applied.bizType.trim() || undefined,
|
|
||||||
status: applied.statusCsv.trim() || undefined,
|
|
||||||
});
|
|
||||||
setData(d);
|
|
||||||
} catch (e) {
|
|
||||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
|
||||||
setData(null);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [page, perPage, applied, tRef]);
|
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [page, perPage, applied]);
|
|
||||||
|
|
||||||
const runSearch = () => {
|
|
||||||
setApplied({ ...draft });
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
|
||||||
setDraft(emptyTxnFilters);
|
|
||||||
setApplied(emptyTxnFilters);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showLedgerColumn =
|
|
||||||
data?.items.some((row) => row.ledger_source === "credit_ledger") ?? false;
|
|
||||||
const txnTableColSpan = showLedgerColumn ? 12 : 11;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("walletTransactions")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="tx-no">{t("txnNo")}</Label>
|
|
||||||
<Input
|
|
||||||
id="tx-no"
|
|
||||||
placeholder={t("search")}
|
|
||||||
value={draft.txnNo}
|
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="tx-ext">{t("externalRefNo")}</Label>
|
|
||||||
<Input
|
|
||||||
id="tx-ext"
|
|
||||||
placeholder={t("search")}
|
|
||||||
value={draft.externalRefNo}
|
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="tx-account">{t("playerAccount")}</Label>
|
|
||||||
<Input
|
|
||||||
id="tx-account"
|
|
||||||
placeholder={t("playerAccountPlaceholder")}
|
|
||||||
value={draft.playerAccount}
|
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="tx-player">{t("playerId")}</Label>
|
|
||||||
<Input
|
|
||||||
id="tx-player"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder={t("playerIdOptional")}
|
|
||||||
value={draft.playerId}
|
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="tx-biz">{t("bizType")}</Label>
|
|
||||||
<Select
|
|
||||||
modal={false}
|
|
||||||
value={
|
|
||||||
draft.bizType === "" || !WALLET_TXN_BIZ_OPTIONS.some((o) => o.value === draft.bizType)
|
|
||||||
? WALLET_FILTER_ALL
|
|
||||||
: draft.bizType
|
|
||||||
}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setDraft((d) => ({
|
|
||||||
...d,
|
|
||||||
bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="tx-biz" className="h-8 w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent align="start" sideOffset={6}>
|
|
||||||
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
|
|
||||||
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
|
|
||||||
<SelectItem key={o.value} value={o.value}>
|
|
||||||
{t(o.label)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="tx-status">{t("status")}</Label>
|
|
||||||
<Select
|
|
||||||
modal={false}
|
|
||||||
value={
|
|
||||||
draft.statusCsv === "" || !WALLET_TXN_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
|
|
||||||
? WALLET_FILTER_ALL
|
|
||||||
: draft.statusCsv
|
|
||||||
}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setDraft((d) => ({
|
|
||||||
...d,
|
|
||||||
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="tx-status" className="h-8 w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent align="start" sideOffset={6}>
|
|
||||||
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
|
|
||||||
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
|
|
||||||
<SelectItem key={o.value} value={o.value}>
|
|
||||||
{t(o.label)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
|
|
||||||
<AdminDateRangeField
|
|
||||||
id="tx-created-range"
|
|
||||||
label={t("requestDateRange")}
|
|
||||||
from={draft.createdFrom}
|
|
||||||
to={draft.createdTo}
|
|
||||||
onRangeChange={(r) =>
|
|
||||||
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
|
|
||||||
<span className="text-sm font-medium leading-none">{t("options")}</span>
|
|
||||||
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={draft.abnormalOnly}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{t("abnormalOnlyPending")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<AdminTableExportButton
|
|
||||||
tableId="wallet-transactions-table"
|
|
||||||
filename={exportLabels.filename}
|
|
||||||
sheetName={exportLabels.sheetName}
|
|
||||||
/>
|
|
||||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
|
||||||
{t("search")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
|
||||||
{t("resetFilters")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
|
||||||
{t("refreshCurrentPage")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
|
||||||
{(loading && !data) || data ? (
|
|
||||||
<>
|
|
||||||
<div className="admin-table-shell overflow-x-auto rounded-md border">
|
|
||||||
<Table id="wallet-transactions-table" className="min-w-[1180px]">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="min-w-[10rem] whitespace-nowrap">{t("txnNo")}</TableHead>
|
|
||||||
<TableHead className="min-w-[8rem] whitespace-nowrap">{t("externalRefNo")}</TableHead>
|
|
||||||
<AdminAgentIdentityHeads />
|
|
||||||
<AdminPlayerIdentityHeads />
|
|
||||||
{showLedgerColumn ? (
|
|
||||||
<TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead>
|
|
||||||
) : null}
|
|
||||||
<TableHead className="min-w-[6.5rem] whitespace-nowrap">{t("type")}</TableHead>
|
|
||||||
<TableHead className="min-w-[6.5rem] whitespace-nowrap">{t("amount")}</TableHead>
|
|
||||||
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
|
|
||||||
<TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("requestTime")}</TableHead>
|
|
||||||
<TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("finishedTime")}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading && !data ? (
|
|
||||||
<AdminTableLoadingRow colSpan={txnTableColSpan} />
|
|
||||||
) : !data || data.items.length === 0 ? (
|
|
||||||
<AdminTableNoResourceRow colSpan={txnTableColSpan} />
|
|
||||||
) : (
|
|
||||||
data.items.map((row) => (
|
|
||||||
<TableRow key={row.id}>
|
|
||||||
<TableCell className="min-w-[10rem] max-w-[12rem] align-top">
|
|
||||||
<CellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="min-w-[8rem] max-w-[10rem] align-top">
|
|
||||||
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
|
|
||||||
</TableCell>
|
|
||||||
<AdminAgentIdentityCells row={row} />
|
|
||||||
<AdminPlayerIdentityCells row={row} />
|
|
||||||
{showLedgerColumn ? (
|
|
||||||
<TableCell className="align-top whitespace-nowrap">
|
|
||||||
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
|
||||||
</TableCell>
|
|
||||||
) : null}
|
|
||||||
<TableCell className="min-w-[6.5rem] max-w-[9rem] align-top text-xs">
|
|
||||||
<span
|
|
||||||
className="block truncate"
|
|
||||||
title={walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
|
|
||||||
>
|
|
||||||
{walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className={adminMoneyCellClassName("min-w-[6.5rem] text-right text-xs")}>
|
|
||||||
<AdminTableMoney>
|
|
||||||
{row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
|
|
||||||
</AdminTableMoney>
|
|
||||||
<span className="ml-1 text-muted-foreground">
|
|
||||||
({row.direction === 1 ? t("in") : t("out")})
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="align-top whitespace-nowrap">
|
|
||||||
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="min-w-[8.5rem] align-top whitespace-nowrap font-mono text-[11px] leading-snug text-muted-foreground">
|
|
||||||
{formatTs(row.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="min-w-[8.5rem] align-top whitespace-nowrap font-mono text-[11px] leading-snug text-muted-foreground">
|
|
||||||
{formatTs(row.updated_at)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
{data ? (
|
|
||||||
<AdminListPaginationFooter
|
|
||||||
selectId="wallet-transactions-per-page"
|
|
||||||
total={data.total}
|
|
||||||
page={page}
|
|
||||||
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
|
|
||||||
perPage={perPage}
|
|
||||||
loading={loading}
|
|
||||||
onPerPageChange={(next) => {
|
|
||||||
setPerPage(next);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlayerWalletPanel(): React.ReactElement {
|
export function PlayerWalletPanel(): React.ReactElement {
|
||||||
const { t } = useTranslation(["wallet", "common"]);
|
const { t } = useTranslation(["wallet", "common"]);
|
||||||
const tRef = useTranslationRef(["wallet", "common"]);
|
const tRef = useTranslationRef(["wallet", "common"]);
|
||||||
|
|||||||
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 = {
|
export type AgentProfilePayload = {
|
||||||
|
relative_share_rate?: number;
|
||||||
total_share_rate?: number;
|
total_share_rate?: number;
|
||||||
credit_limit?: number;
|
credit_limit?: number;
|
||||||
rebate_limit?: number;
|
rebate_limit?: number;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AdminAuditLogRow = {
|
|||||||
module_label: string;
|
module_label: string;
|
||||||
action_label: string;
|
action_label: string;
|
||||||
target_label: string;
|
target_label: string;
|
||||||
|
summary_label: string;
|
||||||
before_json: Record<string, unknown> | null;
|
before_json: Record<string, unknown> | null;
|
||||||
after_json: Record<string, unknown> | null;
|
after_json: Record<string, unknown> | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user