290 lines
9.6 KiB
TypeScript
290 lines
9.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import { getAdminSettings, updateAdminSettingsBatch } from "@/api/admin-settings";
|
|
import { useOptionalAdminSettingsData } from "@/modules/settings/admin-settings-data-context";
|
|
import { WALLET_GROUP, WALLET_KEYS } from "@/modules/settings/settings-keys";
|
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
|
import { PRD_WALLET_ADJUST_MANAGE, PRD_WALLET_RECONCILE_MANAGE } from "@/lib/admin-prd";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
|
|
function minorUnitsToDisplay(n: unknown, decimals = 2): string {
|
|
const num = Number(n);
|
|
if (!Number.isFinite(num)) return "";
|
|
return (num / 100).toFixed(decimals);
|
|
}
|
|
|
|
function displayToMinorUnits(s: string): number | null {
|
|
const normalized = s.trim();
|
|
if (normalized === "") return null;
|
|
const n = Number(normalized);
|
|
if (!Number.isFinite(n)) return null;
|
|
return Math.round(n * 100);
|
|
}
|
|
|
|
interface Draft {
|
|
inMin: string;
|
|
inMax: string;
|
|
outMin: string;
|
|
outMax: string;
|
|
}
|
|
|
|
function draftFromKv(kv: Record<string, unknown>): Draft {
|
|
return {
|
|
inMin: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MIN] ?? 100),
|
|
inMax: minorUnitsToDisplay(kv[WALLET_KEYS.IN_MAX] ?? 0),
|
|
outMin: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MIN] ?? 100),
|
|
outMax: minorUnitsToDisplay(kv[WALLET_KEYS.OUT_MAX] ?? 0),
|
|
};
|
|
}
|
|
|
|
type WalletConfigDocScreenProps = {
|
|
embedded?: boolean;
|
|
};
|
|
|
|
function validateDraft(draft: Draft, t: ReturnType<typeof useTranslation<["config", "adminUsers", "common"]>>["t"]): string[] {
|
|
const errors: string[] = [];
|
|
const values = {
|
|
inMin: displayToMinorUnits(draft.inMin),
|
|
inMax: displayToMinorUnits(draft.inMax),
|
|
outMin: displayToMinorUnits(draft.outMin),
|
|
outMax: displayToMinorUnits(draft.outMax),
|
|
};
|
|
|
|
for (const field of ["inMin", "inMax", "outMin", "outMax"] as const) {
|
|
if (values[field] === null || values[field] < 1) {
|
|
errors.push(t("wallet.validation.amountAtLeastMinorUnit", {
|
|
ns: "config",
|
|
field: t(`wallet.fields.${field}`, { ns: "config" }),
|
|
}));
|
|
}
|
|
}
|
|
|
|
if (values.inMin !== null && values.inMax !== null && values.inMax < values.inMin) {
|
|
errors.push(t("wallet.validation.inRangeInvalid", { ns: "config" }));
|
|
}
|
|
if (values.outMin !== null && values.outMax !== null && values.outMax < values.outMin) {
|
|
errors.push(t("wallet.validation.outRangeInvalid", { ns: "config" }));
|
|
}
|
|
|
|
return [...new Set(errors)];
|
|
}
|
|
|
|
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
|
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
|
const tRef = useRef(t);
|
|
tRef.current = t;
|
|
const shared = useOptionalAdminSettingsData();
|
|
const profile = useAdminProfile();
|
|
const canManage = adminHasAnyPermission(profile?.permissions, [
|
|
PRD_WALLET_RECONCILE_MANAGE,
|
|
PRD_WALLET_ADJUST_MANAGE,
|
|
]);
|
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
|
const [draft, setDraft] = useState<Draft>({
|
|
inMin: "",
|
|
inMax: "",
|
|
outMin: "",
|
|
outMax: "",
|
|
});
|
|
const [saved, setSaved] = useState<Draft>({ inMin: "", inMax: "", outMin: "", outMax: "" });
|
|
const [standaloneLoading, setStandaloneLoading] = useState(!embedded);
|
|
const [saving, setSaving] = useState(false);
|
|
const dirty =
|
|
draft.inMin !== saved.inMin ||
|
|
draft.inMax !== saved.inMax ||
|
|
draft.outMin !== saved.outMin ||
|
|
draft.outMax !== saved.outMax;
|
|
const validationErrors = validateDraft(draft, t);
|
|
const hasValidationError = validationErrors.length > 0;
|
|
|
|
const loading = embedded ? (shared?.loading ?? true) : standaloneLoading;
|
|
|
|
const loadStandalone = useCallback(async () => {
|
|
setStandaloneLoading(true);
|
|
try {
|
|
const res = await getAdminSettings(WALLET_GROUP);
|
|
const kv: Record<string, unknown> = {};
|
|
for (const item of res.items) {
|
|
kv[item.key] = item.value;
|
|
}
|
|
const d = draftFromKv(kv);
|
|
setDraft(d);
|
|
setSaved(d);
|
|
} catch {
|
|
toast.error(tRef.current("wallet.loadFailed", { ns: "config" }));
|
|
} finally {
|
|
setStandaloneLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!embedded) {
|
|
void loadStandalone();
|
|
}
|
|
}, [embedded, loadStandalone]);
|
|
|
|
useEffect(() => {
|
|
if (!embedded || shared?.kv === null || shared?.kv === undefined) {
|
|
return;
|
|
}
|
|
const d = draftFromKv(shared.kv);
|
|
setDraft(d);
|
|
setSaved(d);
|
|
}, [embedded, shared?.kv]);
|
|
|
|
const handleChange = (field: keyof Draft, value: string) => {
|
|
setDraft((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (hasValidationError) {
|
|
toast.error(validationErrors[0]);
|
|
return;
|
|
}
|
|
|
|
const items = [];
|
|
if (draft.inMin !== saved.inMin) {
|
|
items.push({ key: WALLET_KEYS.IN_MIN, value: displayToMinorUnits(draft.inMin) });
|
|
}
|
|
if (draft.inMax !== saved.inMax) {
|
|
items.push({ key: WALLET_KEYS.IN_MAX, value: displayToMinorUnits(draft.inMax) });
|
|
}
|
|
if (draft.outMin !== saved.outMin) {
|
|
items.push({ key: WALLET_KEYS.OUT_MIN, value: displayToMinorUnits(draft.outMin) });
|
|
}
|
|
if (draft.outMax !== saved.outMax) {
|
|
items.push({ key: WALLET_KEYS.OUT_MAX, value: displayToMinorUnits(draft.outMax) });
|
|
}
|
|
if (items.length === 0) {
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
await updateAdminSettingsBatch(items);
|
|
const updates: Record<string, unknown> = {};
|
|
for (const item of items) {
|
|
updates[item.key] = item.value;
|
|
}
|
|
shared?.patchKv(updates);
|
|
toast.success(t("wallet.saveSuccess", { ns: "config" }));
|
|
setSaved(draft);
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof LotteryApiBizError ? error.message : t("wallet.saveFailed", { ns: "config" }),
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
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={!canManage || loading || saving}
|
|
aria-invalid={hasValidationError}
|
|
/>
|
|
</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={!canManage || loading || saving}
|
|
aria-invalid={hasValidationError}
|
|
/>
|
|
</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={!canManage || loading || saving}
|
|
aria-invalid={hasValidationError}
|
|
/>
|
|
</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={!canManage || loading || saving}
|
|
aria-invalid={hasValidationError}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{validationErrors.length > 0 && (
|
|
<div className="space-y-1 text-xs text-destructive" role="alert">
|
|
{validationErrors.map((error) => (
|
|
<p key={error}>{error}</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-4 pt-2">
|
|
<Button
|
|
onClick={() =>
|
|
requestConfirm({
|
|
title: t("wallet.confirmSaveTitle", { ns: "config" }),
|
|
description: t("wallet.confirmSaveDescription", { ns: "config" }),
|
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
|
onConfirm: () => handleSave(),
|
|
})
|
|
}
|
|
disabled={!canManage || !dirty || hasValidationError || loading || saving}
|
|
>
|
|
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
|
</Button>
|
|
{dirty && (
|
|
<Button variant="outline" onClick={() => setDraft(saved)} disabled={saving}>
|
|
{t("wallet.discard", { ns: "config" })}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<ConfirmDialog />
|
|
</>
|
|
);
|
|
|
|
if (embedded) {
|
|
return content;
|
|
}
|
|
|
|
return (
|
|
<ConfigDocPage title={t("wallet.title", { ns: "config" })}>{content}</ConfigDocPage>
|
|
);
|
|
}
|