Files
lotteryAdmin/src/modules/config/doc/odds-config-doc-screen.tsx
kang 05fa0cbeec feat(i18n): add batch group switch text to English, Nepali, and Chinese locales
- Updated the English, Nepali, and Chinese locale files to include a new translation for "Toggle batch switch for {{group}}".
- Enhanced internationalization support for the admin interface by adding relevant strings for improved user experience.
2026-05-26 10:33:03 +08:00

674 lines
23 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 { Button } from "@/components/ui/button";
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_ODDS_MANAGE, PRD_REBATE_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 {
PRIZE_SCOPE_MULTIPLIER_HINT,
PRIZE_SCOPE_ORDER,
prizeScopeLabel,
type PrizeScopeCode,
} from "@/modules/config/doc/prize-scopes";
type CatTab = "all" | "d4" | "d3" | "d2";
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
}
function parseOddsMultiplierInput(raw: string): number {
const n = Number.parseFloat(raw);
if (!Number.isFinite(n) || n < 0) {
return 0;
}
const scaled = Math.round(n * 10000);
return Number.isSafeInteger(scaled) ? scaled : 0;
}
function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[] {
if (tab === "all") {
return types;
}
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
return types.filter((t) => t.dimension === dim);
}
type OddsConfigDocScreenProps = {
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
embedded?: boolean;
};
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
const [loadingTypes, setLoadingTypes] = useState(true);
const [loadingList, setLoadingList] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [catTab, setCatTab] = useState<CatTab>("all");
/** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */
const [playCode, setPlayCode] = useState<string>("");
const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [activeCompareRows, setActiveCompareRows] = useState<OddsItemRow[]>([]);
const refreshTypes = useCallback(async () => {
setLoadingTypes(true);
try {
const d = await getAdminPlayTypes();
setTypes(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setTypes([]);
} finally {
setLoadingTypes(false);
}
}, [t]);
const refreshList = useCallback(async () => {
setLoadingList(true);
setError(null);
try {
const d = await getAllConfigVersions(getOddsVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, [t]);
useEffect(() => {
queueMicrotask(() => {
void refreshTypes();
void refreshList();
});
}, [refreshTypes, refreshList]);
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
const d = await getOddsVersion(id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, [t]);
useEffect(() => {
if (list.length === 0 || selectedId !== "") {
return;
}
queueMicrotask(() => {
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
const active = list.find((x) => x.status === "active");
const pick = drafts[0] ?? active ?? [...list].sort((a, b) => b.id - a.id)[0];
if (pick) {
setSelectedId(String(pick.id));
}
});
}, [list, selectedId]);
useEffect(() => {
if (selectedId === "") {
return;
}
const id = Number(selectedId);
if (!Number.isFinite(id)) {
return;
}
queueMicrotask(() => {
void loadDetail(id);
});
}, [selectedId, loadDetail]);
const sortedTypes = useMemo(
() => [...types].sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)),
[types],
);
const filteredTypes = useMemo(() => filterTypes(catTab, sortedTypes), [catTab, sortedTypes]);
const resolvedPlayCode = useMemo(() => {
if (filteredTypes.length === 0) {
return "";
}
if (playCode && filteredTypes.some((t) => t.play_code === playCode)) {
return playCode;
}
return filteredTypes[0].play_code;
}, [filteredTypes, playCode]);
const selectedVersionSummary = useMemo(
() => list.find((x) => String(x.id) === selectedId) ?? null,
[list, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const canEditDraft = isDraft && canManage;
const scopeRows = useMemo(() => {
const rows: Partial<Record<PrizeScopeCode, OddsItemRow>> = {};
if (!resolvedPlayCode) {
return rows;
}
for (const scope of PRIZE_SCOPE_ORDER) {
const hit = draftRows.find((r) => r.play_code === resolvedPlayCode && r.prize_scope === scope);
if (hit) {
rows[scope] = hit;
}
}
return rows;
}, [draftRows, resolvedPlayCode]);
const rebatePercentUi = useMemo(() => {
const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean);
if (!first) {
return "0";
}
const n = Number.parseFloat(String(first.rebate_rate));
if (!Number.isFinite(n)) {
return "0";
}
return String(Math.round(n * 10000) / 100);
}, [scopeRows]);
function rowIndex(play_code: string, prize_scope: string): number {
return draftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope);
}
function updateOddsRow(idx: number, patch: Partial<OddsItemRow>) {
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
function updateOddsForScope(scope: PrizeScopeCode, patch: Partial<OddsItemRow>) {
const idx = rowIndex(resolvedPlayCode, scope);
if (idx >= 0) {
updateOddsRow(idx, patch);
}
}
function setRebateForPlayPercent(percentStr: string) {
const p = Number.parseFloat(percentStr);
const rate = Number.isFinite(p) ? p / 100 : 0;
setDraftRows((prev) =>
prev.map((r) =>
r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r,
),
);
}
async function handleSave() {
if (!detail || !canEditDraft) {
return;
}
setSaving(true);
try {
const payload = draftRows.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(detail.id, payload);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function handlePublish() {
if (!detail || !canEditDraft) {
return;
}
setSaving(true);
try {
const d = await publishOddsVersion(detail.id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.publishFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function requestPublishConfirm() {
if (!detail || !canEditDraft) {
return;
}
const active = list.find((x) => x.status === "active");
if (active && active.id !== detail.id) {
try {
const d = await getOddsVersion(active.id);
setActiveCompareRows(d.items);
} catch {
setActiveCompareRows([]);
}
} else {
setActiveCompareRows([]);
}
setPublishConfirmOpen(true);
}
async function handleNewDraft() {
setSaving(true);
try {
const active = list.find((x) => x.status === "active");
const d = await postOddsVersion({
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(t("odds.createDraftSuccess", { ns: "config", version: d.version_no }));
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.createDraftFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
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("odds.rollbackSuccess", {
ns: "config",
fromVersion: rollbackTarget.version_no,
version: d.version_no,
}),
);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.rollbackFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
const activeHead = list.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.deleteFailed", { ns: "config" }));
throw e;
}
}
function requestRollback(row: ConfigVersionSummary) {
setRollbackTarget(row);
setRollbackOpen(true);
}
const publishDiffRows = useMemo(() => {
if (!detail) {
return [];
}
const selectedPlay = resolvedPlayCode;
return PRIZE_SCOPE_ORDER.map((scope) => {
const next = draftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
return {
scope,
label: prizeScopeLabel(scope, t),
oldValue: old?.odds_value ?? null,
newValue: next?.odds_value ?? null,
};
});
}, [activeCompareRows, detail, draftRows, resolvedPlayCode, t, i18n.language]);
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
{ id: "d4", label: "4D" },
{ id: "d3", label: "3D" },
{ id: "d2", label: "2D" },
];
const filtersBlock = (
<div className="space-y-4 rounded-xl border border-border/60 bg-card p-4">
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
{catTabs.map((tab) => (
<ConfigChip
key={tab.id}
active={catTab === tab.id}
onClick={() => setCatTab(tab.id)}
>
{tab.label}
</ConfigChip>
))}
</ConfigChipGroup>
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
{filteredTypes.length === 0 ? (
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
) : (
filteredTypes.map((type) => (
<ConfigChip
key={type.play_code}
active={resolvedPlayCode === type.play_code}
onClick={() => setPlayCode(type.play_code)}
>
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
</ConfigChip>
))
)}
</ConfigChipGroup>
</div>
);
const toolbarBlock = (
<ConfigDocToolbar
switcher={
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={embedded ? undefined : t("odds.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
/>
}
actions={
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void requestPublishConfirm()}
/>
}
/>
);
const contextBlock =
embedded || !detail ? null : (
<ConfigContextBanner emphasis={!isDraft}>
{t("odds.activeVersionPrefix", { ns: "config" })}
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
{!isDraft ? (
<>
{" "}
<ConfigContextEmphasis>{t("odds.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
);
const mainBlock = (
<>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? (
<div className="flex min-h-[420px] items-center">
<p className="text-base text-muted-foreground">{t("odds.loadingDetails", { ns: "config" })}</p>
</div>
) : resolvedPlayCode ? (
<div className="grid min-h-[420px] gap-4 max-w-md">
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return (
<div key={scope} className="grid gap-1">
<Label className="flex items-baseline gap-2">
{prizeScopeLabel(scope, t)}
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
<div className="flex flex-wrap items-center gap-2">
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 max-w-[200px] font-mono tabular-nums"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
onChange={(e) =>
updateOddsForScope(scope, {
odds_value: parseOddsMultiplierInput(e.target.value),
})
}
/>
) : (
<ConfigReadonlyValue mono className="max-w-[200px]">
{oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue>
)}
{!embedded ? (
<span className="text-sm text-muted-foreground tabular-nums">
{t("odds.multiplier", {
ns: "config",
value: oddsMultiplierLabel(row.odds_value),
currency: row.currency_code,
})}
</span>
) : null}
</div>
) : (
<p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
)}
</div>
);
})}
<div className="grid gap-1 pt-2 border-t">
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 max-w-[200px] font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="max-w-[200px]">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
{!embedded ? (
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</div>
</div>
) : null}
</>
);
const dialogs = (
<>
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("odds.rollbackDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("odds.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("odds.rollbackDialog.confirm", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("odds.publishDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("odds.publishDialog.description", { ns: "config" })}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border">
<div className="grid grid-cols-3 border-b bg-muted/40 px-3 py-2 text-sm font-medium">
<span>{t("odds.publishDialog.columns.prizeScope", { ns: "config" })}</span>
<span className="text-right">{t("odds.publishDialog.columns.currentActive", { ns: "config" })}</span>
<span className="text-right">{t("odds.publishDialog.columns.afterPublish", { ns: "config" })}</span>
</div>
{publishDiffRows.map((row) => (
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
<span>{row.label}</span>
<span className="text-right font-mono tabular-nums">
{row.oldValue === null ? "—" : oddsMultiplierLabel(row.oldValue)}
</span>
<span className="text-right font-mono tabular-nums">
{row.newValue === null ? "—" : oddsMultiplierLabel(row.newValue)}
</span>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPublishConfirmOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button
type="button"
disabled={saving}
onClick={() => {
setPublishConfirmOpen(false);
void handlePublish();
}}
>
{t("odds.publishDialog.confirm", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
if (embedded) {
return (
<div className="space-y-6">
{filtersBlock}
{toolbarBlock}
{contextBlock}
{mainBlock}
{dialogs}
</div>
);
}
return (
<ConfigDocPage
title={t("nav.items.odds", { ns: "config" })}
filters={filtersBlock}
toolbar={toolbarBlock}
context={contextBlock}
>
{mainBlock}
{dialogs}
</ConfigDocPage>
);
}