Compare commits

...

1 Commits

Author SHA1 Message Date
mac
d4fb9a0349 feat: 绩效模块重构 — 目标自动统计 + 年/季/月筛选 + 完成率列
- 目标自动从 plan_finances 季度预算统计(签约/收入/毛利/回款)
- 完成情况自动从 project_finances 统计
- 筛选区对齐已发生模块:年份下拉 | 全年 | Q1-Q4 | 月度
- 新增完成率列(完成/目标×100%),颜色分级绿/琥珀/红
- 目标列改为只读纯显示
- 支持年度、季度、月度三种视图切换
- lazy loading 加载 planFinances + projectFinances 数据
2026-07-17 17:08:56 +08:00

View File

@@ -3,35 +3,93 @@ window.renderPerformance = () => {
const data = state.data;
if (!data) return;
// 确保预算和已发生数据已加载
if (!data._perfDataLoaded) {
loadPerfData().then(() => { data._perfDataLoaded = true; renderPerformance(); });
return;
}
// ── 状态初始化 ──
if (state.planYear == null) state.planYear = new Date().getFullYear();
const planYear = state.planYear;
if (!['annual','quarterly','monthly'].includes(state.perfView)) state.perfView = 'quarterly';
if (state.perfQuarter === undefined) state.perfQuarter = Math.floor(new Date().getMonth() / 3);
const q = state.perfQuarter;
const qLabel = ['Q1 (1-3月)', 'Q2 (4-6月)', 'Q3 (7-9月)', 'Q4 (10-12月)'];
const nowD = new Date();
const activeQ = state.perfQuarter;
const qRanges = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
const qRange = qRanges[q];
const storageKey = 'opc-performance-Q' + (q + 1);
const activeMonths = qRanges[activeQ];
const qLabels = ['Q1','Q2','Q3','Q4'];
const pfs = (data.projectFinances || []);
// 当前聚合范围
let aggMonths = [];
if (state.perfView === 'annual') {
for (var mi = 1; mi <= 12; mi++) aggMonths.push(mi);
} else if (state.perfView === 'quarterly') {
aggMonths = qRanges[activeQ];
} else if (state.perfView === 'monthly') {
const m = state.perfMonth;
aggMonths = m ? [parseInt(m.substring(5))] : [];
}
const sumQ = (field) => {
// 月度选择器默认值
if (state.perfView === 'monthly' && !state.perfMonth) {
state.perfMonth = planYear + '-' + String(nowD.getMonth() + 1).padStart(2, '0');
}
// ── 数据源 ──
const pfs = (data.projectFinances || []); // 已发生 → 完成情况
const planPfs = (data.planFinances || []); // 预算 → 目标
// ── 通用聚合(支持年份过滤 + 月度范围)──
function sumByMonths(items, field, useSign, months) {
let total = 0;
pfs.forEach(p => {
try { JSON.parse(p.budget_data || '[]').forEach(b => { const m = parseInt((b.month || '').substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {}
items.forEach(p => {
if (useSign) {
const sm = p.sign_month || '';
const smY = parseInt(sm.substring(0, 4)) || 0;
const smM = parseInt(sm.substring(5)) || 0;
if (!months.includes(smM)) return;
if (planYear !== 0 && smY && smY !== planYear) return;
total += parseFloat(p.sign_amount || 0);
} else {
let bd = [];
try { bd = JSON.parse(p.budget_data || '[]'); } catch (e) {}
bd.forEach(b => {
const m = parseInt((b.month || '').substring(5)) || 0;
const y = parseInt((b.month || '').substring(0, 4)) || 0;
if (!months.includes(m)) return;
if (planYear !== 0 && y && y !== planYear) return;
total += parseFloat(b[field] || 0);
});
}
});
return Math.round(total);
};
}
const signTotal = Math.round(pfs.filter(x => { const m = parseInt((x.sign_month || '0').substring(5)) || 0; return qRange.includes(m); }).reduce((a, p) => a + (p.sign_amount || 0), 0));
const revTotal = sumQ('rev');
const grossTotal = sumQ('gross');
const paymentTotal = sumQ('payment');
// ── 计算 ──
const signActual = sumByMonths(pfs, '', true, aggMonths);
const revActual = sumByMonths(pfs, 'rev', false, aggMonths);
const grossActual = sumByMonths(pfs, 'gross', false, aggMonths);
const paymentActual = sumByMonths(pfs, 'payment', false, aggMonths);
const signPlan = sumByMonths(planPfs, '', true, aggMonths);
const revPlan = sumByMonths(planPfs, 'rev', false, aggMonths);
const grossPlan = sumByMonths(planPfs, 'gross', false, aggMonths);
const paymentPlan = sumByMonths(planPfs, 'payment', false, aggMonths);
const rows = [
{ key: 'sign', label: '签约', complete: signTotal, icon: 'file-text', defWeight: 10 },
{ key: 'rev', label: '收入', complete: revTotal, icon: 'dollar-sign', defWeight: 20 },
{ key: 'gross', label: '毛利', complete: grossTotal, icon: 'trending-up', defWeight: 50 },
{ key: 'payment', label: '回款', complete: paymentTotal, icon: 'coins', defWeight: 20 },
{ key: 'sign', label: '签约', complete: signActual, plan: signPlan, icon: 'file-text', defWeight: 10 },
{ key: 'rev', label: '收入', complete: revActual, plan: revPlan, icon: 'dollar-sign', defWeight: 20 },
{ key: 'gross', label: '毛利', complete: grossActual, plan: grossPlan, icon: 'trending-up', defWeight: 50 },
{ key: 'payment', label: '回款', complete: paymentActual, plan: paymentPlan, icon: 'coins', defWeight: 20 },
];
// 存储 key 基于当前视图模式
let storageKey = 'opc-perf-t'; // default annual
if (state.perfView === 'quarterly') storageKey = 'opc-perf-Q' + (activeQ + 1);
else if (state.perfView === 'monthly') storageKey = 'opc-perf-M' + (state.perfMonth || '');
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
function val(key, field, def) {
@@ -40,27 +98,23 @@ window.renderPerformance = () => {
}
function moneyWan(v) {
if (v >= 10000) return (v / 10000).toFixed(1) + '万';
if (Math.abs(v) >= 10000) return (v / 10000).toFixed(1) + '万';
return v.toLocaleString();
}
let totalScore = 0;
const tableRows = rows.map(r => {
const targetWan = val(r.key, 'targetWan', 0);
const target = Math.round(targetWan * 10000);
const target = r.plan;
const weight = val(r.key, 'weight', r.defWeight);
const score = target > 0 ? Math.round((r.complete / target) * weight * 10) / 10 : 0;
const rate = target > 0 ? Math.round((r.complete / target) * 100) : 0;
totalScore += score;
return `<tr class="border-b border-slate-100">
<td class="p-3 align-middle text-left font-medium text-slate-700"><i data-lucide="${r.icon}" style="width:16px;height:16px;display:inline-block;vertical-align:-3px;margin-right:6px" class="text-brand-500"></i>${r.label}</td>
<td class="p-3 align-middle text-center">
<div class="inline-flex items-center gap-1">
<input type="number" step="0.1" min="0" value="${targetWan.toFixed(1)}" class="perf-target form-ctrl form-ctrl-sm w-20 text-center" style="appearance:none;-webkit-appearance:none" data-key="${r.key}" onchange="updatePerfScore('${r.key}','targetWan',this.value)">
<span class="text-xs text-slate-400">万</span>
</div>
</td>
<td class="p-3 align-middle text-center text-slate-700 font-mono text-sm">${moneyWan(target)}</td>
<td class="p-3 align-middle text-center text-slate-700 font-mono text-sm">${moneyWan(r.complete)}</td>
<td class="p-3 align-middle text-center font-semibold text-sm ${rate >= 100 ? 'text-green-600' : rate >= 50 ? 'text-amber-600' : 'text-red-500'}">${rate}%</td>
<td class="p-3 align-middle text-center">
<div class="inline-flex justify-center">
<input type="number" step="0.1" min="0" max="100" value="${weight}" class="perf-weight form-ctrl form-ctrl-sm w-16 text-center" style="appearance:none;-webkit-appearance:none" data-key="${r.key}" onchange="updatePerfScore('${r.key}','weight',this.value)">
@@ -70,9 +124,27 @@ window.renderPerformance = () => {
</tr>`;
}).join('');
const quarterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0">` + qLabel.map((l, i) => {
return `<button onclick="switchPerfQuarter(${i})" class="finance-tab${i === q ? ' active' : ''}" style="font-size:14px;font-weight:600">${l}</button>`;
}).join('') + `</div>`;
// ── Header: 仿预算/已发生模块的年/季/月筛选区 ──
const yearOpts = [2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027];
const yearSelect = `<span class="text-sm text-slate-500">年份:</span><select onchange="setPerfYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${planYear===0?' selected':''}>所有年份</option>${yearOpts.map(y => `<option value="${y}" ${planYear===y?'selected':''}>${y}年</option>`).join('')}</select>`;
const viewBtns = `<button onclick="setPerfView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.perfView==='annual'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全年</button>`;
const quarterBtns = qLabels.map((ql, i) =>
`<button onclick="setPerfQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.perfView==='quarterly'&&activeQ===i?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${ql}</button>`
).join('');
const monthBtns = activeMonths.map(m => {
const ms = planYear + '-' + String(m).padStart(2, '0');
return `<button onclick="setPerfMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.perfView==='monthly'&&state.perfMonth===ms?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`;
}).join('');
const headerBar = `<div class="flex items-center gap-2 flex-wrap">
${yearSelect}<span class="text-slate-300 mx-1">|</span>
${viewBtns}
<span class="text-slate-300 mx-1">|</span><span class="text-sm text-slate-500">季度:</span>${quarterBtns}
<span class="text-slate-300 mx-1">|</span><span class="text-sm text-slate-500">月度:</span>${monthBtns}
</div>`;
document.querySelector("#performance").innerHTML = `
<style>
@@ -83,13 +155,14 @@ window.renderPerformance = () => {
.perf-target, .perf-weight { -moz-appearance: textfield; }
</style>
<div class="grid gap-4">
${quarterTabs}
<div class="card py-2 px-4">${headerBar}</div>
<section class="card p-4">
<table class="w-full text-sm">
<thead><tr class="bg-slate-50 border-b border-slate-200">
<th class="p-3 text-left font-semibold text-slate-500">指标</th>
<th class="p-3 text-center font-semibold text-slate-500">目标(万)</th>
<th class="p-3 text-center font-semibold text-slate-500">完成情况</th>
<th class="p-3 text-center font-semibold text-slate-500">完成率</th>
<th class="p-3 text-center font-semibold text-slate-500">权重</th>
<th class="p-3 text-center font-semibold text-slate-500">评分</th>
</tr></thead>
@@ -98,6 +171,7 @@ window.renderPerformance = () => {
<td class="p-3 text-left text-slate-700">合计</td>
<td class="p-3 text-center text-slate-500">—</td>
<td class="p-3 text-center text-slate-500">—</td>
<td class="p-3 text-center text-slate-500">—</td>
<td class="p-3 text-center text-slate-700">100</td>
<td class="p-3 text-center text-brand-700 text-base">${totalScore.toFixed(1)}</td>
</tr></tfoot>
@@ -108,17 +182,47 @@ window.renderPerformance = () => {
if (window.lucide) window.lucide.createIcons();
};
window.switchPerfQuarter = (q) => {
// ── 筛选切换函数 ──
window.setPerfYear = (y) => {
state.planYear = parseInt(y);
renderPerformance();
};
window.setPerfView = (v) => {
state.perfView = v;
renderPerformance();
};
window.setPerfQuarter = (q) => {
state.perfView = 'quarterly';
state.perfQuarter = q;
renderPerformance();
};
window.setPerfMonth = (m) => {
state.perfView = 'monthly';
state.perfMonth = m;
renderPerformance();
};
// ── 编辑保存 ──
window.updatePerfScore = (key, field, value) => {
const q = state.perfQuarter || 0;
const storageKey = 'opc-performance-Q' + (q + 1);
let storageKey = 'opc-perf-t';
if (state.perfView === 'quarterly') storageKey = 'opc-perf-Q' + ((state.perfQuarter || 0) + 1);
else if (state.perfView === 'monthly') storageKey = 'opc-perf-M' + (state.perfMonth || '');
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
saved[key] = saved[key] || {};
saved[key][field] = value;
localStorage.setItem(storageKey, JSON.stringify(saved));
renderPerformance();
};
// ── 数据加载 ──
async function loadPerfData() {
for (var i = 0; i < 2; i++) {
var r = i === 0 ? 'planFinances' : 'projectFinances';
try {
state.data[r] = await api("/api/" + r + "/list?tenant=" + encodeURIComponent(state.tenant));
} catch(e) { console.error('loadPerfData failed:', r, e.message); }
}
}