- Updated English, Nepali, and Chinese locale files to include new translations for the "apply rebate to payout" feature, enhancing clarity on its functionality. - Added new export options for previewing CSV and Excel files in reports, improving user experience with clearer export capabilities. - Enhanced internationalization support across multiple locales to ensure consistent messaging in the admin interface.
703 lines
24 KiB
TypeScript
703 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 { Button } from "@/components/ui/button";
|
|
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
|
import {
|
|
ConfigVersionToolbarMeta,
|
|
ConfigVersionToolbarMetaEmphasis,
|
|
} from "@/modules/config/config-version-toolbar-meta";
|
|
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 { cn } from "@/lib/utils";
|
|
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
|
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;
|
|
/** 与回水分区共用版本选择(合并页) */
|
|
versionId?: string;
|
|
onVersionIdChange?: (id: string) => void;
|
|
};
|
|
|
|
export function OddsConfigDocScreen({
|
|
embedded = false,
|
|
versionId: controlledVersionId,
|
|
onVersionIdChange,
|
|
}: 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 [internalSelectedId, setInternalSelectedId] = useState("");
|
|
const selectedId = controlledVersionId ?? internalSelectedId;
|
|
const setSelectedId = onVersionIdChange ?? setInternalSelectedId;
|
|
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) {
|
|
if (selectedId !== "") {
|
|
queueMicrotask(() => {
|
|
setSelectedId("");
|
|
setDetail(null);
|
|
setDraftRows([]);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (selectedId !== "" && list.some((x) => String(x.id) === selectedId)) {
|
|
return;
|
|
}
|
|
queueMicrotask(() => {
|
|
const pickId = pickDefaultConfigVersionId(list);
|
|
if (pickId) {
|
|
setSelectedId(pickId);
|
|
}
|
|
});
|
|
}, [list, selectedId, setSelectedId]);
|
|
|
|
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("versionActions.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("versionActions.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("versionActions.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={cn("space-y-3", embedded ? "border-t border-border/50 px-3 py-3 sm:px-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>
|
|
) : (
|
|
<div className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
|
{filteredTypes.map((type) => (
|
|
<ConfigChip
|
|
key={type.play_code}
|
|
active={resolvedPlayCode === type.play_code}
|
|
onClick={() => setPlayCode(type.play_code)}
|
|
className="shrink-0"
|
|
>
|
|
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
|
|
</ConfigChip>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ConfigChipGroup>
|
|
</div>
|
|
);
|
|
|
|
const toolbarBlock = (
|
|
<ConfigDocToolbar
|
|
className={embedded ? "rounded-none border-0 shadow-none" : undefined}
|
|
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()}
|
|
/>
|
|
}
|
|
footer={
|
|
!detail ? null : (
|
|
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
|
<span>
|
|
{t("odds.activeVersionPrefix", { ns: "config" })}
|
|
{activeHead ? (
|
|
<>
|
|
v{activeHead.version_no}
|
|
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
|
</>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</span>
|
|
{!isDraft ? (
|
|
<ConfigVersionToolbarMetaEmphasis>
|
|
{t("odds.readOnlyHint", { ns: "config" })}
|
|
</ConfigVersionToolbarMetaEmphasis>
|
|
) : activeHead ? (
|
|
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
|
) : null}
|
|
</ConfigVersionToolbarMeta>
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
|
|
const mainBlock = (
|
|
<>
|
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
|
|
{loadingDetail || loadingTypes ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
{t("odds.loadingDetails", { ns: "config" })}
|
|
</p>
|
|
) : resolvedPlayCode ? (
|
|
<div
|
|
className={cn(
|
|
embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined,
|
|
)}
|
|
>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
|
|
{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 min-w-0 gap-1.5">
|
|
<Label className="truncate text-xs font-medium text-muted-foreground">
|
|
{prizeScopeLabel(scope, t)}
|
|
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
|
|
</Label>
|
|
{row && idx >= 0 ? (
|
|
canEditDraft ? (
|
|
<Input
|
|
type="text"
|
|
inputMode="decimal"
|
|
className="h-9 w-full font-mono tabular-nums"
|
|
disabled={saving}
|
|
value={oddsMultiplierLabel(row.odds_value)}
|
|
onChange={(e) =>
|
|
updateOddsForScope(scope, {
|
|
odds_value: parseOddsMultiplierInput(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
) : (
|
|
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
|
|
{oddsMultiplierLabel(row.odds_value)}
|
|
</ConfigReadonlyValue>
|
|
)
|
|
) : (
|
|
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="grid min-w-0 gap-1.5">
|
|
<Label className="truncate text-xs font-medium text-muted-foreground">
|
|
{t("odds.rebateRate", { ns: "config" })}
|
|
</Label>
|
|
{canEditDraft ? (
|
|
<Input
|
|
type="text"
|
|
inputMode="decimal"
|
|
className="h-9 w-full font-mono tabular-nums"
|
|
disabled={saving}
|
|
value={rebatePercentUi}
|
|
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
|
/>
|
|
) : (
|
|
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
|
|
{rebatePercentUi}
|
|
</ConfigReadonlyValue>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!embedded ? (
|
|
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
|
|
const dialogs = (
|
|
<>
|
|
<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>
|
|
|
|
<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-4">
|
|
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
|
{toolbarBlock}
|
|
{filtersBlock}
|
|
</div>
|
|
{mainBlock}
|
|
{dialogs}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ConfigDocPage
|
|
title={t("nav.items.odds", { ns: "config" })}
|
|
filters={filtersBlock}
|
|
toolbar={toolbarBlock}
|
|
>
|
|
{mainBlock}
|
|
{dialogs}
|
|
</ConfigDocPage>
|
|
);
|
|
}
|