- Updated global CSS to center-align table headers and cells, ensuring a consistent layout. - Modified admin table components to replace switches with status badges for better clarity. - Enhanced internationalization support by adding new strings for version actions and validation messages in multiple locales. - Refactored configuration document screens to include version selection and improved user feedback on status changes.
436 lines
17 KiB
TypeScript
436 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
getAdminSettlementBatches,
|
|
postAdminApproveSettlementBatch,
|
|
postAdminPayoutSettlementBatch,
|
|
postAdminRejectSettlementBatch,
|
|
} from "@/api/admin-settlement";
|
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
|
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 { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
|
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 { AdminSettlementBatchListData, AdminSettlementBatchRow } from "@/types/api/admin-settlement";
|
|
|
|
const STATUS_ALL = "__all__";
|
|
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
|
{ value: STATUS_ALL, label: "statusOptions.all" },
|
|
{ value: "running", label: "statusOptions.running" },
|
|
{ value: "pending_review", label: "statusOptions.pending_review" },
|
|
{ value: "approved", label: "statusOptions.approved" },
|
|
{ value: "rejected", label: "statusOptions.rejected" },
|
|
{ value: "paid", label: "statusOptions.paid" },
|
|
{ value: "completed", label: "statusOptions.completed" },
|
|
{ value: "failed", label: "statusOptions.failed" },
|
|
];
|
|
|
|
type SettlementAction = "approve" | "reject" | "payout";
|
|
|
|
type PendingAction = {
|
|
row: AdminSettlementBatchRow;
|
|
action: SettlementAction;
|
|
};
|
|
|
|
function settlementStatusText(value: string, t: (key: string) => string): string {
|
|
const option = STATUS_OPTIONS.find((item) => item.value === value);
|
|
return option ? t(option.label) : value;
|
|
}
|
|
|
|
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 SettlementBatchesConsole() {
|
|
const { t } = useTranslation(["settlement", "common"]);
|
|
const exportLabels = useExportLabels("settlementBatches");
|
|
const profile = useAdminProfile();
|
|
useAdminCurrencyCatalog();
|
|
const canReviewSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_REVIEW]);
|
|
const canManagePayout = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
|
const [data, setData] = useState<AdminSettlementBatchListData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [draftDrawNo, setDraftDrawNo] = useState("");
|
|
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
|
const [draftStatus, setDraftStatus] = useState(STATUS_ALL);
|
|
const [appliedStatus, setAppliedStatus] = useState(STATUS_ALL);
|
|
const [page, setPage] = useState(1);
|
|
const [perPage, setPerPage] = useState(10);
|
|
const [actingId, setActingId] = useState<number | null>(null);
|
|
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
|
const [reviewRemark, setReviewRemark] = useState("");
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const d = await getAdminSettlementBatches({
|
|
page,
|
|
per_page: perPage,
|
|
draw_no: appliedDrawNo.trim() || undefined,
|
|
status:
|
|
appliedStatus === STATUS_ALL || appliedStatus.trim() === ""
|
|
? undefined
|
|
: appliedStatus.trim(),
|
|
});
|
|
setData(d);
|
|
} catch (e) {
|
|
setError(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
|
setData(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
|
|
|
|
useEffect(() => {
|
|
const t = window.setTimeout(() => void load(), 0);
|
|
return () => window.clearTimeout(t);
|
|
}, [load]);
|
|
|
|
const applyFilters = () => {
|
|
setAppliedDrawNo(draftDrawNo);
|
|
setAppliedStatus(draftStatus);
|
|
setPage(1);
|
|
};
|
|
|
|
async function runBatchAction(batchId: number, label: string, action: () => Promise<unknown>): Promise<void> {
|
|
setActingId(batchId);
|
|
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 {
|
|
setActingId(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 (!pendingAction) {
|
|
return;
|
|
}
|
|
const { row, action } = pendingAction;
|
|
const remark = reviewRemark.trim() || undefined;
|
|
if (action === "approve") {
|
|
void runBatchAction(row.id, actionLabel(action), () => postAdminApproveSettlementBatch(row.id, remark));
|
|
return;
|
|
}
|
|
if (action === "reject") {
|
|
void runBatchAction(row.id, actionLabel(action), () => postAdminRejectSettlementBatch(row.id, remark));
|
|
return;
|
|
}
|
|
void runBatchAction(row.id, actionLabel(action), () => postAdminPayoutSettlementBatch(row.id));
|
|
};
|
|
|
|
const openActionDialog = (row: AdminSettlementBatchRow, action: SettlementAction): void => {
|
|
setReviewRemark("");
|
|
setPendingAction({ row, action });
|
|
};
|
|
|
|
return (
|
|
<ModuleScaffold>
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header space-y-6 pb-6">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
|
|
</div>
|
|
<div className="admin-list-toolbar">
|
|
<div className="admin-list-field">
|
|
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
|
|
{t("drawNo")}
|
|
</Label>
|
|
<Input
|
|
id="sb-draw-no"
|
|
value={draftDrawNo}
|
|
onChange={(e) => setDraftDrawNo(e.target.value)}
|
|
placeholder={t("placeholderDrawNo")}
|
|
className="w-full font-mono sm:w-[18rem] xl:w-[20rem]"
|
|
/>
|
|
</div>
|
|
<div className="admin-list-field">
|
|
<Label className="sm:w-10 sm:shrink-0">{t("status")}</Label>
|
|
<Select value={draftStatus} onValueChange={(v) => setDraftStatus(v ?? STATUS_ALL)}>
|
|
<SelectTrigger className="w-full sm:w-40">
|
|
<SelectValue>{settlementStatusText(draftStatus, t)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STATUS_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={o.value}>
|
|
{t(o.label)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="admin-list-actions">
|
|
<AdminTableExportButton
|
|
tableId="settlement-batches-table"
|
|
filename={exportLabels.filename}
|
|
sheetName={exportLabels.sheetName}
|
|
/>
|
|
<Button type="button" className="xl:shrink-0" onClick={applyFilters}>
|
|
{t("apply")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content pt-0">
|
|
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
|
{loading && !data ? (
|
|
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
|
) : (
|
|
<div className="admin-table-shell">
|
|
<Table id="settlement-batches-table">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
|
|
<TableHead>{t("drawNo")}</TableHead>
|
|
<TableHead className="text-center">{t("totalBet")}</TableHead>
|
|
<TableHead className="text-center">{t("actualDeduct")}</TableHead>
|
|
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
|
<TableHead className="text-center">{t("platformProfit")}</TableHead>
|
|
<TableHead>{t("reviewStatus")}</TableHead>
|
|
<TableHead>{t("status")}</TableHead>
|
|
<TableHead />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
|
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
|
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
|
{formatAdminMinorUnits(row.total_bet_amount, row.currency_code ?? "NPR")}
|
|
</TableCell>
|
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
|
{formatAdminMinorUnits(row.total_actual_deduct, row.currency_code ?? "NPR")}
|
|
</TableCell>
|
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
|
{formatAdminMinorUnits(row.total_payout_amount, row.currency_code ?? "NPR")}
|
|
</TableCell>
|
|
<TableCell
|
|
className={cn(
|
|
"text-center font-mono text-xs tabular-nums",
|
|
row.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
|
|
)}
|
|
>
|
|
{formatAdminMinorUnits(row.platform_profit, row.currency_code ?? "NPR")}
|
|
</TableCell>
|
|
<TableCell>
|
|
{row.review_status ? (
|
|
<AdminStatusBadge status={row.review_status}>
|
|
{settlementReviewStatusText(row.review_status, t)}
|
|
</AdminStatusBadge>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge status={row.status}>
|
|
{settlementStatusText(row.status, t)}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap justify-center gap-1.5">
|
|
<Link
|
|
href={`/admin/settlement-batches/${row.id}/details`}
|
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "!border-border")}
|
|
>
|
|
{t("details")}
|
|
</Link>
|
|
{canReviewSettlement ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={actingId !== null || row.status !== "pending_review"}
|
|
onClick={() => openActionDialog(row, "approve")}
|
|
>
|
|
{t("pass")}
|
|
</Button>
|
|
) : null}
|
|
{canReviewSettlement ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={actingId !== null || row.status !== "pending_review"}
|
|
onClick={() => openActionDialog(row, "reject")}
|
|
>
|
|
{t("reject")}
|
|
</Button>
|
|
) : null}
|
|
{canManagePayout ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={
|
|
actingId !== null
|
|
|| row.status !== "approved"
|
|
|| row.review_status !== "approved"
|
|
}
|
|
onClick={() => openActionDialog(row, "payout")}
|
|
>
|
|
{t("payout")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
{data ? (
|
|
<AdminListPaginationFooter
|
|
selectId="settlement-batches-per-page"
|
|
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>
|
|
<Dialog
|
|
open={pendingAction !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open && actingId === null) {
|
|
setPendingAction(null);
|
|
setReviewRemark("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
{pendingAction ? (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("confirmAction", { name: actionLabel(pendingAction.action) })}</DialogTitle>
|
|
<DialogDescription>
|
|
{pendingAction.action === "payout"
|
|
? t("confirmPayoutDesc")
|
|
: t("confirmActionDesc", { drawNo: pendingAction.row.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(
|
|
pendingAction.row.total_actual_deduct,
|
|
pendingAction.row.currency_code ?? "NPR",
|
|
),
|
|
payout: formatAdminMinorUnits(
|
|
pendingAction.row.total_payout_amount,
|
|
pendingAction.row.currency_code ?? "NPR",
|
|
),
|
|
profit: formatAdminMinorUnits(
|
|
pendingAction.row.platform_profit,
|
|
pendingAction.row.currency_code ?? "NPR",
|
|
),
|
|
})}
|
|
</p>
|
|
{pendingAction.action !== "payout" ? (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="settlement-review-remark">{t("reviewRemark")}</Label>
|
|
<Textarea
|
|
id="settlement-review-remark"
|
|
value={reviewRemark}
|
|
placeholder={t("reviewRemarkPlaceholder")}
|
|
onChange={(event) => setReviewRemark(event.target.value)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={actingId !== null}
|
|
onClick={() => {
|
|
setPendingAction(null);
|
|
setReviewRemark("");
|
|
}}
|
|
>
|
|
{t("cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={pendingAction.action === "reject" ? "destructive" : "default"}
|
|
disabled={actingId !== null}
|
|
onClick={confirmPendingAction}
|
|
>
|
|
{t("confirm")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</ModuleScaffold>
|
|
);
|
|
}
|