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:
2026-06-16 13:50:58 +08:00
parent b774e22352
commit a4454a54a4
57 changed files with 981 additions and 1161 deletions

View File

@@ -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>
);

View File

@@ -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: "代理列表" })}>

View File

@@ -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);
}}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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",

View File

@@ -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>

View File

@@ -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)}

View 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>
);
}

View File

@@ -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}

View File

@@ -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`,
},
]}
/>

View File

@@ -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>
);
}

View File

@@ -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")}

View File

@@ -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>

View File

@@ -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;