feat(api, agents, i18n): enhance settlement features and multi-language support
Added new types and API functions for settlement period summaries and credit ledgers, improving the management of agent settlements. Updated the admin console to reflect these changes, enhancing user experience with better navigation and data presentation. Additionally, expanded multi-language support by incorporating new translations in English, Nepali, and Chinese for settlement-related terms, ensuring consistency across the platform.
This commit is contained in:
@@ -4,6 +4,7 @@ import type { ComponentType } from "react";
|
||||
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
@@ -218,19 +219,23 @@ export function AgentLineDetailPanel({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-0 overflow-x-auto border-b border-border/60 bg-card px-5 sm:px-6">
|
||||
<AdminSubnav
|
||||
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
|
||||
className="overflow-x-auto bg-card px-4 sm:px-5"
|
||||
>
|
||||
{tabs
|
||||
.filter((tab) => tab.visible)
|
||||
.map((tab) => (
|
||||
<TabButton
|
||||
<AdminSubnavButton
|
||||
key={tab.key}
|
||||
active={detailTab === tab.key}
|
||||
onClick={() => onDetailTabChange(tab.key)}
|
||||
label={tab.label}
|
||||
count={tab.count}
|
||||
/>
|
||||
>
|
||||
{tab.label}
|
||||
</AdminSubnavButton>
|
||||
))}
|
||||
</div>
|
||||
</AdminSubnav>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
|
||||
{detailTab === "overview" ? (
|
||||
@@ -569,7 +574,7 @@ function DownlineTable({
|
||||
{child.email ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{summary ? `${ratioToPercentUi(summary.total_share_rate)}%` : "—"}
|
||||
{summary ? `${summary.total_share_rate ?? 0}%` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{summary ? formatCredit(summary.credit_limit) : "—"}
|
||||
@@ -660,40 +665,3 @@ function MetricCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count?: number;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative -mb-px shrink-0 border-b-2 px-4 py-3 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:border-border hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{count !== undefined && count > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1.5 inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums",
|
||||
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { percentUiToRatio } from "@/lib/admin-rate-percent";
|
||||
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
||||
|
||||
@@ -128,19 +129,23 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
disabled={sitesLoading || unboundSites.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
sitesLoading
|
||||
? t("common:loading", { defaultValue: "加载中…" })
|
||||
: unboundSites.length === 0
|
||||
? t("agents:lineProvision.noUnboundSite", {
|
||||
defaultValue: "暂无未绑定一级代理的站点",
|
||||
})
|
||||
: t("agents:lineProvision.siteCodePlaceholder", {
|
||||
defaultValue: "选择站点",
|
||||
})
|
||||
<SelectValue>
|
||||
{(v) =>
|
||||
adminSiteCodeLabel(
|
||||
v,
|
||||
unboundSites,
|
||||
sitesLoading
|
||||
? t("common:loading", { defaultValue: "加载中…" })
|
||||
: unboundSites.length === 0
|
||||
? t("agents:lineProvision.noUnboundSite", {
|
||||
defaultValue: "暂无未绑定一级代理的站点",
|
||||
})
|
||||
: t("agents:lineProvision.siteCodePlaceholder", {
|
||||
defaultValue: "选择站点",
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unboundSites.map((site) => (
|
||||
@@ -248,7 +253,15 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{(v) =>
|
||||
v === "daily"
|
||||
? t("agents:profile.cycleDaily", { defaultValue: "日结" })
|
||||
: v === "monthly"
|
||||
? t("agents:profile.cycleMonthly", { defaultValue: "月结" })
|
||||
: t("agents:profile.cycleWeekly", { defaultValue: "周结" })
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">
|
||||
|
||||
@@ -122,7 +122,9 @@ export function AgentProfileFields({
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-share-rate`}>
|
||||
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
{parentCaps
|
||||
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %)" })
|
||||
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-share-rate`}
|
||||
@@ -133,6 +135,14 @@ export function AgentProfileFields({
|
||||
value={shareRate}
|
||||
onChange={(e) => onShareRateChange(e.target.value)}
|
||||
/>
|
||||
{parentCaps && shareRate ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profile.actualShareRate", {
|
||||
defaultValue: "实际占成 {{rate}}%",
|
||||
rate: Number((Number(parentCaps.total_share_rate) * Number(shareRate) / 100).toFixed(2)),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-credit-limit`}>
|
||||
@@ -200,7 +210,13 @@ export function AgentProfileFields({
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-settlement-cycle`}>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{settlementCycle === "daily"
|
||||
? t("profile.cycleDaily", { defaultValue: "日结" })
|
||||
: settlementCycle === "monthly"
|
||||
? t("profile.cycleMonthly", { defaultValue: "月结" })
|
||||
: t("profile.cycleWeekly", { defaultValue: "周结" })}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">{t("profile.cycleDaily", { defaultValue: "日结" })}</SelectItem>
|
||||
|
||||
@@ -160,7 +160,13 @@ export function AgentsConsole(): React.ReactElement {
|
||||
};
|
||||
|
||||
const applyProfileRowToForm = (row: AgentProfileRow) => {
|
||||
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
|
||||
const parentRate = row.parent_caps?.total_share_rate ?? 0;
|
||||
if (parentRate > 0 && row.total_share_rate != null) {
|
||||
const relative = (row.total_share_rate / parentRate) * 100;
|
||||
setProfileShareRate(percentValueToUi(relative));
|
||||
} else {
|
||||
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
|
||||
}
|
||||
setProfileCreditLimit(String(row.credit_limit ?? 0));
|
||||
setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
|
||||
setProfileDefaultRebate(ratioToPercentUi(row.default_player_rebate ?? 0));
|
||||
@@ -173,17 +179,23 @@ export function AgentsConsole(): React.ReactElement {
|
||||
setProfileRiskTags((row.risk_tags ?? []).join(", "));
|
||||
};
|
||||
|
||||
const profilePayload = () => ({
|
||||
total_share_rate: Number.parseFloat(profileShareRate) || 0,
|
||||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
||||
rebate_limit: percentUiToRatio(profileRebateLimit),
|
||||
default_player_rebate: percentUiToRatio(profileDefaultRebate),
|
||||
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
|
||||
can_grant_extra_rebate: profileExtraRebate,
|
||||
can_create_child_agent: profileCanCreateChild,
|
||||
can_create_player: profileCanCreatePlayer,
|
||||
risk_tags: parseRiskTagsInput(profileRiskTags),
|
||||
});
|
||||
const profilePayload = () => {
|
||||
const shareRate = Number.parseFloat(profileShareRate) || 0;
|
||||
const base = {
|
||||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
||||
rebate_limit: percentUiToRatio(profileRebateLimit),
|
||||
default_player_rebate: percentUiToRatio(profileDefaultRebate),
|
||||
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
|
||||
can_grant_extra_rebate: profileExtraRebate,
|
||||
can_create_child_agent: profileCanCreateChild,
|
||||
can_create_player: profileCanCreatePlayer,
|
||||
risk_tags: parseRiskTagsInput(profileRiskTags),
|
||||
};
|
||||
if (profileParentCaps) {
|
||||
return { ...base, relative_share_rate: shareRate };
|
||||
}
|
||||
return { ...base, total_share_rate: shareRate };
|
||||
};
|
||||
|
||||
const validateProfileFields = (): string | null => {
|
||||
const shareRate = Number.parseFloat(profileShareRate);
|
||||
@@ -443,15 +455,30 @@ export function AgentsConsole(): React.ReactElement {
|
||||
setNodeStatus(1);
|
||||
setNodeUsername("");
|
||||
setNodePassword("");
|
||||
resetProfileForm("create");
|
||||
setProfileLoading(false);
|
||||
setProfileLoaded(true);
|
||||
setEditingNodeNeedsPrimaryAccount(false);
|
||||
setNodeDialogOpen(true);
|
||||
if (canManageProfile) {
|
||||
void getAgentNodeProfile(node.id)
|
||||
.then((p) => setProfileParentCaps(p.parent_caps ?? null))
|
||||
.catch(() => setProfileParentCaps(null));
|
||||
.then((p) => {
|
||||
// 使用父代理自身的 caps,不是 p.parent_caps(祖父)
|
||||
setProfileParentCaps({
|
||||
agent_node_id: node.id,
|
||||
total_share_rate: p.total_share_rate ?? 100,
|
||||
rebate_limit: p.rebate_limit ?? 0,
|
||||
available_credit: p.available_credit ?? 0,
|
||||
});
|
||||
setProfileAvailableCredit(p.available_credit ?? null);
|
||||
resetProfileForm("create");
|
||||
})
|
||||
.catch(() => {
|
||||
setProfileParentCaps(null);
|
||||
setProfileAvailableCredit(null);
|
||||
resetProfileForm("create");
|
||||
});
|
||||
} else {
|
||||
resetProfileForm("create");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AdminSubnav,
|
||||
AdminSubnavBar,
|
||||
AdminSubnavLink,
|
||||
} from "@/components/admin/admin-subnav";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||
|
||||
const primaryTabs: {
|
||||
href: string;
|
||||
@@ -86,68 +89,49 @@ export function AgentsSubnav(): React.ReactElement {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
|
||||
<nav
|
||||
aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1"
|
||||
const siteSelector =
|
||||
canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||
<Select
|
||||
value={String(selectSiteId)}
|
||||
onValueChange={(value) => setAdminSiteId(Number(value))}
|
||||
>
|
||||
{visiblePrimaryTabs.map((tab) => {
|
||||
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
|
||||
<SelectTrigger className="h-9 w-[200px] bg-background">
|
||||
<SelectValue placeholder={t("lineFilter", { defaultValue: "一级代理" })}>
|
||||
{selectedSiteLabel ?? t("lineFilter", { defaultValue: "一级代理" })}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.name} ({site.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<AdminSubnavBar trailing={siteSelector}>
|
||||
<AdminSubnav aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}>
|
||||
{visiblePrimaryTabs.map((tab) => (
|
||||
<AdminSubnavLink
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
active={isTabActive(pathname, tab.href, tab.matchPrefix)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</AdminSubnavLink>
|
||||
))}
|
||||
|
||||
{showProvision ? (
|
||||
<>
|
||||
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
|
||||
<Link
|
||||
href={provisionTab.href}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(provisionTab.labelKey)}
|
||||
</Link>
|
||||
</>
|
||||
<AdminSubnavLink
|
||||
href={provisionTab.href}
|
||||
active={isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)}
|
||||
>
|
||||
{t(provisionTab.labelKey)}
|
||||
</AdminSubnavLink>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
{canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||
<Select
|
||||
value={String(selectSiteId)}
|
||||
onValueChange={(value) => setAdminSiteId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[200px] bg-background">
|
||||
<SelectValue placeholder={t("lineFilter", { defaultValue: "一级代理" })}>
|
||||
{selectedSiteLabel ?? t("lineFilter", { defaultValue: "一级代理" })}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.name} ({site.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSubnav>
|
||||
</AdminSubnavBar>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user