refactor(risk, navigation): update risk management redirects and enhance loading states
Changed default redirects in risk management pages to point to the new risk pools section. Removed unused risk lock log components and streamlined the admin reports page with a loading state for better user experience. Added a new DocFigure component for improved documentation visuals and updated localization files to include new figure descriptions.
This commit is contained in:
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import {
|
||||
@@ -233,7 +234,7 @@ export function AgentLineDetailPanel({
|
||||
|
||||
<AdminSubnav
|
||||
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
|
||||
className="overflow-x-auto border-b border-border/60 px-4 sm:px-5"
|
||||
className="min-h-11 overflow-x-auto border-b border-border/60 px-4 sm:px-5"
|
||||
>
|
||||
{tabs
|
||||
.filter((tab) => tab.visible)
|
||||
@@ -251,6 +252,10 @@ export function AgentLineDetailPanel({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
|
||||
{profileLoading && detailTab !== "overview" ? (
|
||||
<AdminLoadingInline className="py-16" />
|
||||
) : null}
|
||||
|
||||
{detailTab === "overview" ? (
|
||||
<OverviewTab
|
||||
profile={profile}
|
||||
@@ -259,7 +264,7 @@ export function AgentLineDetailPanel({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detailTab === "profile" && canViewProfileTab && profileFields ? (
|
||||
{detailTab === "profile" && canViewProfileTab && profileFields && !profileLoading ? (
|
||||
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm">
|
||||
<CardHeader className="border-b border-border/60 pb-4">
|
||||
<CardTitle className="text-base">
|
||||
@@ -303,7 +308,7 @@ export function AgentLineDetailPanel({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{detailTab === "downline" && canViewDownlineTab ? (
|
||||
{detailTab === "downline" && canViewDownlineTab && !profileLoading ? (
|
||||
<DownlineTable
|
||||
childAgents={childAgents}
|
||||
childCountById={childCountById}
|
||||
@@ -318,7 +323,7 @@ export function AgentLineDetailPanel({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detailTab === "players" && canViewPlayersTab ? (
|
||||
{detailTab === "players" && canViewPlayersTab && !profileLoading ? (
|
||||
<AgentsPlayersPanel
|
||||
siteCode={siteCode}
|
||||
agentNodeId={node.id}
|
||||
@@ -393,44 +398,49 @@ function OverviewTab({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!profileLoading && profile ? (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
value={`${rebateCap ?? "0"}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
value={`${percentValueToUi(profile.default_player_rebate ?? 0)}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||
value={
|
||||
(profile.risk_tags?.length ?? 0) > 0
|
||||
? profile.risk_tags!.join(", ")
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||
value={profileLoading ? "…" : `${rebateCap ?? "0"}%`}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||
value={
|
||||
profileLoading ? "…" : `${percentValueToUi(profile?.default_player_rebate ?? 0)}%`
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label={t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||
value={
|
||||
profileLoading
|
||||
? "…"
|
||||
: (profile?.risk_tags?.length ?? 0) > 0
|
||||
? profile!.risk_tags!.join(", ")
|
||||
: t("common:states.none", { defaultValue: "无" })
|
||||
}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
enabled={profile.can_grant_extra_rebate === true}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
enabled={profile.can_create_player !== false}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
<CapabilityMetric
|
||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
enabled={profile.can_create_child_agent === true}
|
||||
yesLabel={yesLabel}
|
||||
noLabel={noLabel}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -438,11 +448,13 @@ function OverviewTab({
|
||||
function CapabilityMetric({
|
||||
label,
|
||||
enabled,
|
||||
loading = false,
|
||||
yesLabel,
|
||||
noLabel,
|
||||
}: {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
loading?: boolean;
|
||||
yesLabel: string;
|
||||
noLabel: string;
|
||||
}): React.ReactElement {
|
||||
@@ -452,10 +464,10 @@ function CapabilityMetric({
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-2xl font-semibold tracking-tight",
|
||||
enabled ? "text-foreground" : "text-muted-foreground",
|
||||
loading ? "text-muted-foreground" : enabled ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{enabled ? yesLabel : noLabel}
|
||||
{loading ? "…" : enabled ? yesLabel : noLabel}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingInline } from "@/components/admin/admin-loading-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
||||
@@ -55,15 +56,6 @@ function pruneTreeForSearch(
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectExpandableIds(nodes: AgentNodeRow[], into: Set<number>): void {
|
||||
for (const node of nodes) {
|
||||
if ((node.children?.length ?? 0) > 0) {
|
||||
into.add(node.id);
|
||||
collectExpandableIds(node.children ?? [], into);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentLineSidebarProps = {
|
||||
siteLabel: string | null;
|
||||
/** API 返回的嵌套树(含 children) */
|
||||
@@ -72,6 +64,7 @@ export type AgentLineSidebarProps = {
|
||||
selectedId: number | null;
|
||||
keyword: string;
|
||||
agentCount: number;
|
||||
loading?: boolean;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSelect: (node: AgentNodeRow) => void;
|
||||
};
|
||||
@@ -167,6 +160,7 @@ export function AgentLineSidebar({
|
||||
selectedId,
|
||||
keyword,
|
||||
agentCount,
|
||||
loading = false,
|
||||
onKeywordChange,
|
||||
onSelect,
|
||||
}: AgentLineSidebarProps): React.ReactElement {
|
||||
@@ -180,9 +174,20 @@ export function AgentLineSidebar({
|
||||
}, [normalizedKeyword, parentNameMap, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new Set<number>();
|
||||
collectExpandableIds(tree, next);
|
||||
setExpandedIds(next);
|
||||
if (tree.length === 0) {
|
||||
setExpandedIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const node of tree) {
|
||||
if ((node.children?.length ?? 0) > 0) {
|
||||
next.add(node.id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [tree]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -258,7 +263,9 @@ export function AgentLineSidebar({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
{!hasAnyAgent ? (
|
||||
{loading ? (
|
||||
<AdminLoadingInline className="py-10" />
|
||||
) : !hasAnyAgent ? (
|
||||
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
|
||||
) : (
|
||||
<ul className="space-y-0.5" role="listbox" aria-label={t("listTitle", { defaultValue: "代理列表" })}>
|
||||
|
||||
@@ -464,8 +464,8 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const canShowDownlineTab = useMemo(
|
||||
() =>
|
||||
selectedNode !== null &&
|
||||
!selectedProfileLoading &&
|
||||
(isSiteAdmin ||
|
||||
(selectedProfileLoading ||
|
||||
isSiteAdmin ||
|
||||
isSuperAdmin ||
|
||||
selectedProfile?.can_create_child_agent === true),
|
||||
[isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
|
||||
@@ -474,11 +474,11 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const canShowPlayersTab = useMemo(
|
||||
() =>
|
||||
selectedNode !== null &&
|
||||
!selectedProfileLoading &&
|
||||
hasUsersManagePermission &&
|
||||
(isSiteAdmin ||
|
||||
isSuperAdmin ||
|
||||
selectedProfile?.can_create_player === true),
|
||||
(selectedProfileLoading ||
|
||||
(hasUsersManagePermission &&
|
||||
(isSiteAdmin ||
|
||||
isSuperAdmin ||
|
||||
selectedProfile?.can_create_player === true))),
|
||||
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
|
||||
);
|
||||
|
||||
@@ -747,7 +747,19 @@ export function AgentsConsole(): React.ReactElement {
|
||||
selectedProfileLoading,
|
||||
]);
|
||||
|
||||
const showAgentSidebar = visibleAgentRows.length > 0;
|
||||
const showAgentSidebar = loading || visibleAgentRows.length > 0;
|
||||
|
||||
const hasSiteContext =
|
||||
siteOptions.length > 0 ||
|
||||
profile?.site != null ||
|
||||
(profile?.accessible_sites?.length ?? 0) > 0;
|
||||
|
||||
const isAgentLineBootLoading =
|
||||
canViewAgents &&
|
||||
(sitesLoading ||
|
||||
profile === null ||
|
||||
(hasSiteContext && adminSiteId === null) ||
|
||||
(adminSiteId !== null && loading));
|
||||
|
||||
const openAddAgent = (): void => {
|
||||
const parent = selectedNode ?? rootNode;
|
||||
@@ -910,17 +922,12 @@ export function AgentsConsole(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
const hasSiteContext =
|
||||
siteOptions.length > 0 ||
|
||||
profile?.site != null ||
|
||||
(profile?.accessible_sites?.length ?? 0) > 0;
|
||||
|
||||
if (canViewAgents && profile?.agent == null && !sitesLoading && !hasSiteContext) {
|
||||
return <AdminNoIntegrationSiteState canCreate={isSuperAdmin} />;
|
||||
}
|
||||
|
||||
if (canViewAgents && loading && tree.length === 0 && adminSiteId !== null) {
|
||||
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
|
||||
if (canViewAgents && isAgentLineBootLoading) {
|
||||
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} minHeight="28rem" />;
|
||||
}
|
||||
|
||||
const showSiteAdminAwaitingRoot =
|
||||
@@ -980,6 +987,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
selectedId={selectedNodeId}
|
||||
keyword={keyword}
|
||||
agentCount={visibleAgentRows.length}
|
||||
loading={loading && visibleAgentRows.length === 0}
|
||||
onKeywordChange={(value) => {
|
||||
setKeyword(value);
|
||||
}}
|
||||
|
||||
@@ -170,9 +170,6 @@ export function RiskCapRuntimePanel() {
|
||||
>
|
||||
{t("filterSoldOut", { ns: "risk" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/occupancy`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskLockLogs", { ns: "draws" })}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
DocFigure,
|
||||
DocList,
|
||||
DocNote,
|
||||
DocOrderedList,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
DocSection,
|
||||
DocTable,
|
||||
} from "@/components/docs/doc-ui";
|
||||
import { ADMIN_MANUAL_IMAGES } from "@/lib/admin-manual-images";
|
||||
import { useAdminDoc } from "@/modules/docs/admin/use-admin-doc";
|
||||
|
||||
export function AdminOverviewDocScreen(): React.ReactElement {
|
||||
@@ -20,6 +22,7 @@ export function AdminOverviewDocScreen(): React.ReactElement {
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocFigure src={ADMIN_MANUAL_IMAGES.cover} alt={p("figureAlt")} />
|
||||
<DocNote>{p("loginNote")}</DocNote>
|
||||
<DocSection title={p("scope")}>
|
||||
<DocList items={list("scopeItems")} />
|
||||
@@ -65,6 +68,7 @@ export function AdminSiteSetupDocScreen(): React.ReactElement {
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocFigure src={ADMIN_MANUAL_IMAGES.integrationSite} alt={p("figureAlt")} />
|
||||
<DocSection title={p("path")}>
|
||||
<DocOrderedList items={list("pathItems")} />
|
||||
</DocSection>
|
||||
@@ -94,6 +98,7 @@ export function AdminDrawsDocScreen(): React.ReactElement {
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("lifecycle")}>
|
||||
<DocFigure src={ADMIN_MANUAL_IMAGES.drawLifecycle} alt={p("figureAlt")} />
|
||||
<DocTable compact headers={header("status")} rows={rows("statusRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("workflow")}>
|
||||
@@ -128,6 +133,7 @@ export function AdminSettlementCenterDocScreen(): React.ReactElement {
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocFigure src={ADMIN_MANUAL_IMAGES.settlementDual} alt={p("figureAlt")} />
|
||||
<DocSection title={p("entry")}>
|
||||
<DocList items={list("entryItems")} />
|
||||
</DocSection>
|
||||
@@ -172,6 +178,7 @@ export function AdminAgentsDocScreen(): React.ReactElement {
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocFigure src={ADMIN_MANUAL_IMAGES.agentTree} alt={p("figureAlt")} />
|
||||
<DocSection title={p("structure")}>
|
||||
<DocOrderedList items={list("structureItems")} />
|
||||
</DocSection>
|
||||
@@ -208,6 +215,7 @@ export function AdminPlayersDocScreen(): React.ReactElement {
|
||||
<DocOrderedList items={list("freezeSteps")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("modes")}>
|
||||
<DocFigure src={ADMIN_MANUAL_IMAGES.walletVsCredit} alt={p("figureAlt")} />
|
||||
<DocTable compact headers={header("module")} rows={rows("modeRows")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("detail")}>
|
||||
@@ -300,6 +308,7 @@ export function AdminFundOperationsDocScreen(): React.ReactElement {
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocSection title={p("twoSystems")}>
|
||||
<DocFigure src={ADMIN_MANUAL_IMAGES.settlementDual} alt={p("figureAlt")} />
|
||||
<DocList items={list("twoSystemsItems")} />
|
||||
</DocSection>
|
||||
<DocSection title={p("creditModel")}>
|
||||
@@ -340,6 +349,7 @@ export function AdminManualReviewDocScreen(): React.ReactElement {
|
||||
return (
|
||||
<DocPage>
|
||||
<DocPageHeader title={p("title")} description={p("description")} />
|
||||
<DocFigure src={ADMIN_MANUAL_IMAGES.reviewPayout} alt={p("figureAlt")} />
|
||||
<DocSection title={p("distinction")}>
|
||||
<DocList items={list("distinctionItems")} />
|
||||
</DocSection>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminDrawResultBatches } from "@/api/admin-draws";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
@@ -20,7 +18,6 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { canManageDrawResults } from "@/lib/draw-access";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -76,21 +73,11 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<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>
|
||||
{canManageDraw ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("reviewAndPublish")}
|
||||
</Link>
|
||||
) : null}
|
||||
<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 ? (
|
||||
|
||||
@@ -15,13 +15,6 @@ const segments = [
|
||||
{ suffix: "/results", key: "results", label: "subnav.results", requiresManage: false },
|
||||
{ suffix: "/finance", key: "finance", label: "subnav.finance", requiresManage: false },
|
||||
{ suffix: "/review", key: "review", label: "subnav.review", requiresManage: true },
|
||||
{
|
||||
suffix: "/risk/occupancy",
|
||||
key: "riskLockLogs",
|
||||
label: "subnav.riskLockLogs",
|
||||
requiresManage: false,
|
||||
requiresRisk: true,
|
||||
},
|
||||
{
|
||||
suffix: "/risk/pools",
|
||||
key: "riskPools",
|
||||
|
||||
@@ -12,7 +12,6 @@ import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
||||
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -43,7 +42,6 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
|
||||
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
|
||||
import { Eye } from "lucide-react";
|
||||
|
||||
function playerStatusLabel(status: number, t: (key: string) => string): string {
|
||||
if (status === 0) return t("statusNormal");
|
||||
@@ -434,18 +432,22 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<TableHead className="text-center">{t("winAmount", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("updatedAt", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 w-12 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("table.actions", { ns: "common" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ticketsLoading && tickets.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={11} />
|
||||
<AdminTableLoadingRow colSpan={10} />
|
||||
) : null}
|
||||
{tickets.map((row) => (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link
|
||||
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.ticket_no}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||
@@ -472,22 +474,10 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.updated_at ? formatDt(row.updated_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view-ticket-in-list",
|
||||
label: t("viewTicketInList", { ns: "tickets" }),
|
||||
icon: Eye,
|
||||
href: `/admin/tickets?player_id=${player.id}&number=${encodeURIComponent(row.ticket_no)}${row.draw_no ? `&draw_no=${encodeURIComponent(row.draw_no)}` : ""}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!ticketsLoading && tickets.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={11} className="text-muted-foreground" />
|
||||
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
|
||||
import { Eye, ShieldAlert } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
@@ -13,13 +12,19 @@ import {
|
||||
getAdminReconcileJobs,
|
||||
postAdminReconcileJob,
|
||||
} from "@/api/admin-reconcile";
|
||||
import {
|
||||
completeTransferInCredit,
|
||||
manuallyProcessTransferOrder,
|
||||
reverseTransferOrder,
|
||||
} from "@/api/admin-wallet";
|
||||
import { ReconcileItemActions } from "@/modules/reconcile/reconcile-item-actions";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button, buttonVariants } 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";
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -84,6 +90,10 @@ function itemStatusLabel(status: string, t: (key: string) => string): string {
|
||||
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;
|
||||
}
|
||||
@@ -98,6 +108,21 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -109,71 +134,6 @@ function itemResolutionTone(row: Pick<AdminReconcileItemsData["items"][number],
|
||||
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
|
||||
}
|
||||
|
||||
function itemDiagnosisLabel(status: string, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case "stale_processing":
|
||||
return t("diagnosisStaleProcessing");
|
||||
case "pending_reconcile":
|
||||
return t("diagnosisPendingReconcile");
|
||||
case "missing_wallet_txn":
|
||||
return t("diagnosisMissingWalletTxn");
|
||||
case "unexpected_wallet_txn":
|
||||
return t("diagnosisUnexpectedWalletTxn");
|
||||
case "missing_refund":
|
||||
return t("diagnosisMissingRefund");
|
||||
case "missing_reversal":
|
||||
return t("diagnosisMissingReversal");
|
||||
case "matched":
|
||||
return t("diagnosisMatched");
|
||||
default:
|
||||
return t("diagnosisPendingCheck");
|
||||
}
|
||||
}
|
||||
|
||||
function itemSuggestedAction(
|
||||
row: Pick<AdminReconcileItemsData["items"][number], "status" | "resolved_at" | "is_resolved" | "current_transfer_status">,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (row.is_resolved === true || row.resolved_at) {
|
||||
return t("actionResolved", {
|
||||
status: row.current_transfer_status ? itemTransferStatusLabel(row.current_transfer_status, t) : t("statusCompleted"),
|
||||
});
|
||||
}
|
||||
|
||||
const status = row.status;
|
||||
switch (status) {
|
||||
case "stale_processing":
|
||||
return t("actionStaleProcessing");
|
||||
case "pending_reconcile":
|
||||
return t("actionPendingReconcile");
|
||||
case "missing_wallet_txn":
|
||||
return t("actionMissingWalletTxn");
|
||||
case "unexpected_wallet_txn":
|
||||
return t("actionUnexpectedWalletTxn");
|
||||
case "missing_refund":
|
||||
return t("actionMissingRefund");
|
||||
case "missing_reversal":
|
||||
return t("actionMissingReversal");
|
||||
case "matched":
|
||||
return t("actionMatched");
|
||||
default:
|
||||
return t("actionPendingCheck");
|
||||
}
|
||||
}
|
||||
|
||||
function itemTransferStatusLabel(status: string, t: (key: string) => string): string {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return t("transferStatusSuccess");
|
||||
case "reversed":
|
||||
return t("transferStatusReversed");
|
||||
case "manually_processed":
|
||||
return t("transferStatusManual");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function getJobSummaryValue(summary: Record<string, unknown> | null | undefined, key: string): number {
|
||||
const raw = summary?.[key];
|
||||
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
||||
@@ -194,6 +154,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
||||
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
|
||||
const [jobs, setJobs] = useState<AdminReconcileJobListData | null>(null);
|
||||
@@ -216,6 +177,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const [playerLoading, setPlayerLoading] = useState(false);
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setJobsLoading(true);
|
||||
@@ -299,13 +261,17 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await postAdminReconcileJob({
|
||||
const resp = await postAdminReconcileJob({
|
||||
reconcile_type: RECONCILE_TYPE,
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
player_id: selectedPlayer ? selectedPlayer.id : null,
|
||||
});
|
||||
toast.success(t("createSuccess"));
|
||||
const count =
|
||||
typeof resp.summary_json?.item_count === "number"
|
||||
? resp.summary_json.item_count
|
||||
: resp.item_count ?? 0;
|
||||
toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty"));
|
||||
setPage(1);
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
@@ -320,13 +286,54 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function runTransferAction(
|
||||
transferNo: string,
|
||||
action: "reverse" | "complete_credit" | "manually_process",
|
||||
): Promise<void> {
|
||||
const confirmKey =
|
||||
action === "reverse"
|
||||
? "reverse"
|
||||
: action === "complete_credit"
|
||||
? "completeCredit"
|
||||
: "markCaseClosed";
|
||||
const successKey =
|
||||
action === "reverse"
|
||||
? "reverseSuccess"
|
||||
: action === "complete_credit"
|
||||
? "completeCreditSuccess"
|
||||
: "markCaseClosedSuccess";
|
||||
|
||||
requestConfirm({
|
||||
title: t(`confirm.${confirmKey}Title`, { ns: "wallet" }),
|
||||
description: t(`confirm.${confirmKey}Description`, { ns: "wallet", transferNo }),
|
||||
onConfirm: async () => {
|
||||
setActionBusy(true);
|
||||
try {
|
||||
if (action === "reverse") {
|
||||
await reverseTransferOrder(transferNo);
|
||||
} else if (action === "complete_credit") {
|
||||
await completeTransferInCredit(transferNo);
|
||||
} else {
|
||||
await manuallyProcessTransferOrder(transferNo);
|
||||
}
|
||||
toast.success(t(successKey, { ns: "wallet" }));
|
||||
await loadItems();
|
||||
await loadJobs();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { ns: "wallet" }));
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const hasSelectedRange = dateFrom.trim() !== "" && dateTo.trim() !== "";
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
@@ -334,52 +341,30 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t("createHint")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border bg-muted/15 p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="rounded-lg bg-background p-2 text-muted-foreground">
|
||||
<CalendarRange className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("scopeTitle")}</div>
|
||||
<p className="text-sm text-muted-foreground">{t("scopeDescription")}</p>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
||||
<p className="text-xs text-muted-foreground">{t("reconcileTypeHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<AdminDateRangeField
|
||||
id="rc-date-range"
|
||||
label={t("dateRange")}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("dateRangeHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<AdminDateRangeField
|
||||
id="rc-date-range"
|
||||
label={t("dateRange")}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
setDateFrom(from);
|
||||
setDateTo(to);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-background p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="rounded-lg bg-muted/20 p-2 text-muted-foreground">
|
||||
<UserRound className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t("playerScopeTitle")}</div>
|
||||
<p className="text-sm text-muted-foreground">{t("playerSearchHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
||||
<Input
|
||||
@@ -391,16 +376,12 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{selectedPlayer ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{selectedPlayer.site_player_id}
|
||||
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
||||
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{t("playerSelected")} · {selectedPlayer.site_code}
|
||||
</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"
|
||||
@@ -418,7 +399,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
) : null}
|
||||
|
||||
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
||||
<div className="mt-4 rounded-lg border bg-background">
|
||||
<div className="rounded-lg border bg-background">
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{playerLoading ? (
|
||||
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
|
||||
@@ -433,25 +414,19 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
key={player.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
||||
active && "bg-muted/30",
|
||||
"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);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{player.site_player_id}
|
||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{player.username ?? "—"} · {player.site_code}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{active ? t("playerSelectedShort") : t("playerChoose")}
|
||||
<span className="min-w-0 truncate">
|
||||
{player.site_player_id}
|
||||
{player.nickname ? ` · ${player.nickname}` : ""}
|
||||
{player.username ? ` · ${player.username}` : ""}
|
||||
{` · ${player.site_code}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -460,31 +435,11 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-lg border border-dashed bg-muted/10 px-3 py-3 text-sm text-muted-foreground">
|
||||
{t("playerAllPlayersHint")}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3">
|
||||
<div className="min-w-0 text-sm text-muted-foreground">
|
||||
{hasSelectedRange
|
||||
? selectedPlayer
|
||||
? t("createSummaryPlayer", {
|
||||
player: selectedPlayer.site_player_id,
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
})
|
||||
: t("createSummaryAll", {
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
})
|
||||
: t("createSummaryPending", {
|
||||
defaultValue: "请选择完整的对账日期范围后,再创建任务。",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full sm:w-auto"
|
||||
@@ -696,14 +651,14 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead className="min-w-[10rem]">{t("sideARef")}</TableHead>
|
||||
<TableHead className="min-w-[10rem]">{t("sideBRef")}</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="min-w-[16rem] whitespace-normal leading-snug">{t("diagnosis")}</TableHead>
|
||||
<TableHead className="min-w-[16rem] whitespace-normal leading-snug">{t("suggestedAction")}</TableHead>
|
||||
<TableHead className="w-28">{t("processingStatus")}</TableHead>
|
||||
<TableHead className="w-32">{t("quickAccess")}</TableHead>
|
||||
<TableHead className="min-w-[12rem]">{t("actions")}</TableHead>
|
||||
<TableHead className="w-36">{t("detectedAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -715,13 +670,18 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<TableRow
|
||||
key={r.id}
|
||||
className={cn(
|
||||
r.status === "mismatch" && "bg-amber-500/5",
|
||||
r.status === "matched" && "bg-emerald-500/5",
|
||||
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(
|
||||
@@ -736,39 +696,20 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{itemStatusLabel(r.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top min-w-[16rem] max-w-[18rem] whitespace-normal break-words text-xs leading-6 text-muted-foreground">
|
||||
{itemDiagnosisLabel(r.status, t)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top min-w-[16rem] max-w-[18rem] whitespace-normal break-words text-xs leading-6">
|
||||
{itemSuggestedAction(r, t)}
|
||||
</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-[10rem]">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{r.side_a_ref ? (
|
||||
<Link
|
||||
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(r.side_a_ref)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||
>
|
||||
{t("openTransferOrder")}
|
||||
</Link>
|
||||
) : null}
|
||||
{r.side_b_ref ? (
|
||||
<Link
|
||||
href={`/admin/wallet/transactions?txn_no=${encodeURIComponent(r.side_b_ref)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||
>
|
||||
{t("openWalletTxn")}
|
||||
</Link>
|
||||
) : null}
|
||||
{!r.side_a_ref && !r.side_b_ref ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : null}
|
||||
</div>
|
||||
<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)}
|
||||
|
||||
97
src/modules/reconcile/reconcile-item-actions.tsx
Normal file
97
src/modules/reconcile/reconcile-item-actions.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { RotateCcw, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
canCompleteTransferInCredit,
|
||||
canManuallyProcessTransferOrder,
|
||||
canReverseTransferOrder,
|
||||
transferOrderHasReconcileAction,
|
||||
} from "@/lib/wallet-transfer-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminReconcileItemRow } from "@/types/api/admin-reconcile";
|
||||
|
||||
type ReconcileItemActionsProps = {
|
||||
row: AdminReconcileItemRow;
|
||||
canWriteWallet: boolean;
|
||||
busy: boolean;
|
||||
onCompleteCredit: (transferNo: string) => void;
|
||||
onReverse: (transferNo: string) => void;
|
||||
onManualProcess: (transferNo: string) => void;
|
||||
};
|
||||
|
||||
export function ReconcileItemActions({
|
||||
row,
|
||||
canWriteWallet,
|
||||
busy,
|
||||
onCompleteCredit,
|
||||
onReverse,
|
||||
onManualProcess,
|
||||
}: ReconcileItemActionsProps): React.ReactElement {
|
||||
const { t } = useTranslation(["reconcile", "wallet"]);
|
||||
|
||||
const transferNo = row.side_a_ref ?? "";
|
||||
const actionRow = {
|
||||
direction: row.transfer_direction ?? undefined,
|
||||
status: row.current_transfer_status ?? row.status,
|
||||
fail_reason: row.transfer_fail_reason,
|
||||
external_ref_no: row.external_ref_no,
|
||||
can_reverse: row.can_reverse,
|
||||
can_complete_credit: row.can_complete_credit,
|
||||
can_manually_process: row.can_manually_process,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{transferNo !== "" && transferOrderHasReconcileAction(actionRow, canWriteWallet) ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={busy}
|
||||
actions={[
|
||||
{
|
||||
key: "complete",
|
||||
label: t("completeCredit", { ns: "wallet" }),
|
||||
hidden: !canCompleteTransferInCredit(actionRow, canWriteWallet),
|
||||
onClick: () => onCompleteCredit(transferNo),
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
label: t("markCaseClosed", { ns: "wallet" }),
|
||||
icon: Wrench,
|
||||
hidden: !canManuallyProcessTransferOrder(actionRow, canWriteWallet),
|
||||
onClick: () => onManualProcess(transferNo),
|
||||
},
|
||||
{
|
||||
key: "reverse",
|
||||
label: t("reverse", { ns: "wallet" }),
|
||||
icon: RotateCcw,
|
||||
destructive: true,
|
||||
hidden: !canReverseTransferOrder(actionRow, canWriteWallet),
|
||||
onClick: () => onReverse(transferNo),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{transferNo !== "" ? (
|
||||
<Link
|
||||
href={`/admin/wallet/transfer-orders?transfer_no=${encodeURIComponent(transferNo)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||
>
|
||||
{t("openTransferOrder")}
|
||||
</Link>
|
||||
) : null}
|
||||
{row.side_b_ref ? (
|
||||
<Link
|
||||
href={`/admin/wallet/transactions?txn_no=${encodeURIComponent(row.side_b_ref)}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||||
>
|
||||
{t("openWalletTxn")}
|
||||
</Link>
|
||||
) : null}
|
||||
{!transferNo && !row.side_b_ref ? <span>—</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
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";
|
||||
@@ -208,6 +208,68 @@ const emptyFilters: ReportFilters = {
|
||||
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_default)?.code;
|
||||
return fallback?.trim() || "NPR";
|
||||
}
|
||||
|
||||
function reportTimeAxisKey(key: ReportKey): "businessDate" | "recordCreatedAt" | null {
|
||||
switch (key) {
|
||||
case "daily_profit":
|
||||
case "player_win_loss":
|
||||
case "play_dimension":
|
||||
case "rebate_commission":
|
||||
return "businessDate";
|
||||
case "player_transfer":
|
||||
case "admin_audit":
|
||||
return "recordCreatedAt";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function reportDisclaimerKey(key: ReportKey): string | null {
|
||||
switch (key) {
|
||||
case "draw_profit":
|
||||
case "daily_profit":
|
||||
case "player_win_loss":
|
||||
case "play_dimension":
|
||||
return "items.profit_reports.disclaimer";
|
||||
case "player_transfer":
|
||||
return "items.player_transfer.disclaimer";
|
||||
case "rebate_commission":
|
||||
return "items.rebate_commission.disclaimer";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const emptySearch: SearchState = {
|
||||
open: null,
|
||||
query: "",
|
||||
@@ -306,6 +368,7 @@ function buildDailyProfitRowsAndSummary(
|
||||
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;
|
||||
@@ -327,11 +390,11 @@ function buildDailyProfitRowsAndSummary(
|
||||
rows,
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(total) },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
|
||||
{
|
||||
label: pageScopedLabel("houseGross"),
|
||||
value: formatPlainMoney(totalGross, "NPR"),
|
||||
value: formatPlainMoney(totalGross, currencyCode),
|
||||
tone: totalGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
],
|
||||
@@ -390,6 +453,7 @@ function buildPlayDimensionRowsAndSummary(
|
||||
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;
|
||||
@@ -411,8 +475,8 @@ function buildPlayDimensionRowsAndSummary(
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) },
|
||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -422,6 +486,7 @@ function buildRebateCommissionRowsAndSummary(
|
||||
total: number,
|
||||
t: (key: string) => string,
|
||||
pageScopedLabel: (statKey: string) => string,
|
||||
currencyCode: string,
|
||||
): Pick<Extract<ReportResult, { key: "rebate_commission" }>, "rows" | "summary"> {
|
||||
let totalRebate = 0;
|
||||
let totalOrders = 0;
|
||||
@@ -442,7 +507,7 @@ function buildRebateCommissionRowsAndSummary(
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
||||
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, "NPR") },
|
||||
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, currencyCode) },
|
||||
{ label: pageScopedLabel("orders"), value: String(totalOrders) },
|
||||
],
|
||||
};
|
||||
@@ -654,7 +719,8 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(
|
||||
filteredReports[0]?.key ?? REPORTS[0].key,
|
||||
);
|
||||
const [filters, setFilters] = useState<ReportFilters>(emptyFilters);
|
||||
const [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
|
||||
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
|
||||
const [result, setResult] = useState<ReportResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -852,6 +918,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const summary = await getAdminDrawFinanceSummary(draw.id);
|
||||
setDisplayCurrency(resolveDisplayCurrency(summary.currency_code));
|
||||
setResult({
|
||||
key: "draw_profit",
|
||||
raw: summary,
|
||||
@@ -874,7 +941,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const payload = await getAdminReportDailyProfit(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
|
||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||
setDisplayCurrency(currencyCode);
|
||||
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
||||
setResult({
|
||||
key: "daily_profit",
|
||||
raw: payload.items,
|
||||
@@ -888,6 +957,8 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const payload = await getAdminReportPlayerWinLoss(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||
setDisplayCurrency(currencyCode);
|
||||
const rows = payload.items.map((item) => ({
|
||||
player_id: item.player_id,
|
||||
username: item.username,
|
||||
@@ -907,7 +978,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
label: pageScopedLabel("houseGross"),
|
||||
value: formatPlainMoney(
|
||||
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
||||
"NPR",
|
||||
currencyCode,
|
||||
),
|
||||
tone: (() => {
|
||||
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
|
||||
@@ -937,6 +1008,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
t,
|
||||
pageScopedLabel,
|
||||
);
|
||||
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
|
||||
setResult({
|
||||
key: "player_transfer",
|
||||
raw: payload.items,
|
||||
@@ -956,6 +1028,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
||||
setDisplayCurrency(resolveDisplayCurrency(detail.currency_code));
|
||||
const rows: ExportRow[] = [
|
||||
{
|
||||
row_type: "risk_pool",
|
||||
@@ -1007,6 +1080,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
||||
});
|
||||
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
||||
setDisplayCurrency(resolveDisplayCurrency(payload.currency_code));
|
||||
const rows = payload.items.map((item) => ({
|
||||
draw_id: payload.draw_id,
|
||||
draw_no: payload.draw_no,
|
||||
@@ -1038,7 +1112,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const payload = await getAdminReportPlayDimension(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
|
||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||
setDisplayCurrency(currencyCode);
|
||||
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
||||
setResult({
|
||||
key: "play_dimension",
|
||||
raw: payload.items,
|
||||
@@ -1052,7 +1128,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const payload = await getAdminReportRebateCommission(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
|
||||
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
||||
setDisplayCurrency(currencyCode);
|
||||
const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
||||
setResult({
|
||||
key: "rebate_commission",
|
||||
raw: payload.items,
|
||||
@@ -1149,7 +1227,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
setFilters(emptyFilters);
|
||||
setFilters(reportHasPeriodField(selectedReport) ? createDefaultFilters() : { ...emptyFilters });
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
@@ -1521,9 +1599,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
<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, null)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</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>
|
||||
@@ -1536,10 +1614,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
<TableRow key={item.business_date}>
|
||||
<TableCell className="font-medium">{item.business_date}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, "NPR")}>
|
||||
{formatPlainMoney(item.approx_house_gross_minor, "NPR")}
|
||||
<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>
|
||||
@@ -1556,10 +1634,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
{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, "NPR")}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.net_win_loss_minor, "NPR")}>
|
||||
{formatPlainMoney(item.net_win_loss_minor, "NPR")}
|
||||
<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>
|
||||
@@ -1573,10 +1651,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
<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, "NPR")}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, "NPR")}>
|
||||
{formatPlainMoney(item.approx_house_gross_minor, "NPR")}
|
||||
<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>
|
||||
@@ -1590,7 +1668,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
<TableRow key={item.play_code}>
|
||||
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
|
||||
<TableCell>{item.order_count}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{item.ticket_item_count}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
@@ -1648,6 +1726,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
|
||||
{reportTimeAxisKey(selectedReport.key) ? (
|
||||
<p className="text-xs text-muted-foreground">{t(`timeAxis.${reportTimeAxisKey(selectedReport.key)}`)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
@@ -1655,7 +1736,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
{selectedReport.fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-muted-foreground">{t("filterPanel")}</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div>{t("filterPanel")}</div>
|
||||
<div>{t("queryHint")}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
@@ -1686,12 +1770,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedReport.key === "rebate_commission" ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
{t("items.rebate_commission.disclaimer", {
|
||||
defaultValue:
|
||||
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
|
||||
})}
|
||||
{reportDisclaimerKey(selectedReport.key) ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
{t(reportDisclaimerKey(selectedReport.key)!)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ export function RiskIndexConsole() {
|
||||
key: "risk",
|
||||
label: t("enterRisk"),
|
||||
icon: Shield,
|
||||
href: `/admin/draws/${row.id}/risk/occupancy`,
|
||||
href: `/admin/draws/${row.id}/risk/pools`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { riskActionTypeLabel, riskSourceReasonLabel } from "@/modules/risk/risk-display";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminRiskLockLogListData,
|
||||
AdminRiskLockLogRow,
|
||||
AdminRiskLockLogTicketRow,
|
||||
} from "@/types/api/admin-risk";
|
||||
|
||||
const ACTION_ALL = "__all__";
|
||||
const GROUP_TICKET = "ticket";
|
||||
const GROUP_ENTRY = "entry";
|
||||
|
||||
function riskActionFilterLabel(value: string, t: (key: string) => string): string {
|
||||
if (value === ACTION_ALL) {
|
||||
return t("noLimit");
|
||||
}
|
||||
return riskActionTypeLabel(value, t);
|
||||
}
|
||||
|
||||
function isTicketGroupData(
|
||||
data: AdminRiskLockLogListData | null,
|
||||
): data is AdminRiskLockLogListData & { group_by: "ticket"; items: AdminRiskLockLogTicketRow[] } {
|
||||
return data?.group_by === "ticket";
|
||||
}
|
||||
|
||||
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskLockLogs");
|
||||
useAdminCurrencyCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [data, setData] = useState<AdminRiskLockLogListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [draftNumber, setDraftNumber] = useState("");
|
||||
const [appliedNumber, setAppliedNumber] = useState("");
|
||||
const [draftAction, setDraftAction] = useState<string>(ACTION_ALL);
|
||||
const [appliedAction, setAppliedAction] = useState<string>(ACTION_ALL);
|
||||
const [draftGroupBy, setDraftGroupBy] = useState<"ticket" | "entry">(GROUP_TICKET);
|
||||
const [appliedGroupBy, setAppliedGroupBy] = useState<"ticket" | "entry">(GROUP_TICKET);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getAdminRiskPoolLockLogs(drawId, {
|
||||
page,
|
||||
per_page: perPage,
|
||||
group_by: appliedGroupBy,
|
||||
normalized_number: appliedNumber.trim() === "" ? undefined : appliedNumber.trim(),
|
||||
action_type:
|
||||
appliedAction === ACTION_ALL ? undefined : (appliedAction as "lock" | "release"),
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("loadLogsFailed");
|
||||
setError(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber, appliedGroupBy, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [drawId, page, perPage, appliedAction, appliedNumber, appliedGroupBy]);
|
||||
|
||||
const ticketGrouped = isTicketGroupData(data);
|
||||
const currencyCode = data?.currency_code ?? "NPR";
|
||||
|
||||
return (
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header space-y-1">
|
||||
<CardTitle className="admin-list-title">{t("lockLogsTitle")}</CardTitle>
|
||||
<CardDescription className="text-xs">{t("lockLogsGroupedHint")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-group" className="sm:w-20 sm:shrink-0">
|
||||
{t("groupBy")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draftGroupBy}
|
||||
onValueChange={(v) => {
|
||||
if (v === GROUP_TICKET || v === GROUP_ENTRY) setDraftGroupBy(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-log-group" size="sm" className="h-8 w-full sm:w-44">
|
||||
<SelectValue>
|
||||
{draftGroupBy === GROUP_TICKET ? t("groupByTicket") : t("groupByEntry")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={GROUP_TICKET}>{t("groupByTicket")}</SelectItem>
|
||||
<SelectItem value={GROUP_ENTRY}>{t("groupByEntry")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-number" className="sm:w-20 sm:shrink-0">
|
||||
{t("number4d")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-log-number"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={draftNumber}
|
||||
className="h-8 w-full font-mono sm:w-32"
|
||||
onChange={(e) => setDraftNumber(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-action" className="sm:w-20 sm:shrink-0">
|
||||
{t("actionFilter")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draftAction}
|
||||
onValueChange={(v) => {
|
||||
if (v) setDraftAction(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="h-8 w-full sm:w-40">
|
||||
<SelectValue>{riskActionFilterLabel(draftAction, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ACTION_ALL}>{t("noLimit")}</SelectItem>
|
||||
<SelectItem value="lock">{t("lock")}</SelectItem>
|
||||
<SelectItem value="release">{t("release")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-lock-logs-table-${drawId}`}
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAppliedNumber(draftNumber);
|
||||
setAppliedAction(draftAction);
|
||||
setAppliedGroupBy(draftGroupBy);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("applyFilter")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-lock-logs-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
{ticketGrouped ? (
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
<TableHead>{t("number")}</TableHead>
|
||||
<TableHead className="text-center">{t("combinationCount")}</TableHead>
|
||||
<TableHead className="text-center">{t("lockReleaseSummary")}</TableHead>
|
||||
<TableHead className="text-center">{t("amount")}</TableHead>
|
||||
<TableHead className="text-center">{t("viewDetail")}</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
<TableHead>{t("searchNumber")}</TableHead>
|
||||
<TableHead>{t("action")}</TableHead>
|
||||
<TableHead className="text-center">{t("amount")}</TableHead>
|
||||
<TableHead>{t("source")}</TableHead>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && !data ? (
|
||||
<AdminTableLoadingRow colSpan={ticketGrouped ? 8 : 7} />
|
||||
) : null}
|
||||
{ticketGrouped
|
||||
? data.items.map((row: AdminRiskLockLogTicketRow) => (
|
||||
<TableRow key={row.ticket_item_id}>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{row.last_at ? formatDt(row.last_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.ticket_no}</TableCell>
|
||||
<TableCell className="text-sm">{playCodeLabel(row.play_code)}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.original_number}</TableCell>
|
||||
<TableCell className="text-center text-sm tabular-nums">
|
||||
{row.combination_count}
|
||||
{row.number_count !== row.combination_count ? (
|
||||
<span className="text-muted-foreground"> / {row.number_count}</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{t("lock")} {row.lock_entry_count} · {t("release")} {row.release_entry_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
<div>{formatAdminMinorUnits(row.total_lock_amount, currencyCode)}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatAdminMinorUnits(row.total_release_amount, currencyCode)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Link
|
||||
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{t("viewTicket")}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: (data?.items as AdminRiskLockLogRow[] | undefined)?.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm font-medium">
|
||||
{row.normalized_number}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{riskActionTypeLabel(row.action_type, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm font-semibold">
|
||||
{formatAdminMinorUnits(row.amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{riskSourceReasonLabel(row.source_reason, t)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{row.ticket_no ? (
|
||||
<Link
|
||||
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.ticket_no}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{playCodeLabel(row.play_code)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId={`risk-logs-${drawId}-${appliedGroupBy}`}
|
||||
total={data.meta.total}
|
||||
page={data.meta.current_page}
|
||||
lastPage={data.meta.last_page}
|
||||
perPage={data.meta.per_page}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AdminSubnav,
|
||||
AdminSubnavBar,
|
||||
AdminSubnavLink,
|
||||
adminSubnavItemClassName,
|
||||
} from "@/components/admin/admin-subnav";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const segments = [
|
||||
{ suffix: "/occupancy", key: "occupancy", label: "subnavOccupancy" },
|
||||
{ suffix: "/pools", key: "pools", label: "subnavPools" },
|
||||
] as const;
|
||||
|
||||
function isPoolsTabActive(pathname: string, base: string): boolean {
|
||||
const poolsPrefix = `${base}/pools`;
|
||||
return (
|
||||
pathname === poolsPrefix
|
||||
|| pathname.startsWith(`${poolsPrefix}/`)
|
||||
|| pathname === `${base}/hot`
|
||||
|| pathname === `${base}/sold-out`
|
||||
);
|
||||
}
|
||||
|
||||
export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation("risk");
|
||||
const pathname = usePathname();
|
||||
const base = `/admin/draws/${drawId}/risk`;
|
||||
const base = `/admin/draws/${drawId}/risk/pools`;
|
||||
|
||||
return (
|
||||
<AdminSubnavBar className="mb-6">
|
||||
<AdminSubnav aria-label={t("subnavLabel", { defaultValue: "风控导航" })}>
|
||||
{segments.map(({ suffix, key, label }) => {
|
||||
const href = `${base}${suffix}`;
|
||||
const active =
|
||||
key === "pools" ? isPoolsTabActive(pathname, base) : pathname === href;
|
||||
|
||||
return (
|
||||
<AdminSubnavLink key={key} href={href} active={active}>
|
||||
{t(label)}
|
||||
</AdminSubnavLink>
|
||||
);
|
||||
})}
|
||||
</AdminSubnav>
|
||||
<Link href={base} className={cn(adminSubnavItemClassName(true))}>
|
||||
{t("subnavPools")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/draws"
|
||||
className={cn(adminSubnavItemClassName(false), "text-muted-foreground")}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
||||
import { ChevronDown, Eye } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
/** 与玩家端、注项表 status 字段对齐(不含无效的 success) */
|
||||
const TICKET_STATUS_OPTIONS = [
|
||||
@@ -98,6 +99,10 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
||||
return t("statusSelectedCount", { count: statuses.length });
|
||||
}
|
||||
|
||||
function ticketDetailPath(ticketNo: string): string {
|
||||
return `/admin/tickets/${encodeURIComponent(ticketNo)}`;
|
||||
}
|
||||
|
||||
function TicketFilterField({
|
||||
id,
|
||||
label,
|
||||
@@ -372,7 +377,14 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
: row.win_amount_formatted;
|
||||
return (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link
|
||||
href={ticketDetailPath(row.ticket_no)}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{row.ticket_no}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<AdminAgentIdentityCells row={row} />
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell className="text-xs">
|
||||
@@ -401,14 +413,8 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view-ticket",
|
||||
label: t("viewTicketDetail"),
|
||||
icon: Eye,
|
||||
href: `/admin/tickets/${encodeURIComponent(row.ticket_no)}`,
|
||||
},
|
||||
...(row.player_id
|
||||
actions={
|
||||
row.player_id
|
||||
? [
|
||||
{
|
||||
key: "view-player",
|
||||
@@ -417,8 +423,8 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
href: adminPlayerDetailPath(row.player_id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -48,6 +48,11 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd";
|
||||
import {
|
||||
canCompleteTransferInCredit,
|
||||
canManuallyProcessTransferOrder,
|
||||
canReverseTransferOrder,
|
||||
} from "@/lib/wallet-transfer-actions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
@@ -251,51 +256,6 @@ function walletAdminSelectDisplayedLabel(
|
||||
return key ? (t ? t(key) : key) : v;
|
||||
}
|
||||
|
||||
function canReverseTransferOrder(
|
||||
row: { status: string; can_reverse?: boolean },
|
||||
canWriteWallet: boolean,
|
||||
): boolean {
|
||||
return canWriteWallet && (row.can_reverse ?? row.status === "pending_reconcile");
|
||||
}
|
||||
|
||||
function canCompleteTransferInCredit(
|
||||
row: {
|
||||
direction: string;
|
||||
status: string;
|
||||
fail_reason?: string | null;
|
||||
external_ref_no?: string | null;
|
||||
can_complete_credit?: boolean;
|
||||
},
|
||||
canWriteWallet: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
canWriteWallet &&
|
||||
(row.can_complete_credit ??
|
||||
(row.direction === "in" &&
|
||||
row.status === "pending_reconcile" &&
|
||||
row.fail_reason === "lottery_credit_failed" &&
|
||||
Boolean(row.external_ref_no?.trim())))
|
||||
);
|
||||
}
|
||||
|
||||
function canManuallyProcessTransferOrder(
|
||||
row: {
|
||||
direction?: string;
|
||||
status: string;
|
||||
fail_reason?: string | null;
|
||||
can_manually_process?: boolean;
|
||||
},
|
||||
canWriteWallet: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
canWriteWallet &&
|
||||
(row.can_manually_process ??
|
||||
(["processing", "failed", "pending_reconcile"].includes(row.status) &&
|
||||
!(row.direction === "out" && row.status === "pending_reconcile") &&
|
||||
row.fail_reason !== "lottery_credit_failed"))
|
||||
);
|
||||
}
|
||||
|
||||
type TransferOrderRowActionsProps = {
|
||||
row: AdminTransferOrderItem;
|
||||
canWriteWallet: boolean;
|
||||
|
||||
Reference in New Issue
Block a user