feat(i18n): enhance locale support for rebate settings and report exports

- Updated English, Nepali, and Chinese locale files to include new translations for the "apply rebate to payout" feature, enhancing clarity on its functionality.
- Added new export options for previewing CSV and Excel files in reports, improving user experience with clearer export capabilities.
- Enhanced internationalization support across multiple locales to ensure consistent messaging in the admin interface.
This commit is contained in:
2026-05-26 13:53:22 +08:00
parent a76b681828
commit 60271d87fb
17 changed files with 222 additions and 76 deletions

View File

@@ -25,9 +25,11 @@ export function AccountSettingsConsole() {
const [loading, setLoading] = useState(false);
useEffect(() => {
if (adminProfile) {
setNickname(adminProfile.nickname ?? "");
}
queueMicrotask(() => {
if (adminProfile) {
setNickname(adminProfile.nickname ?? "");
}
});
}, [adminProfile]);
async function handleUpdateProfile() {

View File

@@ -3,12 +3,18 @@ import { useTranslation } from "react-i18next";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { resolveAdminStatusTone } from "@/lib/admin-status-tone";
export function ConfigStatusBadge({ status }: { status: string }) {
export function ConfigStatusBadge({
status,
className,
}: {
status: string;
className?: string;
}) {
const { t } = useTranslation("config");
const label = t(`versionStatus.${status}`, { defaultValue: status });
return (
<AdminStatusBadge status={status} tone={resolveAdminStatusTone(status)}>
<AdminStatusBadge status={status} tone={resolveAdminStatusTone(status)} className={className}>
{label}
</AdminStatusBadge>
);

View File

@@ -375,7 +375,7 @@ export function OddsConfigDocScreen({
clone_from_version_id: rollbackTarget.id,
});
toast.success(
t("odds.rollbackSuccess", {
t("versionActions.rollbackSuccess", {
ns: "config",
fromVersion: rollbackTarget.version_no,
version: d.version_no,
@@ -388,7 +388,7 @@ export function OddsConfigDocScreen({
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.rollbackFailed", { ns: "config" }));
toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.rollbackFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -611,9 +611,12 @@ export function OddsConfigDocScreen({
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("odds.rollbackDialog.title", { ns: "config" })}</DialogTitle>
<DialogTitle>{t("versionActions.rollbackDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("odds.rollbackDialog.description", { ns: "config", version: rollbackTarget?.version_no ?? "—" })}
{t("versionActions.rollbackDialog.description", {
ns: "config",
version: rollbackTarget?.version_no ?? "—",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -621,7 +624,7 @@ export function OddsConfigDocScreen({
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
{t("odds.rollbackDialog.confirm", { ns: "config" })}
{t("versionActions.rollbackDialog.confirm", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -19,8 +19,10 @@ import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { getAdminSettings, updateAdminSetting } from "@/api/admin-settings";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
@@ -38,7 +40,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
import { PRD_REBATE_MANAGE } from "@/lib/admin-prd";
import { PRD_REBATE_MANAGE, PRD_WALLET_RECONCILE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -50,6 +52,9 @@ import type {
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
const SETTLEMENT_GROUP = "settlement";
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
function rateToPercentUi(rateStr: string): string {
const n = Number.parseFloat(rateStr);
if (!Number.isFinite(n)) {
@@ -108,6 +113,13 @@ export function RebateConfigDocScreen({
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
const canEditWinEnjoy = adminHasAnyPermission(profile?.permissions, [
PRD_REBATE_MANAGE,
PRD_WALLET_RECONCILE_MANAGE,
]);
const [applyRebateToPayout, setApplyRebateToPayout] = useState(false);
const [winEnjoyLoading, setWinEnjoyLoading] = useState(true);
const [winEnjoySaving, setWinEnjoySaving] = useState(false);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
@@ -147,6 +159,19 @@ export function RebateConfigDocScreen({
}
}, [t]);
const loadWinEnjoySetting = useCallback(async () => {
setWinEnjoyLoading(true);
try {
const res = await getAdminSettings(SETTLEMENT_GROUP);
const hit = res.items.find((item) => item.key === APPLY_REBATE_TO_PAYOUT_KEY);
setApplyRebateToPayout(Boolean(hit?.value));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
} finally {
setWinEnjoyLoading(false);
}
}, [t]);
useEffect(() => {
queueMicrotask(async () => {
setLoading(true);
@@ -156,6 +181,28 @@ export function RebateConfigDocScreen({
});
}, [refreshTypes, refreshList]);
useEffect(() => {
queueMicrotask(() => {
void loadWinEnjoySetting();
});
}, [loadWinEnjoySetting]);
async function handleWinEnjoyChange(checked: boolean): Promise<void> {
if (!canEditWinEnjoy) {
return;
}
setWinEnjoySaving(true);
try {
await updateAdminSetting(APPLY_REBATE_TO_PAYOUT_KEY, checked);
setApplyRebateToPayout(checked);
toast.success(t("rebate.winEnjoy.saveSuccess", { ns: "config" }));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.winEnjoy.saveFailed", { ns: "config" }));
} finally {
setWinEnjoySaving(false);
}
}
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
@@ -511,13 +558,21 @@ export function RebateConfigDocScreen({
</div>
</div>
<Alert className="border-border/80 bg-muted/30">
<AlertDescription className="text-sm leading-relaxed">
<span className="font-medium text-foreground">{t("rebate.winEnjoy.label", { ns: "config" })}</span>
{" — "}
{t("rebate.winEnjoy.pendingNote", { ns: "config" })}
</AlertDescription>
</Alert>
<div className="rounded-xl border border-border/60 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">{t("rebate.winEnjoy.label", { ns: "config" })}</p>
<p className="text-xs text-muted-foreground">{t("rebate.winEnjoy.description", { ns: "config" })}</p>
</div>
<Switch
checked={applyRebateToPayout}
disabled={winEnjoyLoading || winEnjoySaving || !canEditWinEnjoy}
aria-label={t("rebate.winEnjoy.label", { ns: "config" })}
onCheckedChange={(value) => void handleWinEnjoyChange(value)}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">{t("rebate.winEnjoy.hint", { ns: "config" })}</p>
</div>
{!embedded ? (
<div className="grid gap-1 text-sm">

View File

@@ -102,11 +102,15 @@ export function RiskCapRuntimePanel() {
}, [appliedNumber, drawId, poolFilter, t]);
useEffect(() => {
void loadDraws();
queueMicrotask(() => {
void loadDraws();
});
}, [loadDraws]);
useEffect(() => {
void loadPools();
queueMicrotask(() => {
void loadPools();
});
}, [loadPools]);
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;

View File

@@ -150,7 +150,9 @@ export function DashboardConsole(): ReactElement {
}, [i18n.language]);
useEffect(() => {
void loadPlayOptions();
queueMicrotask(() => {
void loadPlayOptions();
});
}, [loadPlayOptions]);
const load = useCallback(async (isRefresh = false) => {

View File

@@ -47,9 +47,11 @@ export function DrawCreateDialog({
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
setForm(resetFormState());
}
queueMicrotask(() => {
if (!open) {
setForm(resetFormState());
}
});
}, [open]);
async function submit(): Promise<void> {

View File

@@ -51,13 +51,15 @@ export function DrawEditDialog({
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open || draw == null) {
return;
}
setDrawTime(isoToScheduleValue(draw.draw_time));
setCloseTime(isoToScheduleValue(draw.close_time));
setStartTime(isoToScheduleValue(draw.start_time));
setDrawNo(draw.draw_no);
queueMicrotask(() => {
if (!open || draw == null) {
return;
}
setDrawTime(isoToScheduleValue(draw.draw_time));
setCloseTime(isoToScheduleValue(draw.close_time));
setStartTime(isoToScheduleValue(draw.start_time));
setDrawNo(draw.draw_no);
});
}, [open, draw]);
async function submit(): Promise<void> {

View File

@@ -58,7 +58,9 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
}, [t]);
useEffect(() => {
void loadJobs();
queueMicrotask(() => {
void loadJobs();
});
}, [loadJobs, refreshToken]);
async function handleDownload(job: AdminReportJobRow): Promise<void> {

View File

@@ -431,7 +431,9 @@ export function ReportsConsole() {
}, [i18n.language]);
useEffect(() => {
void loadPlayOptions();
queueMicrotask(() => {
void loadPlayOptions();
});
}, [loadPlayOptions]);
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
@@ -764,14 +766,18 @@ export function ReportsConsole() {
}, [canViewReports, filters, page, perPage, selectedReport, t]);
useEffect(() => {
setResult(null);
setError(null);
setPage(1);
queueMicrotask(() => {
setResult(null);
setError(null);
setPage(1);
});
}, [selectedKey]);
useEffect(() => {
if (result && result.key === selectedReport.key && selectedReport.connected) {
void queryReport();
queueMicrotask(() => {
void queryReport();
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, perPage]);
@@ -828,14 +834,10 @@ export function ReportsConsole() {
}
}
function exportReport(format: ExportFormat): void {
function exportPreview(format: ExportFormat): void {
if (!canExportReports) {
return;
}
if (usesServerExport) {
void exportViaServer(format);
return;
}
if (!result || result.rows.length === 0) {
toast.info(t("empty"));
return;
@@ -851,6 +853,17 @@ export function ReportsConsole() {
}
}
function exportReport(format: ExportFormat): void {
if (!canExportReports) {
return;
}
if (usesServerExport) {
void exportViaServer(format);
return;
}
exportPreview(format);
}
const renderSearchPicker = (kind: SearchKind) => {
const value =
kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator;
@@ -1327,39 +1340,52 @@ export function ReportsConsole() {
<div>
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
</div>
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
{usesServerExport ? (
<p className="text-xs text-muted-foreground sm:mr-2">{t("exportServerHint")}</p>
) : (
<p className="text-xs text-muted-foreground sm:mr-2">{t("exportClientHint")}</p>
)}
<div className="flex shrink-0 gap-2">
<div className="flex flex-col items-end gap-2">
<p className="text-xs text-muted-foreground">{t("exportServerHint")}</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={
!canExportReports ||
exporting !== null ||
(!usesServerExport && (!result || result.rows.length === 0))
}
disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("csv")}
>
<FileDown data-icon="inline-start" />
{usesServerExport ? t("formats.csvServer") : t("formats.csv")}
{t("formats.csvServer")}
</Button>
<Button
type="button"
disabled={
!canExportReports ||
exporting !== null ||
(!usesServerExport && (!result || result.rows.length === 0))
}
disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("excel")}
>
<FileSpreadsheet data-icon="inline-start" />
{usesServerExport ? t("formats.excelServer") : t("formats.excel")}
{t("formats.excelServer")}
</Button>
</div>
{result && result.rows.length > 0 ? (
<>
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("csv")}
>
{t("formats.csvPreview")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("excel")}
>
{t("formats.excelPreview")}
</Button>
</div>
</>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4 pt-4">

View File

@@ -29,6 +29,7 @@ const DRAW_KEYS = {
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
AUTO_APPROVE: "settlement.auto_approve_on_tick",
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
} as const;
const FRONTEND_GROUP = "frontend";
@@ -45,6 +46,7 @@ interface RuntimeDraft {
autoSettlement: boolean;
autoApprove: boolean;
autoPayout: boolean;
applyRebateToPayout: boolean;
playRulesHtmlZh: string;
playRulesHtmlEn: string;
playRulesHtmlNe: string;
@@ -92,6 +94,7 @@ export function SystemSettingsScreen() {
autoSettlement: true,
autoApprove: true,
autoPayout: true,
applyRebateToPayout: false,
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
@@ -102,6 +105,7 @@ export function SystemSettingsScreen() {
autoSettlement: true,
autoApprove: true,
autoPayout: true,
applyRebateToPayout: false,
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
@@ -131,6 +135,7 @@ export function SystemSettingsScreen() {
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
applyRebateToPayout: Boolean(kv[DRAW_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
@@ -167,6 +172,7 @@ export function SystemSettingsScreen() {
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
@@ -242,6 +248,21 @@ export function SystemSettingsScreen() {
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1 pr-4">
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
</div>
<Switch
checked={draft.applyRebateToPayout}
disabled={loading || saving}
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
onCheckedChange={(value) => updateDraft("applyRebateToPayout", value)}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid max-w-xs gap-2">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
{t("system.fields.cooldownMinutes", { ns: "config" })}