Files
lotteryAdmin/src/modules/agents/agent-profile-fields.tsx
kang a020e34a7d refactor(admin-reports, i18n): remove rebate commission report and enhance localization
Removed the `getAdminReportRebateCommission` function and its references from the admin reports API and localization files. Updated CSS for improved money display handling in admin components. Enhanced localization support by adding new finance and support workspace entries in English, Nepali, and Chinese, improving user experience across the application.
2026-06-16 16:04:03 +08:00

483 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import { cn } from "@/lib/utils";
import type { AgentParentCaps } from "@/types/api/admin-agent";
import { Info } from "lucide-react";
import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import {
AGENT_PERCENT_HARD_MAX,
actualShareRateFromRelative,
creditLimitRangeIssue,
isNumericStepperOutOfRange,
maxCreditLimitFromParent,
maxDefaultRebatePercent,
maxRebatePercentFromParent,
} from "@/lib/agent-profile-caps";
export type AgentProfileFieldsProps = {
disabled?: boolean;
loading?: boolean;
parentCaps: AgentParentCaps | null;
availableCredit: number | null;
canCreateChildAgent: boolean;
isSuperAdmin: boolean;
shareRate: string;
onShareRateChange: (value: string) => void;
creditLimit: string;
onCreditLimitChange: (value: string) => void;
rebateLimit: string;
onRebateLimitChange: (value: string) => void;
defaultRebate: string;
onDefaultRebateChange: (value: string) => void;
extraRebate: boolean;
onExtraRebateChange: (value: boolean) => void;
canCreatePlayer: boolean;
onCanCreatePlayerChange: (value: boolean) => void;
canCreateChild: boolean;
onCanCreateChildChange: (value: boolean) => void;
riskTags: string;
onRiskTagsChange: (value: string) => void;
idPrefix?: string;
currencyCode?: string;
/** 打开表单时的授信 baseline用于计算相对上级的上调空间 */
baselineCreditLimit?: number;
/** 已下发额度下限(编辑时不可低于 allocated */
minCreditLimit?: number;
/** 一级代理占成/授信/回水仅超管可改false 时整表只读) */
profileScalarsEditable?: boolean;
/** card用于代理线路详情 Tab 内的卡片表单 */
variant?: "default" | "card";
};
export function AgentProfileFields({
disabled = false,
loading = false,
parentCaps,
availableCredit,
canCreateChildAgent,
isSuperAdmin,
shareRate,
onShareRateChange,
creditLimit,
onCreditLimitChange,
rebateLimit,
onRebateLimitChange,
defaultRebate,
onDefaultRebateChange,
extraRebate,
onExtraRebateChange,
canCreatePlayer,
onCanCreatePlayerChange,
canCreateChild,
onCanCreateChildChange,
riskTags,
onRiskTagsChange,
idPrefix = "agent-profile",
currencyCode = "NPR",
baselineCreditLimit = 0,
minCreditLimit = 0,
profileScalarsEditable = true,
variant = "default",
}: AgentProfileFieldsProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const fieldDisabled = disabled || loading;
const showReadOnlyDisplay = !loading && (!profileScalarsEditable || fieldDisabled);
const isCard = variant === "card";
const maxSharePercent = AGENT_PERCENT_HARD_MAX;
const maxRebatePercent = maxRebatePercentFromParent(parentCaps);
const maxDefaultRebate = maxDefaultRebatePercent(rebateLimit, parentCaps);
const maxCreditLimit = maxCreditLimitFromParent(parentCaps, baselineCreditLimit);
const actualShare = actualShareRateFromRelative(Number.parseFloat(shareRate) || 0, parentCaps);
const creditRangeIssue = creditLimitRangeIssue(creditLimit, {
min: minCreditLimit,
max: maxCreditLimit,
});
return (
<div className="space-y-6">
{(parentCaps || availableCredit !== null) && !loading ? (
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
<div className="flex flex-col gap-1.5 min-w-0">
{parentCaps ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium leading-snug">
{t("profile.parentCaps", {
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
share: parentCaps.total_share_rate,
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})}
</p>
</div>
) : null}
{availableCredit !== null ? (
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
{t("profile.availableCredit", {
defaultValue: "可下发额度 {{amount}}",
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
})}
</p>
) : null}
</div>
</div>
) : null}
{loading ? (
<p className="text-sm text-muted-foreground animate-pulse">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
{!profileScalarsEditable && !loading ? (
<p className="rounded-lg border border-amber-200/80 bg-amber-50 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
{t("profile.lineRootScalarsReadOnlyHint", {
defaultValue:
"一级代理的占成、站点授信总额与回水由平台超管配置;如需调整请联系平台管理员。",
})}
</p>
) : null}
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor={`${idPrefix}-share-rate`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{parentCaps
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %" })
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" />
) : (
<AdminNumericStepper
id={`${idPrefix}-share-rate`}
value={shareRate}
onValueChange={onShareRateChange}
min={0}
max={maxSharePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
{parentCaps ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.relativeShareCapHint", {
defaultValue: "占上级比例最高 100%(上级总占成 {{parent}}%",
parent: parentCaps.total_share_rate,
})}
</p>
) : (
<p className="text-xs text-muted-foreground/80">
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
</p>
)}
{parentCaps && shareRate && actualShare !== null ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.actualShareRate", {
defaultValue: "实际占成 {{rate}}%",
rate: actualShare,
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label
htmlFor={`${idPrefix}-credit-limit`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} />
) : (
<AdminNumericStepper
id={`${idPrefix}-credit-limit`}
value={creditLimit}
onValueChange={onCreditLimitChange}
min={minCreditLimit}
max={maxCreditLimit}
step={1000}
integer
preserveOverMaxOnBlur
/>
)}
{parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.creditParentCapHint", {
defaultValue: "最高 {{max}}(上级可再下发 {{available}}",
max: formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode),
available: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})}
</p>
) : null}
{profileScalarsEditable && creditRangeIssue === "below_min" ? (
<p className="text-xs text-destructive">
{t("profile.validation.creditBelowAllocated", {
defaultValue: "授信额度不能低于已下发给下级/玩家的总额(当前至少 {{min}}",
min: formatAdminCreditMajorDecimal(minCreditLimit, currencyCode),
})}
</p>
) : null}
{profileScalarsEditable && creditRangeIssue === "above_max" && maxCreditLimit !== undefined ? (
<p className="text-xs text-destructive">
{t("profile.validation.creditExceedsParentWithMax", {
defaultValue: "授信额度不能超过 {{max}}",
max: formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode),
})}
</p>
) : null}
{minCreditLimit > 0 ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.creditAllocatedFloorHint", {
defaultValue: "不可低于已下发 {{amount}}",
amount: formatAdminCreditMajorDecimal(minCreditLimit, currencyCode),
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label
htmlFor={`${idPrefix}-rebate-limit`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" />
) : (
<AdminNumericStepper
id={`${idPrefix}-rebate-limit`}
value={rebateLimit}
onValueChange={onRebateLimitChange}
min={0}
max={maxRebatePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
{parentCaps ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.rebateParentCapHint", {
defaultValue: "不得超过上级回水上限 {{max}}%",
max: maxRebatePercent,
})}
</p>
) : (
<p className="text-xs text-muted-foreground/80">
{t("profile.rebateHardCapHint", { defaultValue: "回水上限最高 100%" })}
</p>
)}
</div>
<div className="space-y-2">
<Label
htmlFor={`${idPrefix}-default-rebate`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" />
) : (
<AdminNumericStepper
id={`${idPrefix}-default-rebate`}
value={defaultRebate}
onValueChange={onDefaultRebateChange}
min={0}
max={maxDefaultRebate}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
<p className="text-xs text-muted-foreground/80">
{t("profile.defaultRebateCapHint", {
defaultValue: "不得超过本节点回水上限 {{max}}%",
max: maxDefaultRebate,
})}
</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label
htmlFor={`${idPrefix}-risk-tags`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{t("profile.riskTags", { defaultValue: "风控标签" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar
id={`${idPrefix}-risk-tags`}
value={riskTags.trim() === "" ? "—" : riskTags}
className="justify-start font-normal"
/>
) : (
<Input
id={`${idPrefix}-risk-tags`}
className="h-10 bg-background/50 transition-colors focus:bg-background"
value={riskTags}
onChange={(e) => onRiskTagsChange(e.target.value)}
placeholder={t("profile.riskTagsPlaceholder", {
defaultValue: "逗号分隔,如 overdue, high_turnover",
})}
/>
)}
</div>
</div>
<div className="pt-2">
<div className="rounded-xl border border-border/80 bg-muted/20 overflow-hidden shadow-sm">
{showReadOnlyDisplay ? (
<>
<ReadOnlySwitchRow
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
checked={extraRebate}
/>
<ReadOnlySwitchRow
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
checked={canCreatePlayer}
/>
<ReadOnlySwitchRow
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
checked={canCreateChild}
isLast
/>
</>
) : (
<>
<SwitchRow
checked={extraRebate}
onCheckedChange={onExtraRebateChange}
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
/>
<SwitchRow
checked={canCreatePlayer}
onCheckedChange={onCanCreatePlayerChange}
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
/>
<SwitchRow
checked={canCreateChild}
onCheckedChange={onCanCreateChildChange}
disabled={!canCreateChildAgent && !isSuperAdmin}
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
isLast
/>
</>
)}
</div>
{!isCard ? (
<p className="mt-3 px-1 text-xs text-muted-foreground/80">
{t("profile.capabilityHint", {
defaultValue:
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
})}
</p>
) : null}
</div>
</div>
);
}
function ReadOnlyScalar({
id,
value,
suffix,
className,
}: {
id?: string;
value: string;
suffix?: string;
className?: string;
}): React.ReactElement {
return (
<div
id={id}
className={cn(
"flex min-h-10 min-w-0 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 py-2 text-center shadow-xs",
className,
)}
>
<AdminMoneyDisplay as="span" value={value} size="sm" emphasize className="text-foreground">
{value}
{suffix ? <span className="ml-0.5 font-medium text-foreground/80">{suffix}</span> : null}
</AdminMoneyDisplay>
</div>
);
}
function ReadOnlySwitchRow({
label,
checked,
isLast = false,
}: {
label: string;
checked: boolean;
isLast?: boolean;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
return (
<div
className={cn(
"flex items-center justify-between gap-4 bg-background/60 px-4 py-3.5",
!isLast && "border-b border-border/60",
)}
>
<span className="font-medium text-foreground">{label}</span>
<AdminStatusBadge tone={checked ? "success" : "neutral"}>
{checked
? t("common:status.enabled", { defaultValue: "已开启" })
: t("common:status.disabled", { defaultValue: "已关闭" })}
</AdminStatusBadge>
</div>
);
}
function SwitchRow({
checked,
onCheckedChange,
label,
disabled = false,
isLast = false,
}: {
checked: boolean;
onCheckedChange: (value: boolean) => void;
label: string;
disabled?: boolean;
isLast?: boolean;
}): React.ReactElement {
return (
<div className={cn(
"flex items-center justify-between gap-4 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30",
!isLast && "border-b border-border/50"
)}>
<Label
className={cn("font-medium", disabled ? "cursor-default text-muted-foreground" : "cursor-pointer")}
onClick={() => !disabled && onCheckedChange(!checked)}
>
{label}
</Label>
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
);
}