feat(admin, i18n): enhance reports, draws, config, and player workflows

This commit is contained in:
2026-06-08 17:41:55 +08:00
parent af982bb9f7
commit 7e65c53732
55 changed files with 1986 additions and 804 deletions

View File

@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { CalendarRange, Eye, ShieldAlert, UserRound } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -18,7 +19,7 @@ import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admi
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -71,6 +72,18 @@ function itemStatusLabel(status: string, t: (key: string) => string): string {
return t("itemMatched");
case "pending_check":
return t("itemPendingCheck");
case "stale_processing":
return t("itemStaleProcessing");
case "pending_reconcile":
return t("itemPendingReconcile");
case "missing_wallet_txn":
return t("itemMissingWalletTxn");
case "unexpected_wallet_txn":
return t("itemUnexpectedWalletTxn");
case "missing_refund":
return t("itemMissingRefund");
case "missing_reversal":
return t("itemMissingReversal");
default:
return status;
}
@@ -85,6 +98,82 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
}
}
function itemResolutionLabel(
row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">,
t: (key: string) => string,
): string {
return row.is_resolved === true || row.resolved_at ? t("itemResolved") : t("itemUnresolved");
}
function itemResolutionTone(row: Pick<AdminReconcileItemsData["items"][number], "resolved_at" | "is_resolved">): "success" | "warning" {
return row.is_resolved === true || row.resolved_at ? "success" : "warning";
}
function 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;
@@ -507,8 +596,8 @@ export function ReconcileConsole(): React.ReactElement {
<AdminRowActionsMenu
actions={[
{
key: "view",
label: t("view"),
key: "view-details",
label: t("viewDetails"),
icon: Eye,
onClick: () => {
setSelectedId(row.id);
@@ -609,16 +698,20 @@ export function ReconcileConsole(): React.ReactElement {
<TableHeader>
<TableRow>
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("sideARef")}</TableHead>
<TableHead>{t("sideBRef")}</TableHead>
<TableHead className="text-right">{t("differenceAmount")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("detectedAt")}</TableHead>
<TableHead className="min-w-[10rem]">{t("sideARef")}</TableHead>
<TableHead className="min-w-[10rem]">{t("sideBRef")}</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="w-36">{t("detectedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={6} />
<AdminTableNoResourceRow colSpan={10} />
) : (
items.items.map((r) => (
<TableRow
@@ -628,10 +721,10 @@ export function ReconcileConsole(): React.ReactElement {
r.status === "matched" && "bg-emerald-500/5",
)}
>
<TableCell>{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums">
<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 text-right tabular-nums">
<span
className={cn(
r.difference_amount !== 0 ? "font-medium text-amber-700" : "text-muted-foreground",
@@ -640,12 +733,46 @@ export function ReconcileConsole(): React.ReactElement {
{r.difference_amount}
</span>
</TableCell>
<TableCell>
<TableCell className="align-top">
<AdminStatusBadge status={r.status}>
{itemStatusLabel(r.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
<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>
<TableCell className="align-top whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(r.created_at)}
</TableCell>
</TableRow>