Files
lotteryAdmin/src/modules/config/doc/rebate-config-doc-screen.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

692 lines
24 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
deleteOddsVersion,
getAdminPlayTypes,
getAllConfigVersions,
getOddsVersion,
getOddsVersions,
postOddsVersion,
publishOddsVersion,
putOddsItems,
} from "@/api/admin-config";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { updateAdminSetting } from "@/api/admin-settings";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import {
loadApplyRebateToPayoutSetting,
setCachedApplyRebateToPayoutSetting,
} from "@/lib/admin-settlement-settings-cache";
import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
import type { OddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace";
import { PRD_REBATE_MANAGE, PRD_WALLET_RECONCILE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminPlayTypeRow,
ConfigVersionSummary,
OddsItemRow,
OddsVersionDetail,
} from "@/types/api/admin-config";
import { ConfigWorkflowSection } from "@/modules/config/config-workflow-section";
import {
inferRebatePercentFromDimension,
rateToPercentUi,
} from "@/modules/config/doc/odds-rebate-rates";
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
function dimensionDistinctPrimaryScopePercents(
dim: 2 | 3 | 4,
rows: OddsItemRow[],
typeList: AdminPlayTypeRow[],
): Set<string> {
const codes = typeList
.filter((t) => (t.dimension ?? 2) === dim)
.map((t) => t.play_code)
.sort((a, b) => a.localeCompare(b));
const scope = PRIZE_SCOPE_ORDER[0];
const percents = new Set<string>();
for (const code of codes) {
const hit = rows.find((r) => r.play_code === code && r.prize_scope === scope);
if (hit) {
percents.add(rateToPercentUi(String(hit.rebate_rate)));
}
}
return percents;
}
type RebateConfigDocScreenProps = {
embedded?: boolean;
/** 合并页第 3 步卡片 */
mergedSection?: boolean;
workspace?: OddsConfigWorkspace;
versionId?: string;
onVersionIdChange?: (id: string) => void;
};
export function RebateConfigDocScreen({
embedded = false,
mergedSection = false,
workspace,
versionId: controlledVersionId,
onVersionIdChange,
}: RebateConfigDocScreenProps) {
const { t } = useTranslation(["config", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
const canEditWinEnjoy = adminHasAnyPermission(profile?.permissions, [
PRD_REBATE_MANAGE,
PRD_WALLET_RECONCILE_MANAGE,
]);
const [applyRebateToPayout, setApplyRebateToPayout] = useState(false);
const [winEnjoyLoading, setWinEnjoyLoading] = useState(true);
const [winEnjoySaving, setWinEnjoySaving] = useState(false);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
const [internalSelectedId, setInternalSelectedId] = useState("");
const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId;
const setSelectedId = workspace?.setSelectedId ?? onVersionIdChange ?? setInternalSelectedId;
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const resolvedTypes = workspace?.types ?? types;
const resolvedList = workspace?.list ?? listRows;
const resolvedDetail = workspace?.detail ?? detail;
const resolvedDraftRows = workspace?.draftRows ?? draftRows;
const setResolvedDraftRows = workspace?.setDraftRows ?? setDraftRows;
const resolvedLoading = workspace ? workspace.loadingList || workspace.loadingTypes : loading;
const resolvedLoadingDetail = workspace?.loadingDetail ?? loadingDetail;
const [p2, setP2] = useState("0");
const [p3, setP3] = useState("0");
const [p4, setP4] = useState("0");
const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const refreshTypes = useCallback(async () => {
try {
setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes));
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setTypes([]);
}
}, []);
const refreshList = useCallback(async () => {
try {
const d = await getAllConfigVersions(getOddsVersions);
setListRows(d.items);
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setListRows([]);
}
}, []);
useAsyncEffect(() => {
if (workspace) {
return;
}
void (async () => {
setLoading(true);
await Promise.all([refreshTypes(), refreshList()]);
setLoading(false);
})();
}, [workspace]);
useAsyncEffect(() => {
void (async () => {
setWinEnjoyLoading(true);
try {
setApplyRebateToPayout(await loadApplyRebateToPayoutSetting());
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
} finally {
setWinEnjoyLoading(false);
}
})();
}, []);
useEffect(() => {
if (!workspace) {
return;
}
setP2(inferRebatePercentFromDimension(2, workspace.draftRows, workspace.types));
setP3(inferRebatePercentFromDimension(3, workspace.draftRows, workspace.types));
setP4(inferRebatePercentFromDimension(4, workspace.draftRows, workspace.types));
}, [workspace?.draftRows, workspace?.types, workspace]);
async function handleWinEnjoyChange(checked: boolean): Promise<void> {
if (!canEditWinEnjoy) {
return;
}
setWinEnjoySaving(true);
try {
await updateAdminSetting(APPLY_REBATE_TO_PAYOUT_KEY, checked);
setCachedApplyRebateToPayoutSetting(checked);
setApplyRebateToPayout(checked);
toast.success(t("rebate.winEnjoy.saveSuccess", { ns: "config" }));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.winEnjoy.saveFailed", { ns: "config" }));
} finally {
setWinEnjoySaving(false);
}
}
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
const typeList = await ensureAdminPlayTypesLoaded(getAdminPlayTypes);
setTypes(typeList);
const d = await getOddsVersion(id);
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferRebatePercentFromDimension(2, rows, typeList));
setP3(inferRebatePercentFromDimension(3, rows, typeList));
setP4(inferRebatePercentFromDimension(4, rows, typeList));
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
useEffect(() => {
if (workspace) {
return;
}
if (listRows.length === 0) {
if (selectedId !== "") {
queueMicrotask(() => {
setSelectedId("");
setDetail(null);
setDraftRows([]);
});
}
return;
}
if (selectedId !== "" && listRows.some((x) => String(x.id) === selectedId)) {
return;
}
queueMicrotask(() => {
const pickId = pickDefaultConfigVersionId(listRows);
if (pickId) {
setSelectedId(pickId);
}
});
}, [listRows, selectedId, setSelectedId, workspace]);
useEffect(() => {
if (workspace) {
return;
}
if (selectedId === "") {
return;
}
const id = Number(selectedId);
if (!Number.isFinite(id)) {
return;
}
queueMicrotask(() => {
void loadDetail(id);
});
}, [selectedId, loadDetail, workspace]);
const typesByCode = useMemo(() => {
const m = new Map<string, AdminPlayTypeRow>();
for (const row of resolvedTypes) {
m.set(row.play_code, row);
}
return m;
}, [resolvedTypes]);
const rebateBulkPercentsMixed = useMemo(() => {
if (resolvedTypes.length === 0 || resolvedDraftRows.length === 0) {
return false;
}
for (const dim of [2, 3, 4] as const) {
if (dimensionDistinctPrimaryScopePercents(dim, resolvedDraftRows, resolvedTypes).size > 1) {
return true;
}
}
return false;
}, [resolvedTypes, resolvedDraftRows]);
const selectedVersionSummary = useMemo(
() => resolvedList.find((x) => String(x.id) === selectedId) ?? null,
[resolvedList, selectedId],
);
const isSelectedDetail = resolvedDetail !== null && String(resolvedDetail.id) === selectedId;
const selectedStatus = isSelectedDetail ? resolvedDetail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const canEditDraft = isDraft && canManage;
function applyDimensionPercentsToRows(rows: OddsItemRow[]): OddsItemRow[] {
const r2 = Number.parseFloat(p2);
const r3 = Number.parseFloat(p3);
const r4 = Number.parseFloat(p4);
const rate2 = Number.isFinite(r2) ? r2 / 100 : 0;
const rate3 = Number.isFinite(r3) ? r3 / 100 : 0;
const rate4 = Number.isFinite(r4) ? r4 / 100 : 0;
return rows.map((row) => {
const t = typesByCode.get(row.play_code);
const dim = (t?.dimension ?? 2) as 2 | 3 | 4;
const rate = dim === 4 ? rate4 : dim === 3 ? rate3 : rate2;
return { ...row, rebate_rate: String(rate) };
});
}
async function handleSave() {
if (!resolvedDetail || !canEditDraft) {
return;
}
setSaving(true);
try {
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
const payload = nextRows.map((r) => ({
play_code: r.play_code,
prize_scope: r.prize_scope,
odds_value: r.odds_value,
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
currency_code: r.currency_code,
extra_config_json: r.extra_config_json,
}));
const d = await putOddsItems(resolvedDetail.id, payload);
const rows = d.items.map((it) => ({ ...it }));
if (workspace) {
workspace.applyDetail(d);
} else {
setDetail(d);
setDraftRows(rows);
}
setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void (workspace?.refreshList() ?? refreshList());
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function handlePublish() {
if (!resolvedDetail || !canEditDraft) {
return;
}
setSaving(true);
try {
const d = await publishOddsVersion(resolvedDetail.id);
const rows = d.items.map((it) => ({ ...it }));
if (workspace) {
workspace.applyDetail(d);
} else {
setDetail(d);
setDraftRows(rows);
}
setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
toast.success(t("rebate.publishSuccess", { ns: "config" }));
void (workspace?.refreshList() ?? refreshList());
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.publishFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function handleNewDraft() {
setSaving(true);
try {
const active = resolvedList.find((x) => x.status === "active");
const d = await postOddsVersion({
reason: `rebate draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(t("rebate.createDraftSuccess", { ns: "config", version: d.version_no }));
await (workspace?.refreshList() ?? refreshList());
setSelectedId(String(d.id));
const rows = d.items.map((it) => ({ ...it }));
if (workspace) {
workspace.applyDetail(d);
} else {
setDetail(d);
setDraftRows(rows);
}
setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
const activeHead = resolvedList.find((x) => x.status === "active");
function requestRollback(row: ConfigVersionSummary) {
setRollbackTarget(row);
setRollbackOpen(true);
}
async function handleRollback() {
if (!rollbackTarget) {
return;
}
setSaving(true);
try {
const d = await postOddsVersion({
reason: `rollback from v${rollbackTarget.version_no}`,
clone_from_version_id: rollbackTarget.id,
});
toast.success(
t("versionActions.rollbackSuccess", {
ns: "config",
fromVersion: rollbackTarget.version_no,
version: d.version_no,
}),
);
await (workspace?.refreshList() ?? refreshList());
setSelectedId(String(d.id));
const rows = d.items.map((it) => ({ ...it }));
if (workspace) {
workspace.applyDetail(d);
} else {
setDetail(d);
setDraftRows(rows);
}
setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.rollbackFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await (workspace?.refreshList() ?? refreshList());
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.deleteFailed", { ns: "config" }));
throw e;
}
}
const toolbarBlock = embedded ? null : (
<ConfigDocToolbar
switcher={
<ConfigVersionSwitcher
versions={resolvedList}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={resolvedLoading}
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
/>
}
actions={
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={resolvedLoading}
loadingDetail={resolvedLoadingDetail}
saving={saving}
publishLabel={t("rebate.publishLabel", { ns: "config" })}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() =>
requestConfirm({
title: t("rebate.publishDialog.title", { ns: "config" }),
description: t("rebate.publishDialog.description", { ns: "config" }),
confirmLabel: t("rebate.publishDialog.confirm", { ns: "config" }),
confirmVariant: "destructive",
onConfirm: () => handlePublish(),
})
}
/>
}
footer={
embedded || !resolvedDetail ? null : (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
<span>
{t("rebate.editingVersion", {
ns: "config",
version: resolvedDetail.version_no,
status:
resolvedDetail.status === "draft"
? t("versionStatus.draft", { ns: "config" })
: resolvedDetail.status === "active"
? t("versionStatus.active", { ns: "config" })
: t("versionStatus.archived", { ns: "config" }),
})}
</span>
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("rebate.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
)}
</ConfigVersionToolbarMeta>
)
}
/>
);
const fieldsBlock = (
<>
{rebateBulkPercentsMixed ? (
<Alert className="border-amber-500/35 bg-amber-500/10 text-amber-950 dark:text-amber-100">
<AlertDescription>{t("rebate.dimensionRatesMixedHint", { ns: "config" })}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-5 sm:grid-cols-3">
<div className="grid gap-2">
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={saving}
value={p2}
onChange={(e) => setP2(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p2}</ConfigReadonlyValue>
)}
</div>
<div className="grid gap-2">
<Label>{t("rebate.fields.d3", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={saving}
value={p3}
onChange={(e) => setP3(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p3}</ConfigReadonlyValue>
)}
</div>
<div className="grid gap-2">
<Label>{t("rebate.fields.d4", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={saving}
value={p4}
onChange={(e) => setP4(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p4}</ConfigReadonlyValue>
)}
</div>
</div>
<div className="rounded-xl border border-border/60 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">{t("rebate.winEnjoy.label", { ns: "config" })}</p>
<p className="text-xs text-muted-foreground">{t("rebate.winEnjoy.description", { ns: "config" })}</p>
</div>
<Switch
checked={applyRebateToPayout}
disabled={winEnjoyLoading || winEnjoySaving || !canEditWinEnjoy}
aria-label={t("rebate.winEnjoy.label", { ns: "config" })}
onCheckedChange={(value) => void handleWinEnjoyChange(value)}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">{t("rebate.winEnjoy.hint", { ns: "config" })}</p>
</div>
{!embedded ? (
<div className="grid gap-1 text-sm">
<span className="text-muted-foreground">{t("rebate.effectiveTime", { ns: "config" })}</span>
<span className="font-mono text-sm">
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
</span>
</div>
) : null}
{resolvedLoading || resolvedLoadingDetail ? (
<AdminLoadingState minHeight="6rem" className="py-6" />
) : null}
</>
);
const rollbackDialog = (
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("versionActions.rollbackDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("versionActions.rollbackDialog.description", {
ns: "config",
version: rollbackTarget?.version_no ?? "—",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRollbackOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
{t("versionActions.rollbackDialog.confirm", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
if (embedded && mergedSection) {
return (
<>
<ConfigWorkflowSection
step={3}
title={t("nav.items.rebate", { ns: "config" })}
description={t("rebate.sectionHint", { ns: "config" })}
contentClassName="space-y-5"
>
{fieldsBlock}
</ConfigWorkflowSection>
{rollbackDialog}
<ConfirmDialog />
</>
);
}
if (embedded) {
return (
<div className="space-y-4">
{fieldsBlock}
{rollbackDialog}
<ConfirmDialog />
</div>
);
}
return (
<ConfigDocPage
title={t("nav.items.rebate", { ns: "config" })}
toolbar={toolbarBlock}
>
{fieldsBlock}
{rollbackDialog}
<ConfirmDialog />
</ConfigDocPage>
);
}