feat(ui): enhance table and admin components with improved layout and status display
- 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.
This commit is contained in:
@@ -16,8 +16,11 @@ import {
|
||||
} 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 {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -34,6 +37,7 @@ 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";
|
||||
@@ -77,16 +81,25 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
|
||||
type OddsConfigDocScreenProps = {
|
||||
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
|
||||
embedded?: boolean;
|
||||
/** 与回水分区共用版本选择(合并页) */
|
||||
versionId?: string;
|
||||
onVersionIdChange?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
|
||||
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 [selectedId, setSelectedId] = useState("");
|
||||
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);
|
||||
@@ -417,38 +430,42 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
||||
];
|
||||
|
||||
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) => (
|
||||
<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={tab.id}
|
||||
active={catTab === tab.id}
|
||||
onClick={() => setCatTab(tab.id)}
|
||||
key={type.play_code}
|
||||
active={resolvedPlayCode === type.play_code}
|
||||
onClick={() => setPlayCode(type.play_code)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{tab.label}
|
||||
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</ConfigChipGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toolbarBlock = (
|
||||
<ConfigDocToolbar
|
||||
className={embedded ? "rounded-none border-0 shadow-none" : undefined}
|
||||
switcher={
|
||||
<ConfigVersionSwitcher
|
||||
versions={list}
|
||||
@@ -475,57 +492,64 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
||||
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 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>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("odds.loadingDetails", { ns: "config" })}
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<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 max-w-[200px] font-mono tabular-nums"
|
||||
className="h-9 w-full font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={oddsMultiplierLabel(row.odds_value)}
|
||||
onChange={(e) =>
|
||||
@@ -535,46 +559,39 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue mono className="max-w-[200px]">
|
||||
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
|
||||
{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}
|
||||
)
|
||||
) : (
|
||||
<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}
|
||||
</>
|
||||
@@ -649,10 +666,11 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{filtersBlock}
|
||||
{toolbarBlock}
|
||||
{contextBlock}
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||
{toolbarBlock}
|
||||
{filtersBlock}
|
||||
</div>
|
||||
{mainBlock}
|
||||
{dialogs}
|
||||
</div>
|
||||
@@ -664,7 +682,6 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
||||
title={t("nav.items.odds", { ns: "config" })}
|
||||
filters={filtersBlock}
|
||||
toolbar={toolbarBlock}
|
||||
context={contextBlock}
|
||||
>
|
||||
{mainBlock}
|
||||
{dialogs}
|
||||
|
||||
@@ -10,16 +10,18 @@ import {
|
||||
getPlayConfigVersion,
|
||||
getPlayConfigVersions,
|
||||
postPlayConfigVersion,
|
||||
patchAdminPlayType,
|
||||
publishPlayConfigVersion,
|
||||
putPlayConfigItems,
|
||||
} from "@/api/admin-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { 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 {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -136,7 +138,7 @@ function buildPlayConfigSavePayload(
|
||||
|
||||
export function PlayConfigDocScreen() {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
@@ -269,25 +271,10 @@ export function PlayConfigDocScreen() {
|
||||
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
async function applyPlayToggleInstant(playCode: string, enabled: boolean) {
|
||||
try {
|
||||
await patchAdminPlayType(playCode, { is_enabled: enabled });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : t("play.toggleInstantFailed", { ns: "config" }),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
|
||||
const targets = draftRows.filter(group.match);
|
||||
function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
|
||||
setDraftRows((prev) =>
|
||||
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
|
||||
);
|
||||
for (const row of targets) {
|
||||
await applyPlayToggleInstant(row.play_code, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
const batchSwitchStates = useMemo(
|
||||
@@ -448,26 +435,27 @@ export function PlayConfigDocScreen() {
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
detail ? (
|
||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||
{activeHead ? (
|
||||
<span>
|
||||
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
|
||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{!isDraft ? (
|
||||
<ConfigVersionToolbarMetaEmphasis>
|
||||
{t("play.readOnlyHint", { ns: "config" })}
|
||||
</ConfigVersionToolbarMetaEmphasis>
|
||||
) : activeHead ? (
|
||||
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</ConfigVersionToolbarMeta>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
context={
|
||||
detail ? (
|
||||
<ConfigContextBanner emphasis={!isDraft}>
|
||||
{activeHead ? (
|
||||
<>
|
||||
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
|
||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||
</>
|
||||
) : null}
|
||||
{!isDraft ? (
|
||||
<>
|
||||
{activeHead ? " — " : ""}
|
||||
<ConfigContextEmphasis>{t("play.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
||||
</>
|
||||
) : null}
|
||||
</ConfigContextBanner>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detail ? (
|
||||
<ConfigSection
|
||||
@@ -476,7 +464,9 @@ export function PlayConfigDocScreen() {
|
||||
>
|
||||
<ConfigChipGroup>
|
||||
{batchSwitchStates.map((group) => {
|
||||
const groupOn = group.enabledCount > 0;
|
||||
const groupOn = group.allEnabled;
|
||||
const isPartial =
|
||||
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||
return (
|
||||
<div
|
||||
key={group.key}
|
||||
@@ -486,16 +476,23 @@ export function PlayConfigDocScreen() {
|
||||
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{group.total > 0
|
||||
? t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
? isPartial
|
||||
? t("play.batchPartialEnabled", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.noPlayTypes", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
<ConfirmableSwitch
|
||||
checked={groupOn}
|
||||
confirmBusy={confirmBusy}
|
||||
disabled={!isDraft || saving || group.total === 0}
|
||||
aria-label={t("play.aria.batchGroupSwitch", {
|
||||
ns: "config",
|
||||
@@ -537,8 +534,8 @@ export function PlayConfigDocScreen() {
|
||||
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-32 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[120px] text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px] text-center">{t("play.table.actions", { ns: "config" })}</TableHead>
|
||||
@@ -550,41 +547,47 @@ export function PlayConfigDocScreen() {
|
||||
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<Switch
|
||||
checked={row.is_enabled}
|
||||
disabled={!isDraft || saving}
|
||||
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!isDraft) {
|
||||
return;
|
||||
}
|
||||
const enabled = checked;
|
||||
const action = enabled
|
||||
? t("play.toggleEnable", { ns: "config" })
|
||||
: t("play.toggleDisable", { ns: "config" });
|
||||
requestConfirm({
|
||||
title: t("play.toggleConfirmTitle", {
|
||||
ns: "config",
|
||||
action,
|
||||
playCode: row.play_code,
|
||||
}),
|
||||
description: t("play.toggleConfirmDescription", { ns: "config" }),
|
||||
confirmVariant: enabled ? "default" : "destructive",
|
||||
onConfirm: () => {
|
||||
updateConfigRow(row.play_code, { is_enabled: enabled });
|
||||
void applyPlayToggleInstant(row.play_code, enabled);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isDraft ? (
|
||||
<div className="flex justify-center">
|
||||
<ConfirmableSwitch
|
||||
checked={row.is_enabled}
|
||||
confirmBusy={confirmBusy}
|
||||
disabled={saving}
|
||||
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
|
||||
onCheckedChange={(enabled) => {
|
||||
const action = enabled
|
||||
? t("play.toggleEnable", { ns: "config" })
|
||||
: t("play.toggleDisable", { ns: "config" });
|
||||
requestConfirm({
|
||||
title: t("play.toggleConfirmTitle", {
|
||||
ns: "config",
|
||||
action,
|
||||
playCode: row.play_code,
|
||||
}),
|
||||
description: t("play.toggleConfirmDescription", { ns: "config" }),
|
||||
confirmVariant: enabled ? "default" : "destructive",
|
||||
onConfirm: () => {
|
||||
updateConfigRow(row.play_code, { is_enabled: enabled });
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
|
||||
{row.is_enabled
|
||||
? t("play.states.enabled", { ns: "config" })
|
||||
: t("play.states.disabled", { ns: "config" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-32 text-center">
|
||||
<TableCell className="w-36 text-center">
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
className="mx-auto h-8 w-28 text-center text-sm"
|
||||
className="mx-auto h-8 w-full max-w-[9rem] text-center text-sm"
|
||||
disabled={saving}
|
||||
value={row.display_name ?? ""}
|
||||
placeholder={row.play_code}
|
||||
@@ -604,12 +607,12 @@ export function PlayConfigDocScreen() {
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-[96px] text-center">
|
||||
<TableCell className="w-24 text-center">
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="h-8 w-16 font-mono tabular-nums text-center"
|
||||
className="mx-auto h-8 w-16 font-mono tabular-nums text-center"
|
||||
value={row.display_order}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
@@ -630,7 +633,7 @@ export function PlayConfigDocScreen() {
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-8 text-center font-mono tabular-nums"
|
||||
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
||||
onChange={(e) =>
|
||||
@@ -651,7 +654,7 @@ export function PlayConfigDocScreen() {
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="h-8 text-center font-mono tabular-nums"
|
||||
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -14,9 +14,12 @@ import {
|
||||
publishOddsVersion,
|
||||
putOddsItems,
|
||||
} from "@/api/admin-config";
|
||||
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||
@@ -54,9 +57,15 @@ function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPl
|
||||
|
||||
type RebateConfigDocScreenProps = {
|
||||
embedded?: boolean;
|
||||
versionId?: string;
|
||||
onVersionIdChange?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScreenProps) {
|
||||
export function RebateConfigDocScreen({
|
||||
embedded = false,
|
||||
versionId: controlledVersionId,
|
||||
onVersionIdChange,
|
||||
}: RebateConfigDocScreenProps) {
|
||||
const { t } = useTranslation(["config", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
const profile = useAdminProfile();
|
||||
@@ -65,7 +74,9 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
|
||||
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
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 [loading, setLoading] = useState(true);
|
||||
@@ -313,31 +324,34 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
embedded || !detail ? null : (
|
||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||
<span>
|
||||
{t("rebate.editingVersion", {
|
||||
ns: "config",
|
||||
version: detail.version_no,
|
||||
status:
|
||||
detail.status === "draft"
|
||||
? t("versionStatus.draft", { ns: "config" })
|
||||
: detail.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 contextBlock =
|
||||
embedded || !detail ? null : (
|
||||
<ConfigContextBanner emphasis={!isDraft}>
|
||||
{t("rebate.editingVersion", {
|
||||
ns: "config",
|
||||
version: detail.version_no,
|
||||
status:
|
||||
detail.status === "draft"
|
||||
? t("versionStatus.draft", { ns: "config" })
|
||||
: detail.status === "active"
|
||||
? t("versionStatus.active", { ns: "config" })
|
||||
: t("versionStatus.archived", { ns: "config" }),
|
||||
})}
|
||||
{!isDraft ? (
|
||||
<>
|
||||
{" "}
|
||||
— <ConfigContextEmphasis>{t("rebate.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
||||
</>
|
||||
) : null}
|
||||
</ConfigContextBanner>
|
||||
);
|
||||
|
||||
const fieldsBlock = (
|
||||
<>
|
||||
<div className="grid gap-5 sm:grid-cols-3">
|
||||
@@ -392,10 +406,10 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/60 px-4 py-3">
|
||||
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
|
||||
{t("rebate.winEnjoy.label", { ns: "config" })}
|
||||
</Label>
|
||||
<Switch id="win-enjoy" checked disabled aria-label={t("rebate.winEnjoy.label", { ns: "config" })} />
|
||||
<p className="text-sm font-medium">{t("rebate.winEnjoy.label", { ns: "config" })}</p>
|
||||
<AdminStatusBadge status="enabled">
|
||||
{t("system.states.enabled", { ns: "config" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
|
||||
{!embedded ? (
|
||||
@@ -415,8 +429,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{contextBlock}
|
||||
<div className="space-y-4">
|
||||
{fieldsBlock}
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
@@ -427,7 +440,6 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
||||
<ConfigDocPage
|
||||
title={t("nav.items.rebate", { ns: "config" })}
|
||||
toolbar={toolbarBlock}
|
||||
context={contextBlock}
|
||||
>
|
||||
{fieldsBlock}
|
||||
<ConfirmDialog />
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
putRiskCapItems,
|
||||
} from "@/api/admin-config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
|
||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||
import {
|
||||
ConfigVersionToolbarMeta,
|
||||
ConfigVersionToolbarMetaEmphasis,
|
||||
} from "@/modules/config/config-version-toolbar-meta";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -375,21 +378,27 @@ export function RiskCapDocScreen() {
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
detail ? (
|
||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||
<span>
|
||||
{t("riskCap.effectiveAt", {
|
||||
ns: "config",
|
||||
value: detail.effective_at ? formatDt(detail.effective_at) : "—",
|
||||
})}
|
||||
</span>
|
||||
{!isDraft ? (
|
||||
<ConfigVersionToolbarMetaEmphasis>
|
||||
{t("riskCap.readOnlyHint", { ns: "config" })}
|
||||
</ConfigVersionToolbarMetaEmphasis>
|
||||
) : (
|
||||
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||
)}
|
||||
</ConfigVersionToolbarMeta>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
context={
|
||||
detail ? (
|
||||
<ConfigContextBanner emphasis={!isDraft}>
|
||||
{t("riskCap.effectiveAt", { ns: "config", value: detail.effective_at ? formatDt(detail.effective_at) : "—" })}
|
||||
{!isDraft ? (
|
||||
<>
|
||||
{" "}
|
||||
— <ConfigContextEmphasis>{t("riskCap.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
||||
</>
|
||||
) : null}
|
||||
</ConfigContextBanner>
|
||||
) : null
|
||||
}
|
||||
contentClassName="space-y-8"
|
||||
>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
@@ -447,8 +456,8 @@ export function RiskCapDocScreen() {
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[90px] text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[90px] text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[90px] text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[90px] text-center">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[72px] text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
@@ -494,8 +503,8 @@ export function RiskCapDocScreen() {
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground text-sm">—</TableCell>
|
||||
<TableCell>
|
||||
{canEditDraft ? (
|
||||
@@ -546,9 +555,9 @@ export function RiskCapDocScreen() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-right">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
@@ -557,15 +566,11 @@ export function RiskCapDocScreen() {
|
||||
{occFiltered.map((r) => (
|
||||
<TableRow key={`occ-${r.clientKey}`}>
|
||||
<TableCell className="font-mono text-sm">{r.normalized_number}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" disabled>
|
||||
{t("riskCap.actions.close", { ns: "config" })}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
Reference in New Issue
Block a user