feat(admin, settlement, dashboard): strengthen permission gating and billing workflows

This commit is contained in:
2026-06-09 13:44:19 +08:00
parent 7e65c53732
commit b7278e68a4
41 changed files with 900 additions and 199 deletions

View File

@@ -48,7 +48,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
@@ -560,7 +560,7 @@ export function PlayConfigDocScreen() {
</div>
<div className="flex flex-col gap-1.5 md:w-[140px]">
<span className="text-sm font-medium">{t("play.filters.category", { ns: "config" })}</span>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
<SelectTrigger className="h-8">
<SelectValue>
{categoryFilter === "all"

View File

@@ -12,6 +12,9 @@ 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 {
@@ -20,9 +23,11 @@ function minorUnitsToDisplay(n: unknown, decimals = 2): string {
return (num / 100).toFixed(decimals);
}
function displayToMinorUnits(s: string): number {
const n = parseFloat(s);
if (Number.isNaN(n) || n < 0) return 0;
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);
}
@@ -46,11 +51,44 @@ 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: "",
@@ -66,6 +104,8 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
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;
@@ -107,6 +147,11 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
};
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) });
@@ -156,7 +201,8 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.inMin}
onChange={(e) => handleChange("inMin", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
aria-invalid={hasValidationError}
/>
</div>
<div className="space-y-2">
@@ -169,7 +215,8 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.inMax}
onChange={(e) => handleChange("inMax", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
aria-invalid={hasValidationError}
/>
</div>
<div className="space-y-2">
@@ -182,7 +229,8 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.outMin}
onChange={(e) => handleChange("outMin", e.target.value)}
disabled={loading || saving}
disabled={!canManage || loading || saving}
aria-invalid={hasValidationError}
/>
</div>
<div className="space-y-2">
@@ -195,10 +243,18 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.outMax}
onChange={(e) => handleChange("outMax", e.target.value)}
disabled={loading || saving}
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={() =>
@@ -209,7 +265,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
onConfirm: () => handleSave(),
})
}
disabled={!dirty || loading || saving}
disabled={!canManage || !dirty || hasValidationError || loading || saving}
>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>