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

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