// 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/区域数据范围
); } const summary = kpi.summary || {}; const canViewAll = master?.permissions?.kpi_view_all !== false; const canManage = Boolean(master?.permissions?.kpi_manage); const remainingDays = remainingDaysOf(kpi.month_end_ms); const heroRate = summary.month_attainment_rate; const attainText = isBlank(heroRate) ? "未设目标" : formatRatioPercent(heroRate); const ringPct = isBlank(heroRate) ? 0 : Math.max(0, Math.min(100, Number(heroRate) * 100)); const heroGap = gapMinorOf(summary.month_target_usd_minor, summary.month_recharge_usd_minor); const heroGapText = heroGap === null ? "--" : heroGap <= 0 ? "已达成" : formatMoneyMinor(heroGap); const heroDailyNeed = heroGap !== null && heroGap > 0 && remainingDays > 0 ? formatMoneyMinor(Math.round(heroGap / remainingDays)) : "--"; return (
{canViewAll ? "团队充值" : "我的充值"} 本月充值目标进度 · {kpi.period_month} {!canViewAll ? 仅展示我负责的范围 : null}
{attainText} 本月达成率
当月充值
{formatMoneyMinor(summary.month_recharge_usd_minor)}
月目标
{formatMoneyMinor(summary.month_target_usd_minor)}
缺口
{heroGapText}
剩余天数 {remainingDays} 天 {kpi.period_month}
需日均 {heroDailyNeed} 达成月目标每日还需充值
区间充值 {formatMoneyMinor(summary.range_recharge_usd_minor)} {rangeLabel(range)}
{appRows.length ? : null}
充值排行 {kpi.period_month} · {formatCount(summary.operator_count)} 名运营
{DIMENSIONS.map((option) => ( ))}
{SORTS.map((option) => ( ))}
{canManage ? ( ) : null}
{dimension === "person" ? ( ) : dimension === "operatorApp" ? ( ) : ( )}
{isDrawerOpen ? ( setIsDrawerOpen(false)} onSaved={() => { setIsDrawerOpen(false); refresh(); }} /> ) : null}
); } function KpiSkeleton() { return (
{[0, 1, 2].map((index) => (
))}
{[0, 1, 2, 3, 4].map((index) => ( ))}
); } function RankBadge({ rank }) { const medal = rank === 1 ? " is-gold" : rank === 2 ? " is-silver" : rank === 3 ? " is-bronze" : ""; return {rank}; } // 达成率单元格:底槽 + 按达成率填充(超 100% 封顶),数字显示真实值;null 显示"未设目标"。 function AttainmentBar({ rate }) { if (isBlank(rate)) { return 未设目标; } const numeric = Number(rate); const pct = Math.max(0, Math.min(100, numeric * 100)); return ( ); } // App 总目标进度条:不分运营的全员共同目标(kpi.app_rows)。 function AppTargetStrip({ appCodes, appRows, remainingDays }) { return (
{appRows.map((row) => { const gap = gapMinorOf(row.month_target_usd_minor, row.month_recharge_usd_minor); return (
{row.app_name || row.app_code} App 总目标 {row.data_error ? ( 数据异常 ) : null}
{formatMoneyMinor(row.month_recharge_usd_minor)} / 目标 {row.month_target_usd_minor > 0 ? formatMoneyMinor(row.month_target_usd_minor) : "未设"}
{[ `${formatCount(row.operator_count)} 名运营`, `区间充值 ${formatMoneyMinor(row.range_recharge_usd_minor)}`, `需日均 ${dailyNeedText(gap, remainingDays)}` ].join(" · ")}
); })}
); } // 人员×App 小计表:该运营在此 App 下全部负责区域的合计与其 App 级目标。 function OperatorAppTable({ appCodes, remainingDays, rows }) { return ( {rows.map((row, index) => { const item = row.item; const gap = gapMinorOf(item.month_target_usd_minor, item.month_recharge_usd_minor); return ( ); })}
名次 运营人员 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)}
); } function PersonTable({ appCodes, remainingDays, rows }) { return ( {rows.map((row, index) => { const item = row.item; const gap = gapMinorOf(item.month_target_usd_minor, item.month_recharge_usd_minor); return ( ); })}
名次 运营人员 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)}
); } function TeamTable({ rows }) { return ( {rows.map((row, index) => ( ))}
名次 部门 人数 区间充值 当月充值 月目标 达成率
{row.team} {formatCount(row.memberCount)} {formatMoneyMinor(row.rangeMinor)} {formatMoneyMinor(row.monthMinor)} {formatMoneyMinor(row.targetMinor)}
); } function draftInvalid(draft) { return usdInputToMinor(draft.monthText) === null || usdInputToMinor(draft.dailyText) === null; } function TargetInputCell({ draft, label, onChange }) { return ( ); } // 配置目标抽屉:三级目标—— // ① App 总目标(不分运营的全员共同目标) ② 运营×App 总目标(该运营在此 App 的总盘子) // ③ 运营×App×区域 明细目标。月/日目标同时填 0 表示清除该条。 function KpiTargetDrawer({ appCodes, kpi, onClose, onSaved }) { const [appDrafts, setAppDrafts] = useState(() => (kpi.app_rows || []).map((row) => ({ appCode: row.app_code, appName: row.app_name || row.app_code, dailyText: usdInputText(row.daily_target_usd_minor), monthText: usdInputText(row.month_target_usd_minor) })) ); // 整 App 范围(region 0)的运营在明细区已有"全部区域"行(同一存储键),此处排除避免同键两处编辑。 const [groupDrafts, setGroupDrafts] = useState(() => (kpi.operator_app_rows || []) .filter((row) => !row.whole_app_scope) .map((row) => ({ appCode: row.app_code, appName: row.app_name || row.app_code, dailyText: usdInputText(row.daily_target_usd_minor), monthText: usdInputText(row.month_target_usd_minor), operatorAccount: row.operator_account || "", operatorLabel: row.operator_name || row.operator_account || `#${row.operator_user_id}`, regionNames: row.region_names || "", userId: row.operator_user_id })) ); const [regionDrafts, setRegionDrafts] = useState(() => (kpi.items || []).map((item) => ({ appCode: item.app_code, appName: item.app_name || item.app_code, dailyText: usdInputText(item.daily_target_usd_minor), monthText: usdInputText(item.month_target_usd_minor), operatorAccount: item.operator_account || "", operatorLabel: operatorLabelOf(item), regionId: Number(item.region_id ?? 0), regionName: regionLabelOf(item), userId: item.operator_user_id })) ); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(""); const hasInvalid = [...appDrafts, ...groupDrafts, ...regionDrafts].some(draftInvalid); const hasRows = appDrafts.length > 0 || groupDrafts.length > 0 || regionDrafts.length > 0; const updateAt = (setDrafts) => (index, key, value) => { setDrafts((current) => current.map((draft, draftIndex) => (draftIndex === index ? { ...draft, [key]: value } : draft))); }; const updateAppDraft = updateAt(setAppDrafts); const updateGroupDraft = updateAt(setGroupDrafts); const updateRegionDraft = updateAt(setRegionDrafts); const handleSave = async () => { const items = [ ...groupDrafts.map((draft) => ({ appCode: draft.appCode, dailyTargetUsdMinor: usdInputToMinor(draft.dailyText), periodMonth: kpi.period_month, regionId: 0, targetUsdMinor: usdInputToMinor(draft.monthText), userId: draft.userId })), ...regionDrafts.map((draft) => ({ appCode: draft.appCode, dailyTargetUsdMinor: usdInputToMinor(draft.dailyText), periodMonth: kpi.period_month, regionId: draft.regionId, targetUsdMinor: usdInputToMinor(draft.monthText), userId: draft.userId })) ]; const appItems = appDrafts.map((draft) => ({ appCode: draft.appCode, dailyTargetUsdMinor: usdInputToMinor(draft.dailyText), periodMonth: kpi.period_month, targetUsdMinor: usdInputToMinor(draft.monthText) })); setIsSaving(true); setSaveError(""); try { await replaceSocialBiKpiTargets({ appItems, items }); onSaved(); } catch (error) { setSaveError(error?.message || "保存失败,请稍后重试"); setIsSaving(false); } }; return (
); }