2026-07-04 00:09:12 +08:00

884 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}