323 lines
10 KiB
JavaScript
323 lines
10 KiB
JavaScript
// TeamKpiView:运营中心视图。后端已按当前登录用户的数据范围裁剪,页面只展示可见运营负责范围内的充值数据。
|
||
// 金额字段为 USD 分,缺失值显示 "--",不在前端把缺失口径当作 0。
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||
import { appColor } from "../metrics.js";
|
||
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
|
||
import "./team-kpi-view.css";
|
||
|
||
const DIMENSIONS = [
|
||
{ key: "person", label: "按人员×区域" },
|
||
{ key: "operatorApp", label: "按人员×App" },
|
||
{ key: "team", label: "按部门" }
|
||
];
|
||
|
||
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 compareByMonthRecharge(left, right) {
|
||
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 operatorCountOf(items) {
|
||
return new Set(items.map((item) => item.operator_user_id).filter((id) => !isBlank(id))).size;
|
||
}
|
||
|
||
export function TeamKpiView() {
|
||
const { appCodes, isLoading, kpi } = useSocialBi();
|
||
const [dimension, setDimension] = useState("person");
|
||
|
||
const items = useMemo(() => kpi?.items || [], [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
|
||
}));
|
||
rows.sort(compareByMonthRecharge);
|
||
return rows;
|
||
}, [items]);
|
||
|
||
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
|
||
}));
|
||
rows.sort(compareByMonthRecharge);
|
||
return rows;
|
||
}, [kpi]);
|
||
|
||
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, 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);
|
||
});
|
||
const rows = [...groups.values()].map((group) => ({
|
||
memberCount: group.members.size,
|
||
monthMinor: group.monthMinor,
|
||
rangeMinor: group.rangeMinor,
|
||
team: group.team
|
||
}));
|
||
rows.sort(compareByMonthRecharge);
|
||
return rows;
|
||
}, [items]);
|
||
|
||
if (!items.length) {
|
||
return isLoading ? (
|
||
<KpiSkeleton />
|
||
) : (
|
||
<section className="sbi-card">
|
||
<div className="sbi-empty">
|
||
<strong>暂无充值数据</strong>
|
||
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const summary = kpi.summary || {};
|
||
const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count;
|
||
|
||
return (
|
||
<div className="sbi-kpi-view">
|
||
<section className="sbi-card sbi-kpi-board">
|
||
<div className="sbi-card-header">
|
||
<strong>充值排行</strong>
|
||
<small>
|
||
{kpi.period_month} · {formatCount(operatorCount)} 名运营
|
||
</small>
|
||
<div className="sbi-card-toolbar">
|
||
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
|
||
{DIMENSIONS.map((option) => (
|
||
<button
|
||
aria-checked={dimension === option.key}
|
||
className={dimension === option.key ? "is-active" : ""}
|
||
key={option.key}
|
||
onClick={() => setDimension(option.key)}
|
||
role="radio"
|
||
type="button"
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="sbi-table-scroll sbi-kpi-board-scroll">
|
||
{dimension === "person" ? (
|
||
<PersonTable appCodes={appCodes} rows={personRows} />
|
||
) : dimension === "operatorApp" ? (
|
||
<OperatorAppTable appCodes={appCodes} rows={operatorAppRows} />
|
||
) : (
|
||
<TeamTable rows={teamRows} />
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function KpiSkeleton() {
|
||
return (
|
||
<div aria-busy="true" className="sbi-kpi-view">
|
||
<section className="sbi-card">
|
||
<div className="sbi-kpi-table-skeleton">
|
||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||
<span className="sbi-skeleton" key={index} style={{ height: 18, width: "100%" }} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RankBadge({ rank }) {
|
||
const medal = rank === 1 ? " is-gold" : rank === 2 ? " is-silver" : rank === 3 ? " is-bronze" : "";
|
||
return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>;
|
||
}
|
||
|
||
function OperatorAppTable({ appCodes, rows }) {
|
||
return (
|
||
<table className="sbi-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="is-left">名次</th>
|
||
<th className="is-left">运营人员</th>
|
||
<th className="is-left">App</th>
|
||
<th className="is-left">负责区域</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="所选区间内该运营在此 App 负责范围的充值合计(USD)">
|
||
区间充值
|
||
</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="本自然月至今该运营在此 App 负责范围的充值合计(USD)">
|
||
当月充值
|
||
</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, index) => {
|
||
const item = row.item;
|
||
return (
|
||
<tr key={row.key}>
|
||
<td className="is-left">
|
||
<RankBadge rank={index + 1} />
|
||
</td>
|
||
<td className="is-left">
|
||
<OperatorCell item={item} />
|
||
</td>
|
||
<td className="is-left">
|
||
<AppCell appCodes={appCodes} item={item} />
|
||
</td>
|
||
<td className="is-left">{item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`}</td>
|
||
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
|
||
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function PersonTable({ appCodes, rows }) {
|
||
return (
|
||
<table className="sbi-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="is-left">名次</th>
|
||
<th className="is-left">运营人员</th>
|
||
<th className="is-left">App</th>
|
||
<th className="is-left">区域</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="所选区间内负责范围的充值合计(USD)">
|
||
区间充值
|
||
</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="本自然月至今负责范围的充值合计(USD)">
|
||
当月充值
|
||
</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, index) => {
|
||
const item = row.item;
|
||
return (
|
||
<tr key={row.key}>
|
||
<td className="is-left">
|
||
<RankBadge rank={index + 1} />
|
||
</td>
|
||
<td className="is-left">
|
||
<OperatorCell item={item} />
|
||
</td>
|
||
<td className="is-left">
|
||
<AppCell appCodes={appCodes} item={item} />
|
||
</td>
|
||
<td className="is-left">{regionLabelOf(item)}</td>
|
||
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
|
||
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function TeamTable({ rows }) {
|
||
return (
|
||
<table className="sbi-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="is-left">名次</th>
|
||
<th className="is-left">部门</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="部门内去重运营人数">
|
||
人数
|
||
</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="部门内区间充值合计(USD)">
|
||
区间充值
|
||
</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="部门内本月充值合计(USD)">
|
||
当月充值
|
||
</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, index) => (
|
||
<tr key={row.team}>
|
||
<td className="is-left">
|
||
<RankBadge rank={index + 1} />
|
||
</td>
|
||
<td className="is-left">{row.team}</td>
|
||
<td>{formatCount(row.memberCount)}</td>
|
||
<td>{formatMoneyMinor(row.rangeMinor)}</td>
|
||
<td>{formatMoneyMinor(row.monthMinor)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function OperatorCell({ item }) {
|
||
return (
|
||
<div className="sbi-kpi-person">
|
||
<span className="sbi-kpi-person-top">
|
||
<strong>{operatorLabelOf(item)}</strong>
|
||
{item.data_error ? (
|
||
<span className="sbi-badge is-warn" title={item.data_error}>
|
||
数据异常
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<small>{[item.operator_account, item.team || "未分组"].filter(Boolean).join(" · ")}</small>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AppCell({ appCodes, item }) {
|
||
return (
|
||
<span className="sbi-kpi-app">
|
||
<span className="sbi-kpi-app-dot" style={{ background: appColor(item.app_code, appCodes) }} />
|
||
{item.app_name || item.app_code}
|
||
</span>
|
||
);
|
||
}
|