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}
|
||||
|
||||
Reference in New Issue
Block a user