- 当月充值
- {formatMoneyMinor(summary.month_recharge_usd_minor)}
- 月目标
- {formatMoneyMinor(summary.month_target_usd_minor)}
- 缺口
- {heroGapText}
// TeamKpiView:运营中心视图。kpi_view_all 用户看团队排行,普通运营看"我的充值"。
// 数据契约见 SocialBiApp.jsx 的 useSocialBi():金额为 USD 分、比例为 0-1 小数、缺失为 null(显示 "--")。
import { useMemo, useState } from "react";
import Button from "@mui/material/Button";
import { useSocialBi } from "../SocialBiApp.jsx";
import { replaceSocialBiKpiTargets } from "../../api.js";
import { appColor } from "../metrics.js";
import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js";
import { rangeLabel } from "../state.js";
import "./team-kpi-view.css";
const DAY_MS = 86400000;
const DIMENSIONS = [
{ key: "person", label: "按人员×区域" },
{ key: "operatorApp", label: "按人员×App" },
{ key: "team", label: "按部门" }
];
const SORTS = [
{ key: "rate", label: "按达成率" },
{ key: "month", label: "按当月充值" }
];
// 剩余天数:当月结束时间与当前时间的差值向上取整,最小 0。
function remainingDaysOf(monthEndMs) {
if (isBlank(monthEndMs)) {
return 0;
}
return Math.max(0, Math.ceil((Number(monthEndMs) - Date.now()) / DAY_MS));
}
// 缺口(USD 分):月目标 - 当月充值;无目标或当月充值缺失时返回 null(不可当 0 计算)。
function gapMinorOf(targetMinor, monthMinor) {
if (isBlank(targetMinor) || Number(targetMinor) <= 0 || isBlank(monthMinor)) {
return null;
}
return Number(targetMinor) - Number(monthMinor);
}
// 行内"需日均":缺口 / 剩余天数;已达成显示"已达成",算不出来显示 "--"。
function dailyNeedText(gapMinor, remainingDays) {
if (gapMinor === null) {
return "--";
}
if (gapMinor <= 0) {
return "已达成";
}
if (!remainingDays) {
return "--";
}
return formatMoneyMinor(Math.round(gapMinor / remainingDays));
}
// 可空求和:全部缺失保持 null,避免把缺失口径当 0。
function addNullable(sum, value) {
if (isBlank(value)) {
return sum;
}
return (sum ?? 0) + Number(value);
}
function numberOr(value, fallback) {
return isBlank(value) ? fallback : Number(value);
}
// 排序:按达成率(缺失排最后),并以当月充值作为次级排序;或直接按当月充值。
function compareRows(left, right, sortKey) {
if (sortKey === "rate") {
const diff = numberOr(right.rate, -1) - numberOr(left.rate, -1);
if (diff) {
return diff;
}
}
return numberOr(right.monthMinor, -1) - numberOr(left.monthMinor, -1);
}
function operatorLabelOf(item) {
return item.operator_name || item.operator_account || `#${item.operator_user_id}`;
}
function regionLabelOf(item) {
return item.region_name || item.region_code || "全部区域";
}
function usdInputText(minor) {
if (isBlank(minor)) {
return "0";
}
return String(Number(minor) / 100);
}
// 美元输入 → USD 分;空串视为 0(清除目标),NaN/负数返回 null 表示非法。
function usdInputToMinor(text) {
const numeric = Number(String(text).trim());
if (!Number.isFinite(numeric) || numeric < 0) {
return null;
}
return Math.round(numeric * 100);
}
export function TeamKpiView() {
const { appCodes, isLoading, kpi, master, range, refresh } = useSocialBi();
const [dimension, setDimension] = useState("person");
const [sortKey, setSortKey] = useState("rate");
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const items = useMemo(() => kpi?.items || [], [kpi]);
const appRows = useMemo(() => kpi?.app_rows || [], [kpi]);
const personRows = useMemo(() => {
const rows = items.map((item, index) => ({
item,
key: `${item.operator_user_id}-${item.app_code}-${item.region_id}-${index}`,
monthMinor: item.month_recharge_usd_minor,
rate: item.month_attainment_rate
}));
rows.sort((left, right) => compareRows(left, right, sortKey));
return rows;
}, [items, sortKey]);
const operatorAppRows = useMemo(() => {
const rows = (kpi?.operator_app_rows || []).map((item, index) => ({
item,
key: `${item.operator_user_id}-${item.app_code}-${index}`,
monthMinor: item.month_recharge_usd_minor,
rate: item.month_attainment_rate
}));
rows.sort((left, right) => compareRows(left, right, sortKey));
return rows;
}, [kpi, sortKey]);
const teamRows = useMemo(() => {
const groups = new Map();
items.forEach((item) => {
const teamName = item.team || "未分组";
let group = groups.get(teamName);
if (!group) {
group = { members: new Set(), monthMinor: null, rangeMinor: null, targetMinor: null, team: teamName };
groups.set(teamName, group);
}
group.members.add(item.operator_user_id);
group.rangeMinor = addNullable(group.rangeMinor, item.range_recharge_usd_minor);
group.monthMinor = addNullable(group.monthMinor, item.month_recharge_usd_minor);
group.targetMinor = addNullable(group.targetMinor, item.month_target_usd_minor);
});
const rows = [...groups.values()].map((group) => ({
memberCount: group.members.size,
monthMinor: group.monthMinor,
rangeMinor: group.rangeMinor,
rate: group.targetMinor && group.monthMinor !== null ? group.monthMinor / group.targetMinor : null,
targetMinor: group.targetMinor,
team: group.team
}));
rows.sort((left, right) => compareRows(left, right, sortKey));
return rows;
}, [items, sortKey]);
if (!items.length) {
return isLoading ?
| 名次 | 运营人员 | App | 负责区域 | 区间充值 | 当月充值 | App 月目标 | 达成率 | 需日均 |
|---|---|---|---|---|---|---|---|---|
|
|
{operatorLabelOf(item)}
{item.data_error ? (
数据异常
) : null}
{[item.operator_account, item.team || "未分组"].filter(Boolean).join(" · ")}
|
{item.app_name || item.app_code} | {item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`} | {formatMoneyMinor(item.range_recharge_usd_minor)} | {formatMoneyMinor(item.month_recharge_usd_minor)} | {formatMoneyMinor(item.month_target_usd_minor)} |
|
{dailyNeedText(gap, remainingDays)} |
| 名次 | 运营人员 | App | 区域 | 区间充值 | 当月充值 | 月目标 | 达成率 | 需日均 |
|---|---|---|---|---|---|---|---|---|
|
|
{operatorLabelOf(item)}
{item.data_error ? (
数据异常
) : null}
{[item.operator_account, item.team || "未分组"].filter(Boolean).join(" · ")}
|
{item.app_name || item.app_code} | {regionLabelOf(item)} | {formatMoneyMinor(item.range_recharge_usd_minor)} | {formatMoneyMinor(item.month_recharge_usd_minor)} | {formatMoneyMinor(item.month_target_usd_minor)} |
|
{dailyNeedText(gap, remainingDays)} |
| 名次 | 部门 | 人数 | 区间充值 | 当月充值 | 月目标 | 达成率 |
|---|---|---|---|---|---|---|
|
|
{row.team} | {formatCount(row.memberCount)} | {formatMoneyMinor(row.rangeMinor)} | {formatMoneyMinor(row.monthMinor)} | {formatMoneyMinor(row.targetMinor)} |
|