diff --git a/static/modules/performance.js b/static/modules/performance.js index 4c99471..3b24702 100644 --- a/static/modules/performance.js +++ b/static/modules/performance.js @@ -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 ` ${r.label} - -
- - -
- + ${moneyWan(target)} ${moneyWan(r.complete)} + ${rate}%
@@ -70,9 +124,27 @@ window.renderPerformance = () => { `; }).join(''); - const quarterTabs = `
` + qLabel.map((l, i) => { - return ``; - }).join('') + `
`; + // ── Header: 仿预算/已发生模块的年/季/月筛选区 ── + const yearOpts = [2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027]; + const yearSelect = `年份:`; + + const viewBtns = ``; + + const quarterBtns = qLabels.map((ql, i) => + `` + ).join(''); + + const monthBtns = activeMonths.map(m => { + const ms = planYear + '-' + String(m).padStart(2, '0'); + return ``; + }).join(''); + + const headerBar = `
+ ${yearSelect}| + ${viewBtns} + |季度:${quarterBtns} + |月度:${monthBtns} +
`; document.querySelector("#performance").innerHTML = `
- ${quarterTabs} +
${headerBar}
+ @@ -98,6 +171,7 @@ window.renderPerformance = () => { + @@ -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); } + } +}
指标 目标(万) 完成情况完成率 权重 评分
合计 100 ${totalScore.toFixed(1)}