feat(admin, settlement, dashboard): strengthen permission gating and billing workflows
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user