feat: 统一管理端多语言、配置与票据/结算页面重构
This commit is contained in:
@@ -276,7 +276,7 @@ export function OddsConfigDocScreen() {
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.publishFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -308,13 +308,13 @@ export function OddsConfigDocScreen() {
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`Created draft v${d.version_no}`);
|
||||
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 : "Create draft failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -330,7 +330,13 @@ export function OddsConfigDocScreen() {
|
||||
reason: `rollback from v${rollbackTarget.version_no}`,
|
||||
clone_from_version_id: rollbackTarget.id,
|
||||
});
|
||||
toast.success(`Cloned v${rollbackTarget.version_no} into new draft v${d.version_no}`);
|
||||
toast.success(
|
||||
t("odds.rollbackSuccess", {
|
||||
ns: "config",
|
||||
fromVersion: rollbackTarget.version_no,
|
||||
version: d.version_no,
|
||||
}),
|
||||
);
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
@@ -338,7 +344,7 @@ export function OddsConfigDocScreen() {
|
||||
setRollbackOpen(false);
|
||||
setRollbackTarget(null);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Rollback failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.rollbackFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -352,7 +358,7 @@ export function OddsConfigDocScreen() {
|
||||
toast.success(t("versionSwitcher.delete", { ns: "config" }));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.deleteFailed", { ns: "config" }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -382,7 +388,7 @@ export function OddsConfigDocScreen() {
|
||||
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
|
||||
|
||||
const catTabs: { id: CatTab; label: string }[] = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
|
||||
{ id: "d4", label: "4D" },
|
||||
{ id: "d3", label: "3D" },
|
||||
{ id: "d2", label: "2D" },
|
||||
@@ -395,7 +401,7 @@ export function OddsConfigDocScreen() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-base text-muted-foreground self-center mr-2">Category</span>
|
||||
<span className="text-base text-muted-foreground self-center mr-2">{t("odds.category", { ns: "config" })}</span>
|
||||
{catTabs.map((t) => (
|
||||
<Button
|
||||
key={t.id}
|
||||
@@ -410,10 +416,10 @@ export function OddsConfigDocScreen() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-h-[96px]">
|
||||
<p className="text-base text-muted-foreground">Play Type</p>
|
||||
<p className="text-base text-muted-foreground">{t("odds.playType", { ns: "config" })}</p>
|
||||
<div className="flex flex-wrap gap-2 min-h-[44px]">
|
||||
{filteredTypes.length === 0 ? (
|
||||
<span className="text-base text-muted-foreground">No play types in this category.</span>
|
||||
<span className="text-base text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
||||
) : (
|
||||
filteredTypes.map((t) => (
|
||||
<Button
|
||||
@@ -443,7 +449,7 @@ export function OddsConfigDocScreen() {
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loadingList}
|
||||
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription="Choose a version to view here. Non-draft versions can be rolled back into a new draft."
|
||||
sheetDescription={t("odds.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onRollbackVersion={requestRollback}
|
||||
rollbackBusy={saving}
|
||||
@@ -465,7 +471,7 @@ export function OddsConfigDocScreen() {
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Active version:
|
||||
{t("odds.activeVersionPrefix", { ns: "config" })}
|
||||
{activeHead ? (
|
||||
<>
|
||||
v{activeHead.version_no}
|
||||
@@ -475,7 +481,7 @@ export function OddsConfigDocScreen() {
|
||||
"—"
|
||||
)}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400"> - This version is read-only. Create a draft before editing odds.</span>
|
||||
<span className="text-amber-600 dark:text-amber-400"> - {t("odds.readOnlyHint", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -484,7 +490,7 @@ export function OddsConfigDocScreen() {
|
||||
|
||||
{loadingDetail || loadingTypes ? (
|
||||
<div className="flex min-h-[420px] items-center">
|
||||
<p className="text-base text-muted-foreground">Loading details…</p>
|
||||
<p className="text-base text-muted-foreground">{t("odds.loadingDetails", { ns: "config" })}</p>
|
||||
</div>
|
||||
) : resolvedPlayCode ? (
|
||||
<div className="grid min-h-[420px] gap-4 max-w-md">
|
||||
@@ -519,17 +525,21 @@ export function OddsConfigDocScreen() {
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Multiplier x{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
|
||||
{t("odds.multiplier", {
|
||||
ns: "config",
|
||||
value: oddsMultiplierLabel(row.odds_value),
|
||||
currency: row.currency_code,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">Missing {scope} row. Check seed or version data.</p>
|
||||
<p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid gap-1 pt-2 border-t">
|
||||
<Label>Rebate Rate (%)</Label>
|
||||
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
@@ -544,7 +554,7 @@ export function OddsConfigDocScreen() {
|
||||
{rebatePercentUi}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">Writes rebate_rate to all prize scopes under this play type.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -554,9 +564,9 @@ export function OddsConfigDocScreen() {
|
||||
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm rollback</DialogTitle>
|
||||
<DialogTitle>{t("odds.rollbackDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
A new draft will be cloned from version v{rollbackTarget?.version_no}. The active version will not be overwritten directly.
|
||||
{t("odds.rollbackDialog.description", { ns: "config", version: rollbackTarget?.version_no ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -564,7 +574,7 @@ export function OddsConfigDocScreen() {
|
||||
{t("actions.cancel", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
|
||||
Confirm rollback
|
||||
{t("odds.rollbackDialog.confirm", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -573,16 +583,16 @@ export function OddsConfigDocScreen() {
|
||||
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish odds version?</DialogTitle>
|
||||
<DialogTitle>{t("odds.publishDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
New odds affect new tickets immediately. Existing successful tickets still settle by their saved odds snapshot.
|
||||
{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>Prize Scope</span>
|
||||
<span className="text-right">Current Active</span>
|
||||
<span className="text-right">After Publish</span>
|
||||
<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">
|
||||
@@ -606,7 +616,7 @@ export function OddsConfigDocScreen() {
|
||||
void handlePublish();
|
||||
}}
|
||||
>
|
||||
Confirm publish
|
||||
{t("odds.publishDialog.confirm", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -284,7 +284,7 @@ export function PlayConfigDocScreen() {
|
||||
const payload = buildPlayConfigSavePayload(draftRows);
|
||||
for (const r of payload) {
|
||||
if (r.min_bet_amount > r.max_bet_amount) {
|
||||
toast.error(`${r.play_code}: min_bet_amount cannot exceed max_bet_amount`);
|
||||
toast.error(t("play.validation.minMaxInvalid", { ns: "config", playCode: r.play_code }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export function PlayConfigDocScreen() {
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.publishFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -329,14 +329,14 @@ export function PlayConfigDocScreen() {
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`Created draft v${d.version_no}`);
|
||||
toast.success(t("play.createDraftSuccess", { ns: "config", version: d.version_no }));
|
||||
setCreatingDraftId(String(d.id));
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
void refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -356,7 +356,7 @@ export function PlayConfigDocScreen() {
|
||||
updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null });
|
||||
setRuleDialogOpen(false);
|
||||
setRulePlayCode(null);
|
||||
toast.message("Rule text saved into the local draft. Save the draft to persist it.");
|
||||
toast.message(t("play.ruleSavedLocal", { ns: "config" }));
|
||||
}
|
||||
|
||||
const activeHead = list.find((x) => x.status === "active");
|
||||
@@ -367,7 +367,7 @@ export function PlayConfigDocScreen() {
|
||||
toast.success(t("versionSwitcher.delete", { ns: "config" }));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.deleteFailed", { ns: "config" }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -407,14 +407,14 @@ export function PlayConfigDocScreen() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeHead ? (
|
||||
<>
|
||||
Active version v{activeHead.version_no}
|
||||
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
|
||||
{activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""}
|
||||
</>
|
||||
) : null}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
{activeHead ? " — " : ""}
|
||||
Limits and rules are read-only. Create a draft first.
|
||||
{t("play.readOnlyHint", { ns: "config" })}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
@@ -424,14 +424,14 @@ export function PlayConfigDocScreen() {
|
||||
<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">Batch switches</p>
|
||||
<p className="text-sm font-medium">{t("play.batchSwitchesTitle", { ns: "config" })}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only updates the current draft. The player betting table refreshes after save and publish.
|
||||
{t("play.batchSwitchesDesc", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
{!isDraft ? (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Current version is read-only. Create a draft first.
|
||||
{t("play.readOnlyDraftHint", { ns: "config" })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -444,7 +444,13 @@ export function PlayConfigDocScreen() {
|
||||
<div className="min-w-[92px]">
|
||||
<p className="text-sm font-medium">{group.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{group.total > 0 ? `${group.enabledCount}/${group.total} enabled` : "No play types"}
|
||||
{group.total > 0
|
||||
? t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.noPlayTypes", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -454,7 +460,9 @@ export function PlayConfigDocScreen() {
|
||||
disabled={!isDraft || saving || group.total === 0}
|
||||
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
|
||||
>
|
||||
{group.allEnabled ? "Disable" : "Enable"}
|
||||
{group.allEnabled
|
||||
? t("play.actions.disable", { ns: "config" })
|
||||
: t("play.actions.enable", { ns: "config" })}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -471,14 +479,14 @@ export function PlayConfigDocScreen() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center">Play Code</TableHead>
|
||||
<TableHead className="w-[100px] text-center">Category</TableHead>
|
||||
<TableHead className="w-[88px] text-center">Status</TableHead>
|
||||
<TableHead className="min-w-[120px] text-center">Display Name</TableHead>
|
||||
<TableHead className="w-[120px] text-center">Order</TableHead>
|
||||
<TableHead className="w-[110px] text-center">Min Bet</TableHead>
|
||||
<TableHead className="w-[110px] text-center">Max Bet</TableHead>
|
||||
<TableHead className="w-[140px] text-center">Actions</TableHead>
|
||||
<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="min-w-[120px] 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-[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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -494,11 +502,13 @@ export function PlayConfigDocScreen() {
|
||||
onCheckedChange={(v) => {
|
||||
updateConfigRow(row.play_code, { is_enabled: v === true });
|
||||
}}
|
||||
aria-label={`Enable ${row.play_code}`}
|
||||
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue className="justify-center">
|
||||
{row.is_enabled ? "Enabled" : "Disabled"}
|
||||
{row.is_enabled
|
||||
? t("play.states.enabled", { ns: "config" })
|
||||
: t("play.states.disabled", { ns: "config" })}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -588,10 +598,10 @@ export function PlayConfigDocScreen() {
|
||||
disabled={saving}
|
||||
onClick={() => openRuleEditor(row.play_code)}
|
||||
>
|
||||
Rule Text
|
||||
{t("play.actions.ruleText", { ns: "config" })}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Read only</span>
|
||||
<span className="text-sm text-muted-foreground">{t("play.states.readOnly", { ns: "config" })}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -605,13 +615,13 @@ export function PlayConfigDocScreen() {
|
||||
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rule Text (Chinese)</DialogTitle>
|
||||
<DialogTitle>{t("play.ruleDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Play {rulePlayCode ?? "—"}; changes are only stored in the draft until you save and publish it.
|
||||
{t("play.ruleDialog.description", { ns: "config", playCode: rulePlayCode ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rule-zh">rule_text_zh</Label>
|
||||
<Label htmlFor="rule-zh">{t("play.ruleDialog.fieldLabel", { ns: "config" })}</Label>
|
||||
<textarea
|
||||
id="rule-zh"
|
||||
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
|
||||
@@ -624,7 +634,7 @@ export function PlayConfigDocScreen() {
|
||||
{t("actions.cancel", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
<Button type="button" onClick={saveRuleZh}>
|
||||
Apply to Draft
|
||||
{t("play.ruleDialog.apply", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -218,11 +218,11 @@ export function RebateConfigDocScreen() {
|
||||
setP2(inferPercentFrom(2, rows, types));
|
||||
setP3(inferPercentFrom(3, rows, types));
|
||||
setP4(inferPercentFrom(4, rows, types));
|
||||
toast.success("Published odds version with rebate");
|
||||
toast.success(t("rebate.publishSuccess", { ns: "config" }));
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.publishFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export function RebateConfigDocScreen() {
|
||||
reason: `rebate draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`Created draft v${d.version_no}`);
|
||||
toast.success(t("rebate.createDraftSuccess", { ns: "config", version: d.version_no }));
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
@@ -246,7 +246,7 @@ export function RebateConfigDocScreen() {
|
||||
setP3(inferPercentFrom(3, rows, types));
|
||||
setP4(inferPercentFrom(4, rows, types));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -260,7 +260,7 @@ export function RebateConfigDocScreen() {
|
||||
toast.success(t("versionSwitcher.delete", { ns: "config" }));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.deleteFailed", { ns: "config" }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ export function RebateConfigDocScreen() {
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loading}
|
||||
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription="Rebate is stored in the odds draft version and shares the same version set as odds."
|
||||
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
className="w-auto min-w-0"
|
||||
/>
|
||||
@@ -288,7 +288,7 @@ export function RebateConfigDocScreen() {
|
||||
loadingList={loading}
|
||||
loadingDetail={loadingDetail}
|
||||
saving={saving}
|
||||
publishLabel="Publish"
|
||||
publishLabel={t("rebate.publishLabel", { ns: "config" })}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
onSaveDraft={() => void handleSave()}
|
||||
@@ -297,9 +297,18 @@ export function RebateConfigDocScreen() {
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Editing version v{detail.version_no} · {detail.status === "draft" ? "Draft" : detail.status === "active" ? "Active" : "Archived"}
|
||||
{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 ? (
|
||||
<span className="text-amber-600 dark:text-amber-400"> - Create a draft before editing rebate.</span>
|
||||
<span className="text-amber-600 dark:text-amber-400"> - {t("rebate.readOnlyHint", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -307,7 +316,7 @@ export function RebateConfigDocScreen() {
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>2D Rebate Rate (%)</Label>
|
||||
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="number"
|
||||
@@ -323,7 +332,7 @@ export function RebateConfigDocScreen() {
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>3D Rebate Rate (%)</Label>
|
||||
<Label>{t("rebate.fields.d3", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="number"
|
||||
@@ -339,7 +348,7 @@ export function RebateConfigDocScreen() {
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>4D Rebate Rate (%)</Label>
|
||||
<Label>{t("rebate.fields.d4", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="number"
|
||||
@@ -357,19 +366,25 @@ export function RebateConfigDocScreen() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
|
||||
<Checkbox id="win-enjoy" checked aria-disabled disabled aria-label="Apply rebate on winning tickets" />
|
||||
<Checkbox
|
||||
id="win-enjoy"
|
||||
checked
|
||||
aria-disabled
|
||||
disabled
|
||||
aria-label={t("rebate.winEnjoy.label", { ns: "config" })}
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
|
||||
Apply rebate on winning tickets
|
||||
{t("rebate.winEnjoy.label", { ns: "config" })}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Placeholder field. It can later be aligned with risk and settlement rules and persisted.
|
||||
{t("rebate.winEnjoy.description", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Effective Time (current active odds version)</span>
|
||||
<span className="text-muted-foreground">{t("rebate.effectiveTime", { ns: "config" })}</span>
|
||||
<span className="font-mono text-sm">
|
||||
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
|
||||
</span>
|
||||
|
||||
@@ -189,19 +189,19 @@ export function RiskCapDocScreen() {
|
||||
return;
|
||||
}
|
||||
if (draftRows.length === 0) {
|
||||
toast.error("At least one cap row is required");
|
||||
toast.error(t("riskCap.validation.requireAtLeastOne", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
for (const r of draftRows) {
|
||||
if (isDefaultRiskRow(r)) {
|
||||
if (r.cap_amount <= 0) {
|
||||
toast.error("Default cap amount must be greater than 0");
|
||||
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
|
||||
toast.error(`Number must be 4 digits: ${r.normalized_number}`);
|
||||
toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function RiskCapDocScreen() {
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.publishFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export function RiskCapDocScreen() {
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`Created draft v${d.version_no}`);
|
||||
toast.success(t("riskCap.createDraftSuccess", { ns: "config", version: d.version_no }));
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
@@ -282,7 +282,7 @@ export function RiskCapDocScreen() {
|
||||
setDraftRows(nd);
|
||||
syncDefaultCapFromRows(nd);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -291,7 +291,7 @@ export function RiskCapDocScreen() {
|
||||
function applyDefaultCap() {
|
||||
const n = Number.parseInt(defaultCapStr, 10);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
toast.error("Enter a valid cap amount");
|
||||
toast.error(t("riskCap.validation.enterValidCapAmount", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
setDraftRows((prev) => {
|
||||
@@ -299,7 +299,7 @@ export function RiskCapDocScreen() {
|
||||
return [defaultRiskRowFromAmount(n), ...next];
|
||||
});
|
||||
setSyncOpen(false);
|
||||
toast.message("Saved into local draft. Save the draft to persist it.");
|
||||
toast.message(t("riskCap.savedLocalDraft", { ns: "config" }));
|
||||
}
|
||||
|
||||
const occFiltered = useMemo(() => {
|
||||
@@ -321,7 +321,7 @@ export function RiskCapDocScreen() {
|
||||
toast.success(t("versionSwitcher.delete", { ns: "config" }));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.deleteFailed", { ns: "config" }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -364,9 +364,9 @@ export function RiskCapDocScreen() {
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Effective at: {detail.effective_at ? formatDt(detail.effective_at) : "—"} · Note: {detail.reason ?? "—"}
|
||||
{t("riskCap.effectiveAt", { ns: "config", value: detail.effective_at ? formatDt(detail.effective_at) : "—" })} · {t("riskCap.note", { ns: "config", value: detail.reason ?? "—" })}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400"> - Read only. Create a draft first.</span>
|
||||
<span className="text-amber-600 dark:text-amber-400"> - {t("riskCap.readOnlyHint", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -375,13 +375,13 @@ 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">
|
||||
<h3 className="text-sm font-medium">Default Cap</h3>
|
||||
<h3 className="text-sm font-medium">{t("riskCap.defaultCap.title", { ns: "config" })}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Numbers without a special cap use this default cap template.
|
||||
{t("riskCap.defaultCap.description", { ns: "config" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="default-cap">Cap Amount (minor unit)</Label>
|
||||
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
id="default-cap"
|
||||
@@ -400,7 +400,7 @@ export function RiskCapDocScreen() {
|
||||
</div>
|
||||
{isDraft ? (
|
||||
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
|
||||
Update
|
||||
{t("riskCap.actions.update", { ns: "config" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -408,7 +408,7 @@ export function RiskCapDocScreen() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Special Caps</h3>
|
||||
<h3 className="text-sm font-medium">{t("riskCap.specialCaps.title", { ns: "config" })}</h3>
|
||||
{isDraft ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -416,25 +416,25 @@ export function RiskCapDocScreen() {
|
||||
disabled={saving}
|
||||
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
|
||||
>
|
||||
+ Add Special Cap
|
||||
{t("riskCap.actions.addSpecialCap", { ns: "config" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">Loading details…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.loadingDetails", { ns: "config" })}</p>
|
||||
) : specialRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No detail rows.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px]">Number</TableHead>
|
||||
<TableHead className="w-[140px]">Cap Amount</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Used</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Remaining</TableHead>
|
||||
<TableHead className="w-[72px] text-center">Sold Out</TableHead>
|
||||
<TableHead className="w-[160px]">Actions</TableHead>
|
||||
<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-[72px] text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -490,7 +490,7 @@ export function RiskCapDocScreen() {
|
||||
{t("actions.delete", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Read only</span>
|
||||
<span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -502,42 +502,42 @@ export function RiskCapDocScreen() {
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">All Number Occupancy</h3>
|
||||
<h3 className="text-sm font-medium">{t("riskCap.occupancy.title", { ns: "config" })}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Placeholder view: filters and exports still need ticket-summary integration. Data below still comes from the current draft list.
|
||||
{t("riskCap.occupancy.description", { ns: "config" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="occ-search">Search Number</Label>
|
||||
<Label htmlFor="occ-search">{t("riskCap.occupancy.searchLabel", { ns: "config" })}</Label>
|
||||
<Input
|
||||
id="occ-search"
|
||||
className="w-[140px] font-mono"
|
||||
placeholder="e.g. 8888"
|
||||
placeholder={t("riskCap.occupancy.searchPlaceholder", { ns: "config" })}
|
||||
value={occSearch}
|
||||
onChange={(e) => setOccSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => toast.message("Sold-out / high-risk preset filter is pending integration")}>
|
||||
Filter Presets…
|
||||
<Button type="button" variant="outline" onClick={() => toast.message(t("riskCap.occupancy.filterPending", { ns: "config" }))}>
|
||||
{t("riskCap.actions.filterPresets", { ns: "config" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => toast.message("CSV export is pending integration")}
|
||||
onClick={() => toast.message(t("riskCap.occupancy.exportPending", { ns: "config" }))}
|
||||
>
|
||||
Export CSV
|
||||
{t("riskCap.actions.exportCsv", { ns: "config" })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Number</TableHead>
|
||||
<TableHead className="text-right">Used</TableHead>
|
||||
<TableHead className="text-right">Remaining</TableHead>
|
||||
<TableHead className="text-right">Ratio</TableHead>
|
||||
<TableHead className="text-center">Sold Out</TableHead>
|
||||
<TableHead className="w-[140px]">Actions</TableHead>
|
||||
<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.soldOut", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -550,7 +550,7 @@ export function RiskCapDocScreen() {
|
||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" disabled>
|
||||
Close
|
||||
{t("riskCap.actions.close", { ns: "config" })}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -564,9 +564,9 @@ export function RiskCapDocScreen() {
|
||||
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sync Default Cap</DialogTitle>
|
||||
<DialogTitle>{t("riskCap.syncDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The default cap template will be set to {defaultCapStr || "(empty)"}. This only changes the draft. Save and publish after confirming.
|
||||
{t("riskCap.syncDialog.description", { ns: "config", value: defaultCapStr || "(empty)" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -574,7 +574,7 @@ export function RiskCapDocScreen() {
|
||||
{t("actions.cancel", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
<Button type="button" onClick={applyDefaultCap}>
|
||||
Confirm
|
||||
{t("riskCap.syncDialog.confirm", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user