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.
490 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|