feat: 添加货币管理功能,更新国际化支持,移除报表相关代码

This commit is contained in:
2026-05-21 16:24:56 +08:00
parent 6ecbaf5fb4
commit 055c613a6d
87 changed files with 1615 additions and 1319 deletions

View File

@@ -5,41 +5,26 @@ import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const LINKS: { href: string; key: string; match?: "exact" | "prefix" }[] = [
{ href: "/admin/config/plays", key: "plays" },
{ href: "/admin/config/odds", key: "odds" },
{ href: "/admin/config/rebate", key: "rebate" },
{ href: "/admin/config/risk-cap", key: "risk-cap" },
];
function linkActive(pathname: string, href: string, match: "exact" | "prefix"): boolean {
if (match === "exact") {
return pathname === href || pathname === `${href}/`;
}
return pathname === href || pathname.startsWith(`${href}/`);
}
import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model";
export function ConfigSubNav() {
const { t } = useTranslation("config");
const pathname = usePathname();
const links = CONFIG_NAV_GROUPS.flatMap((group) => group.items);
return (
<nav
className="flex flex-wrap gap-2 border-b border-border pb-3 mb-6"
aria-label={t("nav.aria")}
>
{LINKS.map(({ href, key, match = "prefix" }) => {
const active = linkActive(pathname, href, match);
<nav className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1" aria-label={t("nav.aria")}>
{links.map(({ href, key }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
className={cn(
"rounded-md px-3 py-1.5 text-sm transition-colors",
"border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "bg-primary text-primary-foreground"
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground",
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
)}
>
{t(`nav.items.${key}`)}

View File

@@ -5,7 +5,6 @@ import { Layers } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -165,22 +164,23 @@ export function ConfigVersionSwitcher({
</SheetHeader>
</div>
<div className="border-b bg-white px-4 py-3">
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-wrap gap-2">
{statusCounts.map((s) => (
<div key={s.status} className="rounded-xl border bg-white px-3 py-2">
<p className="text-[11px] font-medium text-slate-500">{s.label}</p>
<p className="mt-0.5 text-lg font-semibold tabular-nums text-slate-950">
{s.count}
</p>
<div
key={s.status}
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background px-3 py-1.5 text-xs text-muted-foreground"
>
<span className="font-medium text-foreground/80">{s.label}</span>
<span className="font-mono tabular-nums">{s.count}</span>
</div>
))}
</div>
</div>
<div className="flex-1 overflow-auto space-y-5 px-4 py-4">
<div className="flex-1 overflow-auto px-4 py-4">
{sortedVersions.length === 0 ? (
<Card className="border-dashed border-slate-200 bg-white/80 p-5 text-center text-sm text-slate-500 shadow-none">
<div className="rounded-2xl border border-dashed border-border/60 bg-white/70 p-5 text-center text-sm text-muted-foreground">
{t("versionSwitcher.empty", { ns: "config" })}
</Card>
</div>
) : (
STATUS_ORDER.map((status) => {
const rows = groupedVersions.get(status) ?? [];
@@ -188,8 +188,8 @@ export function ConfigVersionSwitcher({
return null;
}
return (
<section key={status} className="space-y-2.5">
<div className="flex items-center justify-between px-1">
<section key={status} className="border-b border-border/60 pb-4 last:border-b-0 last:pb-0">
<div className="mb-2 flex items-center justify-between px-1">
<div className="flex items-center gap-2.5">
<div
className={cn(
@@ -199,111 +199,109 @@ export function ConfigVersionSwitcher({
status === "archived" && "bg-slate-400 shadow-slate-100",
)}
/>
<p className="text-[15px] font-semibold text-slate-950">
<p className="text-[15px] font-semibold text-foreground">
{t(`versionStatus.${status}`, { ns: "config" })}
</p>
</div>
<p className="rounded-full bg-white px-2 py-0.5 text-xs font-medium tabular-nums text-slate-500 ring-1 ring-slate-200">
<p className="rounded-full bg-muted/50 px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{t("versionSwitcher.count", { ns: "config", count: rows.length })}
</p>
</div>
<div className="space-y-2.5">
<div className="space-y-1.5">
{rows.map((v) => {
const isCurrent = selectedId === String(v.id);
return (
<Card
<div
key={v.id}
className={cn(
"group overflow-hidden rounded-2xl border bg-white p-0 shadow-none transition-colors hover:border-slate-300",
isCurrent && "border-slate-900 ring-1 ring-slate-900/10",
"group flex gap-3 rounded-xl border border-transparent px-2 py-3 transition-colors hover:bg-muted/30",
isCurrent && "border-border/60 bg-muted/20",
)}
>
<div className="flex gap-3 p-3.5">
<div
className={cn(
"mt-1 h-auto w-1 shrink-0 rounded-full bg-slate-200",
v.status === "draft" && "bg-amber-300",
v.status === "active" && "bg-emerald-400",
v.status === "archived" && "bg-slate-300",
)}
/>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-slate-950">
v{v.version_no}
</span>
<ConfigStatusBadge status={v.status} />
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium tabular-nums text-slate-500">
#{v.id}
</span>
</div>
<p className="line-clamp-2 text-[13px] leading-5 text-slate-500">
{t("versionSwitcher.effectiveAt", {
ns: "config",
value: v.effective_at ? formatDt(v.effective_at) : "—",
})}
{v.reason
? ` · ${t("versionSwitcher.note", {
ns: "config",
value: v.reason,
})}`
: ""}
</p>
</div>
{isCurrent ? (
<span className="shrink-0 rounded-full bg-slate-950 px-2.5 py-1 text-xs font-medium text-white shadow-sm">
{t("versionSwitcher.current", { ns: "config" })}
<div
className={cn(
"mt-1 h-auto w-1 shrink-0 rounded-full bg-slate-200",
v.status === "draft" && "bg-amber-300",
v.status === "active" && "bg-emerald-400",
v.status === "archived" && "bg-slate-300",
)}
/>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-foreground">
v{v.version_no}
</span>
) : null}
<ConfigStatusBadge status={v.status} />
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
#{v.id}
</span>
</div>
<p className="line-clamp-2 text-[13px] leading-5 text-muted-foreground">
{t("versionSwitcher.effectiveAt", {
ns: "config",
value: v.effective_at ? formatDt(v.effective_at) : "—",
})}
{v.reason
? ` · ${t("versionSwitcher.note", {
ns: "config",
value: v.reason,
})}`
: ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-3">
{isCurrent ? (
<span className="shrink-0 rounded-full bg-foreground px-2.5 py-1 text-xs font-medium text-background">
{t("versionSwitcher.current", { ns: "config" })}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={isCurrent ? "secondary" : "outline"}
size="sm"
className={cn(
"h-8 rounded-full px-3 text-xs",
isCurrent && "bg-muted text-muted-foreground hover:bg-muted",
)}
onClick={() => switchTo(v.id)}
>
{isCurrent
? t("versionSwitcher.selected", { ns: "config" })
: t("versionSwitcher.view", { ns: "config" })}
</Button>
{onRollbackVersion && v.status !== "draft" ? (
<Button
type="button"
variant={isCurrent ? "secondary" : "outline"}
variant="ghost"
size="sm"
className={cn(
"h-8 rounded-full px-3 text-xs",
isCurrent && "bg-slate-100 text-slate-500 hover:bg-slate-100",
)}
onClick={() => switchTo(v.id)}
className="rounded-full text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setSheetOpen(false);
}}
>
{isCurrent
? t("versionSwitcher.selected", { ns: "config" })
: t("versionSwitcher.view", { ns: "config" })}
{t("versionSwitcher.rollback", { ns: "config" })}
</Button>
{onRollbackVersion && v.status !== "draft" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full text-xs text-slate-600 hover:bg-slate-100 hover:text-slate-950"
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setSheetOpen(false);
}}
>
{t("versionSwitcher.rollback", { ns: "config" })}
</Button>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
{t("versionSwitcher.delete", { ns: "config" })}
</Button>
) : null}
</div>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
{t("versionSwitcher.delete", { ns: "config" })}
</Button>
) : null}
</div>
</div>
</Card>
</div>
);
})}
</div>

View File

@@ -1,88 +1,16 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model";
function navLinkActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
import { ConfigSubNav } from "@/modules/config/config-subnav";
export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
const { t } = useTranslation("config");
const pathname = usePathname() ?? "";
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<aside className="shrink-0 lg:sticky lg:top-[72px] lg:w-56 lg:self-start">
<div className="rounded-2xl border border-border/70 bg-card/80 p-3 shadow-sm backdrop-blur lg:max-h-[calc(100vh-7rem)] lg:overflow-y-auto">
<div className="mb-3 px-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{t("nav.sidebarTitle")}
</p>
</div>
<nav className="hidden space-y-3 lg:block" aria-label={t("nav.aria")}>
{CONFIG_NAV_GROUPS.map((group) => (
<div key={group.id} className="space-y-1.5">
<p className="px-2 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{t(`nav.groups.${group.id}`)}
</p>
<ul className="space-y-1">
{group.items.map((item) => {
const active = navLinkActive(pathname, item.href);
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"block rounded-xl border px-3 py-2.5 text-sm transition-all outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
active
? "border-primary/20 bg-primary/10 text-primary shadow-sm"
: "border-transparent bg-transparent text-foreground hover:border-border hover:bg-muted/60",
)}
>
<div className="font-medium">{t(`nav.items.${item.key}`)}</div>
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="lg:hidden overflow-x-auto pb-1 -mx-1 px-1">
<div className="flex w-max gap-2">
{CONFIG_NAV_GROUPS.flatMap((g) => g.items).map((item) => {
const active = navLinkActive(pathname, item.href);
return (
<Link
key={`m-${item.href}`}
href={item.href}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors whitespace-nowrap",
active
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-foreground hover:bg-muted/60",
)}
>
{t(`nav.items.${item.key}`)}
</Link>
);
})}
</div>
</div>
</div>
</aside>
<div className="min-w-0 flex-1">{children}</div>
<div className="mx-auto flex w-full max-w-[1680px] flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
<div className="sticky top-14 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<ConfigSubNav />
</div>
<div className="min-w-0">{children}</div>
</div>
);
}

View File

@@ -445,32 +445,30 @@ export function OddsConfigDocScreen() {
</div>
</div>
<div className="rounded-xl border bg-muted/20 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={t("odds.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
className="lg:flex-1"
/>
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={t("odds.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
className="lg:flex-1"
/>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void requestPublishConfirm()}
/>
</div>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void requestPublishConfirm()}
/>
</div>
{detail ? (

View File

@@ -380,29 +380,27 @@ export function PlayConfigDocScreen() {
<CardTitle className="text-lg">{t("nav.items.plays", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-xl border bg-muted/20 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
className="lg:flex-1"
/>
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
className="lg:flex-1"
/>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSaveDraft()}
onPublish={() => void handlePublish()}
/>
</div>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSaveDraft()}
onPublish={() => void handlePublish()}
/>
</div>
{detail ? (
@@ -423,11 +421,9 @@ export function PlayConfigDocScreen() {
) : null}
{detail ? (
<div className="rounded-xl border bg-muted/20 p-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">{t("play.batchSwitchesTitle", { ns: "config" })}</p>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium">{t("play.batchSwitchesTitle", { ns: "config" })}</p>
{!isDraft ? (
<span className="text-xs text-amber-600 dark:text-amber-400">
{t("play.readOnlyDraftHint", { ns: "config" })}
@@ -438,7 +434,7 @@ export function PlayConfigDocScreen() {
{batchSwitchStates.map((group) => (
<div
key={group.key}
className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2"
className="flex items-center gap-2 rounded-xl border border-border/60 bg-background/70 px-3 py-2"
>
<div className="min-w-[92px]">
<p className="text-sm font-medium">{group.label}</p>
@@ -474,8 +470,7 @@ export function PlayConfigDocScreen() {
{loadingDetail ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
@@ -606,8 +601,7 @@ export function PlayConfigDocScreen() {
</TableRow>
))}
</TableBody>
</Table>
</div>
</Table>
)}
</CardContent>

View File

@@ -365,7 +365,7 @@ export function RebateConfigDocScreen() {
</div>
</div>
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
<div className="flex items-start gap-3 px-1">
<Checkbox
id="win-enjoy"
checked

View File

@@ -374,7 +374,7 @@ export function RiskCapDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
<section className="space-y-3">
<h3 className="text-sm font-medium">{t("riskCap.defaultCap.title", { ns: "config" })}</h3>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
@@ -422,8 +422,7 @@ export function RiskCapDocScreen() {
) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
@@ -492,9 +491,8 @@ export function RiskCapDocScreen() {
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableBody>
</Table>
)}
</section>
@@ -522,8 +520,7 @@ export function RiskCapDocScreen() {
{t("riskCap.actions.exportCsv", { ns: "config" })}
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<Table>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
@@ -551,7 +548,6 @@ export function RiskCapDocScreen() {
))}
</TableBody>
</Table>
</div>
</section>
</CardContent>

View File

@@ -42,7 +42,11 @@ interface Draft {
outMax: string;
}
export function WalletConfigDocScreen() {
type WalletConfigDocScreenProps = {
embedded?: boolean;
};
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
const { t } = useTranslation(["config", "adminUsers"]);
const [draft, setDraft] = useState<Draft>({
inMin: "",
@@ -109,83 +113,91 @@ export function WalletConfigDocScreen() {
}
};
const content = (
<>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="in-min">{t("wallet.fields.inMin", { ns: "config" })}</Label>
<Input
id="in-min"
type="number"
min="0"
step="0.01"
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.inMin}
onChange={(e) => handleChange("inMin", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="in-max">{t("wallet.fields.inMax", { ns: "config" })}</Label>
<Input
id="in-max"
type="number"
min="0"
step="0.01"
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.inMax}
onChange={(e) => handleChange("inMax", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="out-min">{t("wallet.fields.outMin", { ns: "config" })}</Label>
<Input
id="out-min"
type="number"
min="0"
step="0.01"
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.outMin}
onChange={(e) => handleChange("outMin", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="out-max">{t("wallet.fields.outMax", { ns: "config" })}</Label>
<Input
id="out-max"
type="number"
min="0"
step="0.01"
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.outMax}
onChange={(e) => handleChange("outMax", e.target.value)}
disabled={loading || saving}
/>
</div>
</div>
<div className="flex items-center gap-4 pt-2">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
{t("wallet.discard", { ns: "config" })}
</Button>
)}
</div>
</>
);
if (embedded) {
return content;
}
return (
<Card>
<CardHeader>
<CardTitle>{t("wallet.title", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="in-min">{t("wallet.fields.inMin", { ns: "config" })}</Label>
<Input
id="in-min"
type="number"
min="0"
step="0.01"
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.inMin}
onChange={(e) => handleChange("inMin", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="in-max">{t("wallet.fields.inMax", { ns: "config" })}</Label>
<Input
id="in-max"
type="number"
min="0"
step="0.01"
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.inMax}
onChange={(e) => handleChange("inMax", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="out-min">{t("wallet.fields.outMin", { ns: "config" })}</Label>
<Input
id="out-min"
type="number"
min="0"
step="0.01"
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.outMin}
onChange={(e) => handleChange("outMin", e.target.value)}
disabled={loading || saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="out-max">{t("wallet.fields.outMax", { ns: "config" })}</Label>
<Input
id="out-max"
type="number"
min="0"
step="0.01"
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.outMax}
onChange={(e) => handleChange("outMax", e.target.value)}
disabled={loading || saving}
/>
</div>
</div>
<div className="flex items-center gap-4">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
{t("wallet.discard", { ns: "config" })}
</Button>
)}
</div>
</CardContent>
<CardContent className="space-y-6">{content}</CardContent>
</Card>
);
}