884 lines
36 KiB
JavaScript
884 lines
36 KiB
JavaScript
// 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 ? <KpiSkeleton /> : (
|
||
<section className="sbi-card">
|
||
<div className="sbi-empty">
|
||
<strong>暂无充值数据</strong>
|
||
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="sbi-kpi-view">
|
||
<section className="sbi-kpi-hero">
|
||
<article className="sbi-card sbi-kpi-hero-main">
|
||
<div className="sbi-card-header">
|
||
<strong>{canViewAll ? "团队充值" : "我的充值"}</strong>
|
||
<small>本月充值目标进度 · {kpi.period_month}</small>
|
||
{!canViewAll ? <span className="sbi-badge">仅展示我负责的范围</span> : null}
|
||
</div>
|
||
<div className="sbi-kpi-hero-body">
|
||
<div
|
||
aria-label={`本月达成率 ${attainText}`}
|
||
className="sbi-kpi-ring"
|
||
role="img"
|
||
style={{ "--sbi-kpi-pct": String(ringPct) }}
|
||
>
|
||
<div className="sbi-kpi-ring-hole">
|
||
<strong className={isBlank(heroRate) ? "is-muted" : undefined}>{attainText}</strong>
|
||
<small>本月达成率</small>
|
||
</div>
|
||
</div>
|
||
<dl className="sbi-kpi-hero-stats">
|
||
<div>
|
||
<dt>当月充值</dt>
|
||
<dd>{formatMoneyMinor(summary.month_recharge_usd_minor)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>月目标</dt>
|
||
<dd>{formatMoneyMinor(summary.month_target_usd_minor)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="sbi-metric-hint" title="月目标 - 当月充值">缺口</dt>
|
||
<dd>{heroGapText}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
</article>
|
||
<article className="sbi-card sbi-kpi-mini">
|
||
<span className="sbi-kpi-mini-label">剩余天数</span>
|
||
<strong className="sbi-kpi-mini-value">{remainingDays} 天</strong>
|
||
<small className="sbi-kpi-mini-note">{kpi.period_month}</small>
|
||
</article>
|
||
<article className="sbi-card sbi-kpi-mini">
|
||
<span className="sbi-kpi-mini-label sbi-metric-hint" title="缺口 / 剩余天数">需日均</span>
|
||
<strong className="sbi-kpi-mini-value">{heroDailyNeed}</strong>
|
||
<small className="sbi-kpi-mini-note">达成月目标每日还需充值</small>
|
||
</article>
|
||
<article className="sbi-card sbi-kpi-mini">
|
||
<span className="sbi-kpi-mini-label">区间充值</span>
|
||
<strong className="sbi-kpi-mini-value">{formatMoneyMinor(summary.range_recharge_usd_minor)}</strong>
|
||
<small className="sbi-kpi-mini-note">{rangeLabel(range)}</small>
|
||
</article>
|
||
</section>
|
||
|
||
{appRows.length ? <AppTargetStrip appCodes={appCodes} appRows={appRows} remainingDays={remainingDays} /> : null}
|
||
|
||
<section className="sbi-card sbi-kpi-board">
|
||
<div className="sbi-card-header">
|
||
<strong>充值排行</strong>
|
||
<small>
|
||
{kpi.period_month} · {formatCount(summary.operator_count)} 名运营
|
||
</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 aria-label="排序方式" className="sbi-seg" role="radiogroup">
|
||
{SORTS.map((option) => (
|
||
<button
|
||
aria-checked={sortKey === option.key}
|
||
className={sortKey === option.key ? "is-active" : ""}
|
||
key={option.key}
|
||
onClick={() => setSortKey(option.key)}
|
||
role="radio"
|
||
type="button"
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{canManage ? (
|
||
<Button onClick={() => setIsDrawerOpen(true)} size="small" variant="outlined">
|
||
配置目标
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="sbi-table-scroll sbi-kpi-board-scroll">
|
||
{dimension === "person" ? (
|
||
<PersonTable appCodes={appCodes} remainingDays={remainingDays} rows={personRows} />
|
||
) : dimension === "operatorApp" ? (
|
||
<OperatorAppTable appCodes={appCodes} remainingDays={remainingDays} rows={operatorAppRows} />
|
||
) : (
|
||
<TeamTable rows={teamRows} />
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{isDrawerOpen ? (
|
||
<KpiTargetDrawer
|
||
appCodes={appCodes}
|
||
kpi={kpi}
|
||
onClose={() => setIsDrawerOpen(false)}
|
||
onSaved={() => {
|
||
setIsDrawerOpen(false);
|
||
refresh();
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function KpiSkeleton() {
|
||
return (
|
||
<div aria-busy="true" className="sbi-kpi-view">
|
||
<section className="sbi-kpi-hero">
|
||
<article className="sbi-card sbi-kpi-hero-loading">
|
||
<span className="sbi-skeleton sbi-kpi-skeleton-ring" />
|
||
<div className="sbi-kpi-skeleton-lines">
|
||
<span className="sbi-skeleton" style={{ height: 16, width: "72%" }} />
|
||
<span className="sbi-skeleton" style={{ height: 16, width: "56%" }} />
|
||
<span className="sbi-skeleton" style={{ height: 16, width: "64%" }} />
|
||
</div>
|
||
</article>
|
||
{[0, 1, 2].map((index) => (
|
||
<article className="sbi-card sbi-kpi-mini" key={index}>
|
||
<span className="sbi-skeleton" style={{ height: 12, width: 64 }} />
|
||
<span className="sbi-skeleton" style={{ height: 24, width: 112 }} />
|
||
<span className="sbi-skeleton" style={{ height: 10, width: 88 }} />
|
||
</article>
|
||
))}
|
||
</section>
|
||
<section className="sbi-card">
|
||
<div className="sbi-kpi-table-skeleton">
|
||
{[0, 1, 2, 3, 4].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>;
|
||
}
|
||
|
||
// 达成率单元格:底槽 + 按达成率填充(超 100% 封顶),数字显示真实值;null 显示"未设目标"。
|
||
function AttainmentBar({ rate }) {
|
||
if (isBlank(rate)) {
|
||
return <span className="sbi-kpi-no-target">未设目标</span>;
|
||
}
|
||
const numeric = Number(rate);
|
||
const pct = Math.max(0, Math.min(100, numeric * 100));
|
||
return (
|
||
<span className="sbi-kpi-attain">
|
||
<span aria-hidden="true" className="sbi-kpi-bar">
|
||
<span className={numeric >= 1 ? "sbi-kpi-bar-fill is-done" : "sbi-kpi-bar-fill"} style={{ width: `${pct}%` }} />
|
||
</span>
|
||
<span className="sbi-kpi-attain-value">{formatRatioPercent(rate)}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// App 总目标进度条:不分运营的全员共同目标(kpi.app_rows)。
|
||
function AppTargetStrip({ appCodes, appRows, remainingDays }) {
|
||
return (
|
||
<section aria-label="App 总目标进度" className="sbi-kpi-app-strip">
|
||
{appRows.map((row) => {
|
||
const gap = gapMinorOf(row.month_target_usd_minor, row.month_recharge_usd_minor);
|
||
return (
|
||
<article className="sbi-card sbi-kpi-app-card" key={row.app_code}>
|
||
<div className="sbi-kpi-app-card-head">
|
||
<span className="sbi-kpi-app">
|
||
<span className="sbi-kpi-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
|
||
<strong>{row.app_name || row.app_code}</strong>
|
||
</span>
|
||
<span className="sbi-badge" title="不分运营的全员共同目标">App 总目标</span>
|
||
{row.data_error ? (
|
||
<span className="sbi-badge is-warn" title={row.data_error}>
|
||
数据异常
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="sbi-kpi-app-card-amounts">
|
||
<strong>{formatMoneyMinor(row.month_recharge_usd_minor)}</strong>
|
||
<small> / 目标 {row.month_target_usd_minor > 0 ? formatMoneyMinor(row.month_target_usd_minor) : "未设"}</small>
|
||
</div>
|
||
<AttainmentBar rate={row.month_attainment_rate} />
|
||
<small className="sbi-kpi-app-card-note">
|
||
{[
|
||
`${formatCount(row.operator_count)} 名运营`,
|
||
`区间充值 ${formatMoneyMinor(row.range_recharge_usd_minor)}`,
|
||
`需日均 ${dailyNeedText(gap, remainingDays)}`
|
||
].join(" · ")}
|
||
</small>
|
||
</article>
|
||
);
|
||
})}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 人员×App 小计表:该运营在此 App 下全部负责区域的合计与其 App 级目标。
|
||
function OperatorAppTable({ appCodes, remainingDays, 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>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="该运营在此 App 的月度总目标(USD)">App 月目标</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="当月充值 / App 月目标">达成率</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="缺口 / 剩余天数">需日均</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, index) => {
|
||
const item = row.item;
|
||
const gap = gapMinorOf(item.month_target_usd_minor, item.month_recharge_usd_minor);
|
||
return (
|
||
<tr key={row.key}>
|
||
<td className="is-left">
|
||
<RankBadge rank={index + 1} />
|
||
</td>
|
||
<td className="is-left">
|
||
<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>
|
||
</td>
|
||
<td className="is-left">
|
||
<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>
|
||
</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>
|
||
<td>{formatMoneyMinor(item.month_target_usd_minor)}</td>
|
||
<td>
|
||
<AttainmentBar rate={item.month_attainment_rate} />
|
||
</td>
|
||
<td>{dailyNeedText(gap, remainingDays)}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function PersonTable({ appCodes, remainingDays, 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>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="本月充值目标(USD),由管理员配置">月目标</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="当月充值 / 月目标">达成率</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="缺口 / 剩余天数,达成月目标每日还需充值">需日均</span>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, index) => {
|
||
const item = row.item;
|
||
const gap = gapMinorOf(item.month_target_usd_minor, item.month_recharge_usd_minor);
|
||
return (
|
||
<tr key={row.key}>
|
||
<td className="is-left">
|
||
<RankBadge rank={index + 1} />
|
||
</td>
|
||
<td className="is-left">
|
||
<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>
|
||
</td>
|
||
<td className="is-left">
|
||
<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>
|
||
</td>
|
||
<td className="is-left">{regionLabelOf(item)}</td>
|
||
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
|
||
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
|
||
<td>{formatMoneyMinor(item.month_target_usd_minor)}</td>
|
||
<td>
|
||
<AttainmentBar rate={item.month_attainment_rate} />
|
||
</td>
|
||
<td>{dailyNeedText(gap, remainingDays)}</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>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="部门内月目标合计(USD)">月目标</span>
|
||
</th>
|
||
<th>
|
||
<span className="sbi-metric-hint" title="部门当月充值合计 / 月目标合计">达成率</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>
|
||
<td>{formatMoneyMinor(row.targetMinor)}</td>
|
||
<td>
|
||
<AttainmentBar rate={row.rate} />
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function draftInvalid(draft) {
|
||
return usdInputToMinor(draft.monthText) === null || usdInputToMinor(draft.dailyText) === null;
|
||
}
|
||
|
||
function TargetInputCell({ draft, label, onChange }) {
|
||
return (
|
||
<input
|
||
aria-label={label}
|
||
className={usdInputToMinor(draft.value) === null ? "sbi-kpi-input is-invalid" : "sbi-kpi-input"}
|
||
inputMode="decimal"
|
||
min="0"
|
||
onChange={onChange}
|
||
step="0.01"
|
||
type="number"
|
||
value={draft.value}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 配置目标抽屉:三级目标——
|
||
// ① 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 (
|
||
<div className="sbi-kpi-drawer-mask" onClick={onClose} role="presentation">
|
||
<aside aria-label="配置充值目标" className="sbi-kpi-drawer" onClick={(event) => event.stopPropagation()} role="dialog">
|
||
<header className="sbi-kpi-drawer-header">
|
||
<div className="sbi-kpi-drawer-title">
|
||
<strong>配置充值目标</strong>
|
||
<small>{kpi.period_month} · App 总目标 / 运营×App / 运营×App×区域 三级 · 月/日目标填 0 表示清除</small>
|
||
</div>
|
||
<button aria-label="关闭" className="sbi-kpi-drawer-close" onClick={onClose} type="button">
|
||
×
|
||
</button>
|
||
</header>
|
||
<div className="sbi-kpi-drawer-body">
|
||
{hasRows ? (
|
||
<>
|
||
{appDrafts.length ? (
|
||
<section className="sbi-kpi-drawer-section">
|
||
<h4>App 总目标 <small>不分运营的全员共同目标</small></h4>
|
||
<div className="sbi-table-scroll">
|
||
<table className="sbi-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="is-left">App</th>
|
||
<th>当月目标 ($)</th>
|
||
<th>当日目标 ($)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{appDrafts.map((draft, index) => (
|
||
<tr key={draft.appCode}>
|
||
<td className="is-left">
|
||
<span className="sbi-kpi-app">
|
||
<span className="sbi-kpi-app-dot" style={{ background: appColor(draft.appCode, appCodes) }} />
|
||
{draft.appName}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<TargetInputCell
|
||
draft={{ ...draft, value: draft.monthText }}
|
||
label={`${draft.appName} App 当月总目标(美元)`}
|
||
onChange={(event) => updateAppDraft(index, "monthText", event.target.value)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<TargetInputCell
|
||
draft={{ ...draft, value: draft.dailyText }}
|
||
label={`${draft.appName} App 当日总目标(美元)`}
|
||
onChange={(event) => updateAppDraft(index, "dailyText", event.target.value)}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
{groupDrafts.length ? (
|
||
<section className="sbi-kpi-drawer-section">
|
||
<h4>运营 × App 总目标 <small>该运营在此 App 全部负责区域的总盘子</small></h4>
|
||
<div className="sbi-table-scroll">
|
||
<table className="sbi-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="is-left">运营人员</th>
|
||
<th className="is-left">App</th>
|
||
<th className="is-left">负责区域</th>
|
||
<th>当月目标 ($)</th>
|
||
<th>当日目标 ($)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{groupDrafts.map((draft, index) => (
|
||
<tr key={`${draft.userId}-${draft.appCode}`}>
|
||
<td className="is-left">
|
||
<div className="sbi-kpi-person">
|
||
<span className="sbi-kpi-person-top">
|
||
<strong>{draft.operatorLabel}</strong>
|
||
</span>
|
||
{draft.operatorAccount ? <small>{draft.operatorAccount}</small> : null}
|
||
</div>
|
||
</td>
|
||
<td className="is-left">
|
||
<span className="sbi-kpi-app">
|
||
<span className="sbi-kpi-app-dot" style={{ background: appColor(draft.appCode, appCodes) }} />
|
||
{draft.appName}
|
||
</span>
|
||
</td>
|
||
<td className="is-left">{draft.regionNames}</td>
|
||
<td>
|
||
<TargetInputCell
|
||
draft={{ ...draft, value: draft.monthText }}
|
||
label={`${draft.operatorLabel} ${draft.appName} 当月总目标(美元)`}
|
||
onChange={(event) => updateGroupDraft(index, "monthText", event.target.value)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<TargetInputCell
|
||
draft={{ ...draft, value: draft.dailyText }}
|
||
label={`${draft.operatorLabel} ${draft.appName} 当日总目标(美元)`}
|
||
onChange={(event) => updateGroupDraft(index, "dailyText", event.target.value)}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
{regionDrafts.length ? (
|
||
<section className="sbi-kpi-drawer-section">
|
||
<h4>运营 × App × 区域 明细目标</h4>
|
||
<div className="sbi-table-scroll">
|
||
<table className="sbi-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="is-left">运营人员</th>
|
||
<th className="is-left">App</th>
|
||
<th className="is-left">区域</th>
|
||
<th>当月目标 ($)</th>
|
||
<th>当日目标 ($)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{regionDrafts.map((draft, index) => (
|
||
<tr key={`${draft.userId}-${draft.appCode}-${draft.regionId}-${index}`}>
|
||
<td className="is-left">
|
||
<div className="sbi-kpi-person">
|
||
<span className="sbi-kpi-person-top">
|
||
<strong>{draft.operatorLabel}</strong>
|
||
</span>
|
||
{draft.operatorAccount ? <small>{draft.operatorAccount}</small> : null}
|
||
</div>
|
||
</td>
|
||
<td className="is-left">
|
||
<span className="sbi-kpi-app">
|
||
<span className="sbi-kpi-app-dot" style={{ background: appColor(draft.appCode, appCodes) }} />
|
||
{draft.appName}
|
||
</span>
|
||
</td>
|
||
<td className="is-left">{draft.regionName}</td>
|
||
<td>
|
||
<TargetInputCell
|
||
draft={{ ...draft, value: draft.monthText }}
|
||
label={`${draft.operatorLabel} ${draft.appName} ${draft.regionName} 当月目标(美元)`}
|
||
onChange={(event) => updateRegionDraft(index, "monthText", event.target.value)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<TargetInputCell
|
||
draft={{ ...draft, value: draft.dailyText }}
|
||
label={`${draft.operatorLabel} ${draft.appName} ${draft.regionName} 当日目标(美元)`}
|
||
onChange={(event) => updateRegionDraft(index, "dailyText", event.target.value)}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<div className="sbi-empty">
|
||
<strong>暂无可配置的目标行</strong>
|
||
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<footer className="sbi-kpi-drawer-footer">
|
||
{saveError || hasInvalid ? (
|
||
<span className="sbi-kpi-drawer-error" role="alert">
|
||
{saveError || "目标金额需为非负数字"}
|
||
</span>
|
||
) : null}
|
||
<Button disabled={isSaving} onClick={onClose} variant="text">
|
||
取消
|
||
</Button>
|
||
<Button disabled={hasInvalid || isSaving || !hasRows} onClick={handleSave} variant="contained">
|
||
{isSaving ? "保存中…" : "保存目标"}
|
||
</Button>
|
||
</footer>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|