Files
lotteryAdmin/src/modules/settlement/settlement-batch-details-console.tsx
kang b15e377187 feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support
Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
2026-06-02 14:37:08 +08:00

490 lines
19 KiB
TypeScript

"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 { toast } from "sonner";
import {
downloadAdminSettlementBatchExport,
getAdminSettlementBatch,
getAdminSettlementBatchDetails,
postAdminApproveSettlementBatch,
postAdminPayoutSettlementBatch,
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
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 { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminSettlementBatchDetailsData,
AdminSettlementBatchShowData,
} from "@/types/api/admin-settlement";
type Props = {
batchId: number;
};
type SettlementAction = "approve" | "reject" | "payout";
function settlementStatusText(value: string, t: (key: string) => string): string {
const key = `statusOptions.${value}`;
const translated = t(key);
return translated === key ? value : translated;
}
function settlementReviewStatusText(value: string | null, t: (key: string) => string): string {
if (!value) return "—";
const key = `reviewStatusOptions.${value}`;
const translated = t(key);
return translated === key ? value : translated;
}
export function SettlementBatchDetailsConsole({ batchId }: Props) {
const { t } = useTranslation(["settlement", "common"]);
const tRef = useTranslationRef(["settlement", "common"]);
const profile = useAdminProfile();
useAdminCurrencyCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const canReviewSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_REVIEW]);
const canManagePayout = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [summary, setSummary] = useState<AdminSettlementBatchShowData | null>(null);
const [details, setDetails] = useState<AdminSettlementBatchDetailsData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [acting, setActing] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
const [reviewRemark, setReviewRemark] = useState("");
const batchCurrency = summary?.currency_code ?? "NPR";
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const [s, d] = await Promise.all([
getAdminSettlementBatch(batchId),
getAdminSettlementBatchDetails(batchId, {
page,
per_page: perPage,
agent_node_id: appliedAgentNodeId,
}),
]);
setSummary(s);
setDetails(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
setSummary(null);
setDetails(null);
} finally {
setLoading(false);
}
}, [batchId, page, perPage, appliedAgentNodeId]);
async function runAction(label: string, action: () => Promise<unknown>): Promise<void> {
setActing(label);
try {
await action();
toast.success(t("actionSuccess", { name: label }));
setPendingAction(null);
setReviewRemark("");
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: label }));
} finally {
setActing(null);
}
}
const actionLabel = (action: SettlementAction): string => {
if (action === "approve") {
return t("approve");
}
if (action === "reject") {
return t("reject");
}
return t("runPayout");
};
const confirmPendingAction = (): void => {
if (!summary || pendingAction === null) {
return;
}
const remark = reviewRemark.trim() || undefined;
if (pendingAction === "approve") {
void runAction(actionLabel(pendingAction), () => postAdminApproveSettlementBatch(batchId, remark));
return;
}
if (pendingAction === "reject") {
void runAction(actionLabel(pendingAction), () => postAdminRejectSettlementBatch(batchId, remark));
return;
}
void runAction(actionLabel(pendingAction), () => postAdminPayoutSettlementBatch(batchId));
};
const openActionDialog = (action: SettlementAction): void => {
setReviewRemark("");
setPendingAction(action);
};
async function exportCsv(): Promise<void> {
setActing(t("export"));
try {
const blob = await downloadAdminSettlementBatchExport(batchId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `settlement-${batchId}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("exportFailed"));
} finally {
setActing(null);
}
}
useAsyncEffect(() => {
void load();
}, [batchId, page, perPage, appliedAgentNodeId]);
return (
<ModuleScaffold>
<div className="mb-4">
<Link href="/admin/settlement-batches" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "px-0")}>
{t("backToList")}
</Link>
</div>
{err ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base">{t("errorTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-destructive">{err}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("retry")}
</Button>
</CardContent>
</Card>
) : null}
{summary ? (
<Card className="mb-6">
<CardHeader>
<CardTitle className="font-mono text-base">{t("batchSummary", { id: summary.id })}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("summaryMeta", {
drawNo: summary.draw_no ?? "—",
drawStatus: summary.draw_status ?? "—",
version: summary.result_batch_version ?? "—",
})}
</p>
</CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<p className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t("settlementStatus")}</span>
<AdminStatusBadge status={summary.status}>
{settlementStatusText(summary.status, t)}
</AdminStatusBadge>
</p>
<p className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t("reviewState")}</span>
{summary.review_status ? (
<AdminStatusBadge status={summary.review_status}>
{settlementReviewStatusText(summary.review_status, t)}
</AdminStatusBadge>
) : (
<span></span>
)}
</p>
<p>
<span className="text-muted-foreground">{t("ticketTotal")}</span>{" "}
<span className="tabular-nums">{summary.total_ticket_count}</span>
</p>
<p>
<span className="text-muted-foreground">{t("winTotal")}</span>{" "}
<span className="tabular-nums">{summary.total_win_count}</span>
</p>
<p>
<span className="text-muted-foreground">{t("totalBet")}</span>{" "}
<span className="font-mono tabular-nums">
{formatAdminMinorUnits(summary.total_bet_amount, summary.currency_code ?? "NPR")}
</span>
</p>
<p>
<span className="text-muted-foreground">{t("actualDeduct")}</span>{" "}
<span className="font-mono tabular-nums">
{formatAdminMinorUnits(summary.total_actual_deduct, summary.currency_code ?? "NPR")}
</span>
</p>
<p>
<span className="text-muted-foreground">{t("payoutAmount")}</span>{" "}
<span className="font-mono tabular-nums">
{formatAdminMinorUnits(summary.total_payout_amount, summary.currency_code ?? "NPR")}
</span>
</p>
<p>
<span className="text-muted-foreground">{t("jackpotPayout")}</span>{" "}
<span className="font-mono tabular-nums">
{formatAdminMinorUnits(summary.total_jackpot_payout_amount, summary.currency_code ?? "NPR")}
</span>
</p>
<p>
<span className="text-muted-foreground">{t("platformProfit")}</span>{" "}
<span
className={cn(
"font-mono tabular-nums",
summary.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
)}
>
{formatAdminMinorUnits(summary.platform_profit, summary.currency_code ?? "NPR")}
</span>
</p>
<p>
<span className="text-muted-foreground">{t("startedAt")}</span> {formatDt(summary.started_at)}
</p>
<p>
<span className="text-muted-foreground">{t("endedAt")}</span> {formatDt(summary.finished_at)}
</p>
<div className="flex flex-wrap gap-2 sm:col-span-2">
{canReviewSettlement ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={acting !== null || summary.status !== "pending_review"}
onClick={() => openActionDialog("approve")}
>
{t("approve")}
</Button>
) : null}
{canReviewSettlement ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={acting !== null || summary.status !== "pending_review"}
onClick={() => openActionDialog("reject")}
>
{t("reject")}
</Button>
) : null}
{canManagePayout ? (
<Button
type="button"
size="sm"
disabled={
acting !== null
|| summary.status !== "approved"
|| summary.review_status !== "approved"
}
onClick={() => openActionDialog("payout")}
>
{t("runPayout")}
</Button>
) : null}
<Button type="button" size="sm" variant="secondary" disabled={acting !== null} onClick={() => void exportCsv()}>
{t("exportSettlementReport")}
</Button>
</div>
</CardContent>
</Card>
) : loading ? (
<AdminLoadingState minHeight="6rem" className="py-4" label={t("loadingSummary")} />
) : null}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("detailTitle")}</CardTitle>
</CardHeader>
<CardContent>
{details ? (
<>
<div className="mb-4 flex flex-wrap items-end gap-3">
<AdminAgentFilter
id="settlement-details-agent-filter"
className="w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<Button
type="button"
size="sm"
onClick={() => {
setAppliedAgentNodeId(agentNodeId);
setPage(1);
}}
>
{t("search", { ns: "common", defaultValue: "Search" })}
</Button>
</div>
<Table id={`settlement-details-table-${batchId}`}>
<TableHeader>
<TableRow>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead>{t("matchedTier")}</TableHead>
<TableHead className="text-center">{t("regularPayout")}</TableHead>
<TableHead className="text-center">{t("jackpot")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{details.items.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(r.play_code)}</TableCell>
<AdminAgentIdentityCells row={r} />
<AdminPlayerIdentityCells row={r} />
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
<TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)}
</TableCell>
<TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(
r.jackpot_allocation_amount,
r.currency_code ?? batchCurrency,
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<AdminListPaginationFooter
selectId="settlement-details-per-page"
total={details.meta.total}
page={details.meta.current_page}
lastPage={details.meta.last_page}
perPage={details.meta.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
</>
) : (
<p className="text-muted-foreground text-sm">
{loading ? (
<AdminLoadingInline label={t("loadingDetails")} />
) : (
t("states.noData", { ns: "common" })
)}
</p>
)}
</CardContent>
</Card>
<Dialog
open={pendingAction !== null}
onOpenChange={(open) => {
if (!open && acting === null) {
setPendingAction(null);
setReviewRemark("");
}
}}
>
<DialogContent>
{summary && pendingAction ? (
<>
<DialogHeader>
<DialogTitle>{t("confirmAction", { name: actionLabel(pendingAction) })}</DialogTitle>
<DialogDescription>
{pendingAction === "payout"
? t("confirmPayoutDesc")
: t("confirmActionDesc", { drawNo: summary.draw_no ?? "—" })}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<p className="rounded-md border bg-muted/30 p-3 text-sm">
{t("confirmAmountLine", {
actual: formatAdminMinorUnits(summary.total_actual_deduct, summary.currency_code ?? "NPR"),
payout: formatAdminMinorUnits(summary.total_payout_amount, summary.currency_code ?? "NPR"),
profit: formatAdminMinorUnits(summary.platform_profit, summary.currency_code ?? "NPR"),
})}
</p>
{pendingAction !== "payout" ? (
<div className="space-y-2">
<Label htmlFor="settlement-detail-review-remark">{t("reviewRemark")}</Label>
<Textarea
id="settlement-detail-review-remark"
value={reviewRemark}
placeholder={t("reviewRemarkPlaceholder")}
onChange={(event) => setReviewRemark(event.target.value)}
/>
</div>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={acting !== null}
onClick={() => {
setPendingAction(null);
setReviewRemark("");
}}
>
{t("cancel")}
</Button>
<Button
type="button"
variant={pendingAction === "reject" ? "destructive" : "default"}
disabled={acting !== null}
onClick={confirmPendingAction}
>
{t("confirm")}
</Button>
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
</ModuleScaffold>
);
}