Files
opc-manager/static/modules/plan.js
mac a57cba8043 refactor: 彻底删除计划模块status字段+新增蓝色tip
- 数据库: DROP plan_finances.status 列(幂等迁移)
- 后端: routes.py TABLES移除status, migrate用显式列名
- 前端: plan.js移除表单/表格/过滤/汇总所有status引用
- planFilter值从待签约改为projects
- 计划页面顶部新增蓝色tip提醒条
- 版本号 v1.0.0.22
2026-07-08 13:21:54 +08:00

1052 lines
74 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.
// plan.js — 计划管理模块
console.log('plan.js loaded');
function renderPlan() {
const pfs = state.data.planFinances || [];
const ops = state.data.operations || [];
const fmTypesByTenant = {
"科普·无界": ["科普音频","科普视频","科普文章","科普专访","患教会","全品类科普","调研问卷"],
"科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"],
"医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"],
};
const fmTypes = fmTypesByTenant[state.tenant] || fmTypesByTenant["科普·无界"];
const tenantOps = (state.data.operations || []).filter(o => (o.project_name || "").includes(state.tenant.replace("·无界","")) || o.tenant === state.tenant);
const now = new Date();
const thisMonth = now.getMonth() + 1;
const displayMonths = [];
for (let i = 0; i < 4; i++) {
const m = thisMonth + i;
const mm = m > 12 ? m - 12 : m;
displayMonths.push({ key: "2026_" + String(mm).padStart(2, "0"), label: mm + "月" });
}
const months = displayMonths.map(d => d.key);
const monthLabels = displayMonths.map(d => d.label);
const signed = pfs;
const pending = pfs;
const sumSign = Math.round(signed.reduce((s,x) => s + (x.sign_amount||0), 0));
const sumPending = Math.round(pending.reduce((s,x) => s + (x.sign_amount||0), 0));
const monthRev = months.map(m => {
return signed.reduce((s, pf) => {
let budget = [];
try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
const row = budget.find(b => (b.month || "").replace("-", "_") === m);
return s + (row ? (parseFloat(row.rev) || 0) : 0);
}, 0);
});
const monthGross = months.map(m => {
return signed.reduce((s, pf) => {
let budget = [];
try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
const row = budget.find(b => (b.month || "").replace("-", "_") === m);
return s + (row ? (parseFloat(row.gross) || 0) : 0);
}, 0);
});
const thisMonthKey = displayMonths[0].key;
const thisMonthRev = monthRev[0];
const thisMonthGross = monthGross[0];
let monthPayment = 0, monthCost = 0;
for (const pf of pfs) {
let budget = [];
try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
for (const b of budget) {
const bKey = (b.month || "").replace("-", "_");
if (bKey === thisMonthKey) {
monthPayment += parseFloat(b.payment || 0);
monthCost += parseFloat(b.cost || 0);
break;
}
}
}
monthPayment = Math.round(monthPayment);
monthCost = Math.round(monthCost);
const monthCashflow = monthPayment - monthCost;
const renderPfRow = (pf) => {
let budgetMap = {};
try {
const budget = JSON.parse(pf.budget_data || "[]");
budget.forEach(b => { budgetMap[(b.month || "").replace("-", "_")] = b; });
} catch (e) {}
const isRevView = state.planView !== "cashflow" && state.planView !== "overview" && state.planView !== "monthly" && state.planView !== "quarterly";
const mCols = months.map(m => {
const b = budgetMap[m] || {};
if (isRevView) {
const rev = b.rev || 0;
const gross = b.gross || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
} else {
const payment = b.payment || 0;
const cost = b.cost || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
}
}).join("");
const totalCol = (() => {
if (isRevView) {
const totalRev = pf.total_rev || 0;
const totalGross = pf.total_gross || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
} else {
let totalPayment = 0, totalCost = 0;
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
}
})();
const sm = pf.sign_month || "";
const signMonthCell = `<td class="p-2 text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="p-2 text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
};
const now2 = new Date();
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
if (!state.planMonth) state.planMonth = defaultMonth2;
if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3);
const monthSet2 = new Set([defaultMonth2]);
pfs.forEach(pf => { let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {} bd.forEach(b => { if (b.month) monthSet2.add(b.month); }); });
const allMonths = [...monthSet2].sort().reverse();
const qLabels2 = ["Q1 (1-3月)","Q2 (4-6月)","Q3 (7-9月)","Q4 (10-12月)"];
const toolMonthSelect = allMonths.map(m => '<option value="'+m+'" '+(m===state.planMonth?'selected':'')+'>'+m+'</option>').join("");
const toolQuarterSelect = qLabels2.map((l,i) => '<option value="'+i+'" '+(i===state.planQuarter?'selected':'')+'>'+l+'</option>').join("");
const toolDateSelect = state.planView==='monthly'?'<span class="text-sm text-slate-500 ml-2">月份:</span><select onchange="state.planMonth=this.value;renderPlan()" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">'+toolMonthSelect+'</select>':state.planView==='quarterly'?'<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="state.planQuarter=parseInt(this.value);localStorage.setItem(\'opc-fin-quarter\',this.value);renderPlan()" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">'+toolQuarterSelect+'</select>':'';
const finHeaderBase = `<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="overview" ${state.planView==='overview'?'selected':''}>总视图</option><option value="quarterly" ${state.planView==='quarterly'?'selected':''}>季度视图</option><option value="monthly" ${state.planView==='monthly'?'selected':''}>月度视图</option></select>${toolDateSelect}`;
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">总览</button><button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目计划 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用计划</button></div>`;
document.querySelector("#plan").innerHTML = `<div class="grid gap-4">
<div class="flex items-center gap-3 px-4 py-3 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
<i data-lucide="lightbulb" class="text-blue-600 flex-shrink-0" style="width:18px;height:18px"></i>
<span class="text-sm text-blue-900"><strong>计划提醒:</strong>计划也是预算,只有真正能够列出计划的预算,才有靠谱的落地可能性</span>
</div>
${planFilterTabs}
${state.planFilter !== 'expense' && state.planFilter !== 'overview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "p-4") : ''}
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchPlanTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
<button class="finance-tab" data-tab="revpay" onclick="switchPlanTab('revpay')"><i data-lucide="dollar-sign" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>确收与回款</button>
<button class="finance-tab" data-tab="cost" onclick="switchPlanTab('cost')"><i data-lucide="receipt" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>费用明细</button>
<button class="finance-tab" data-tab="exec" onclick="switchPlanTab('exec')"><i data-lucide="play-circle" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>执行信息</button>
<button class="finance-tab" data-tab="tasks" onclick="switchPlanTab('tasks')"><i data-lucide="list-checks" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>任务管理</button>
<button class="finance-tab" data-tab="activity" onclick="switchPlanTab('activity')"><i data-lucide="message-square" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>活动与跟进</button>
</div>
<form onsubmit="createPlanFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="plan_pf-id-input" value="">
<div class="finance-tab-body">
<div id="plan_financeTabInfo">
<div class="grid gap-4">
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如KP-2026-001"></label>
<label class="block"><span class="fin-label">项目名称 <span class="text-red-500">*</span></span><input name="customer_name" required class="form-ctrl" placeholder="请输入项目名称"></label>
</div>
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
</div>
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">商务负责人 <span class="text-red-500">*</span></span><input name="sales_person" required class="form-ctrl" placeholder="请输入商务负责人"></label>
<label class="block"><span class="fin-label">经营负责人 <span class="text-red-500">*</span></span><input name="owner" required class="form-ctrl" placeholder="请输入经营负责人"></label>
<label class="block"><span class="fin-label">业务联系人</span><input name="contact_name" class="form-ctrl" placeholder="请输入业务联系人"></label>
</div>
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">联系电话</span><input name="contact_phone" class="form-ctrl" placeholder="请输入联系电话"></label>
<label class="block"><span class="fin-label">其他</span><input name="other_info" class="form-ctrl" placeholder="备注信息"></label>
</div>
<div class="block"><span class="fin-label">业务类型</span><div class="flex flex-wrap gap-2 mt-1" id="plan_businessTypeChecks">${fmTypes.map(t => `<label class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-slate-200 cursor-pointer hover:border-blue-300 text-sm bt-chip" onclick="planToggleBtChip(this)"><input type="checkbox" name="business_type[]" value="${t}" class="hidden"><span>${t}</span></label>`).join("")}</div></div>
</div>
</div>
<div id="plan_financeTabExec" class="hidden">
<div class="fin-field-group">
<p class="fin-section-label">执行信息</p>
<div class="grid grid-cols-3 gap-4">
<label class="block"><span class="fin-label">开始时间</span><input name="start_date" type="date" class="form-ctrl"></label>
<label class="block"><span class="fin-label">结束时间</span><input name="end_date" type="date" class="form-ctrl"></label>
<label class="block"><span class="fin-label">项目经理</span><input name="project_manager" class="form-ctrl" placeholder="请输入项目经理"></label>
<label class="block"><span class="fin-label">合同服务费标准</span><select name="service_fee_standard" class="form-ctrl bg-white">${Array.from({length:21},(_,i)=>{const v=i+5;return `<option value="${v}" ${v===5?'selected':''}>${v}%</option>`}).join("")}</select></label>
</div>
</div>
</div>
<div id="plan_financeTabRevpay" class="hidden">
<div class="grid grid-cols-3 gap-3 mb-4" id="plan_revpaySummary">
<div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100">
<p class="text-xs text-blue-600 font-medium">总确收</p>
<p class="text-lg font-bold text-blue-700" id="plan_revpayTotalRev">¥0</p>
</div>
<div class="bg-green-50 rounded-lg p-3 text-center border border-green-100">
<p class="text-xs text-green-600 font-medium">总毛利</p>
<p class="text-lg font-bold text-green-700" id="plan_revpayTotalGross">¥0</p>
</div>
<div class="bg-amber-50 rounded-lg p-3 text-center border border-amber-100">
<p class="text-xs text-amber-600 font-medium">总回款</p>
<p class="text-lg font-bold text-amber-700" id="plan_revpayTotalPayment">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_revpayTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="plan_revpayTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="planAddRevpayRow()"><i data-lucide="plus"></i></button>
</div>
<div id="plan_financeTabCost" class="hidden">
<div class="grid grid-cols-2 gap-3 mb-4" id="plan_costSummary">
<div class="bg-rose-50 rounded-lg p-3 text-center border border-rose-100">
<p class="text-xs text-rose-600 font-medium">总成本</p>
<p class="text-lg font-bold text-rose-700" id="plan_costTotalCost">¥0</p>
</div>
<div class="bg-purple-50 rounded-lg p-3 text-center border border-purple-100">
<p class="text-xs text-purple-600 font-medium">总已付</p>
<p class="text-lg font-bold text-purple-700" id="plan_costTotalPaid">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_costTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="plan_costTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="planAddCostRow()"><i data-lucide="plus"></i></button>
</div>
<div id="plan_financeTabTasks" class="hidden">
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_taskTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="plan_taskTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="planAddTaskRow()"><i data-lucide="plus"></i></button>
</div>
<div id="plan_financeTabActivity" class="hidden">
<div class="grid gap-2" id="plan_finActivityList"></div>
<div class="comment-box mt-3">
<div class="squire-toolbar">
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('italic')" title="斜体"><i data-lucide="italic"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('underline')" title="下划线"><i data-lucide="underline"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('strikethrough')" title="删除线"><i data-lucide="strikethrough"></i></button>
<span class="squire-sep"></span>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeUnorderedList')" title="无序列表"><i data-lucide="list"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeOrderedList')" title="有序列表"><i data-lucide="list-ordered"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('blockquote')" title="引用"><i data-lucide="quote"></i></button>
<span class="squire-sep"></span>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('undo')" title="撤销"><i data-lucide="undo"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('redo')" title="重做"><i data-lucide="redo"></i></button>
</div>
<div class="squire-editor" id="plan_squire_finance" placeholder="添加评论"></div>
<div class="comment-toolbar">
<span class="comment-hint">支持富文本编辑</span>
<button class="btn btn-primary btn-sm comment-submit" type="button" onclick="planSubmitComment()">评论</button>
</div>
</div>
</div>
</div><div class="flex justify-end gap-3 pt-2 finance-form-actions"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closePlanModal()"></button><button type="submit" class="btn btn-primary btn-sm px-8"></button></div></form></div></div>
${(() => {
if (state.planFilter === 'overview') {
setTimeout(() => renderPlanOverview(), 10);
return `<div id="planOverview"></div>`;
}
if (state.planFilter === 'expense') {
setTimeout(() => renderPlanExpense(), 10);
return `<div id="planExpenseModule"></div>`;
}
const isUnsigned = false;
const METRIC_CARDS = [
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
{ label: '毛利', value: ctx => money(ctx.sumGross), color: 'text-green-700' },
{ label: '成本', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '项目利润', value: ctx => { const v = ctx.sumGross; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '已回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
{ label: '已付', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '执行率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
].filter(c => true);
// 列排序
if (!state.planSort) state.planSort = { key: null, asc: true };
const sortData = (data, key) => {
if (!key || !state.planSort.key) return data;
var asc = state.planSort.asc ? 1 : -1;
return data.slice().sort(function(a, b) {
var va = a[key], vb = b[key];
if (typeof va === 'string') va = va || '', vb = vb || '';
else { va = parseFloat(va) || 0; vb = parseFloat(vb) || 0; }
if (va < vb) return -1 * asc;
if (va > vb) return 1 * asc;
return 0;
});
};
const buildThead = (cols, sortKey) => {
var h = '<thead><tr class="bg-slate-50 border-b border-slate-200">';
cols.forEach(function(c) {
var arrow = sortKey ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
var clickable = sortKey ? ' cursor-pointer select-none' : '';
h += '<th class="p-2 text-center font-semibold align-middle' + clickable + '"' + (sortKey ? ' onclick="setPlanSort(\'' + sortKey + '|' + c.key + '\')"' : '') + '>' + c.label + arrow + '</th>';
});
h += '</tr></thead>';
return h;
};
const SIGNED_COLS = [
{ key: 'customer_name', label: '项目名称' },
,
{ key: 'sign_amount', label: '签约金额' },
{ key: 'rev', label: '已确收' },
{ key: 'gross', label: '毛利' },
{ key: 'cost', label: '成本' },
{ key: 'profit', label: '项目利润' },
{ key: 'payment', label: '已回款' },
{ key: 'paid', label: '已付' },
{ key: 'cashflow', label: '现金流' },
{ key: 'exe_rate', label: '执行率' },
{ key: 'gross_rate', label: '毛利率' },
{ key: 'pay_rate', label: '回款率' },
{ key: 'paid_rate', label: '付款率' },
];
const UNSIGNED_COLS = [
{ key: 'customer_name', label: '项目名称' },
,
{ key: 'sign_amount', label: '签约金额' },
{ key: 'sign_month', label: '签约月份' },
{ key: 'gross', label: '毛利' },
{ key: 'cost', label: '成本' },
{ key: 'profit', label: '项目利润' },
];
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt };
const cards = METRIC_CARDS.map(c => {
const v = c.value(ctx);
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
});
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
const theadCols = isUnsigned ? 6 : 13;
const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
const subtitle = isUnsigned
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
return card(`<div class="grid ${cardGrid} gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">' + l + '</span><strong class="mt-2 block text-xl ' + c + '">' + v + '</strong></div>').join("")}</div>`, "p-4") +
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
};
const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>';
const tdRow = (pf, rev, payment, cost, paid, gross) => {
const cashflow = payment - paid;
const exe = rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) + '%' : '<span class="text-slate-300">—</span>';
const payRVal = rev && payment ? Math.round(payment / rev * 100) : null;
const payRCls = payRVal === null ? '' : payRVal < 30 ? 'text-red-600' : payRVal < 80 ? 'text-amber-600' : 'text-green-600';
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="p-2 text-center align-middle text-sm">${exe}</td><td class="p-2 text-center align-middle text-sm">${grossR}</td><td class="p-2 text-center align-middle text-sm">${payR}</td><td class="p-2 text-center align-middle text-sm">${paidR}</td></tr>`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
};
if (state.planView === 'monthly') {
const allPfs = pfs;
const now = new Date();
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
if (!state.planMonth) state.planMonth = defaultMonth;
const selMonth = state.planMonth;
var dataItems = [];
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
const b = bd.find(x => (x.month || "") === selMonth) || {};
const e = ed.find(x => (x.month || "") === selMonth) || {};
const rev = Math.round(parseFloat(b.rev || 0) || 0);
const payment = Math.round(parseFloat(b.payment || 0) || 0);
const gross = Math.round(parseFloat(b.gross || 0) || 0);
const cost = Math.round(parseFloat(e.cost || 0) || 0);
const paid = Math.round(parseFloat(e.paid || 0) || 0);
if (!rev && !payment && !cost && !paid && !gross) return;
dataItems.push({
pf: pf,
customer_name: pf.customer_name || '',
sign_amount: pf.sign_amount || 0,
sign_month: pf.sign_month || '',
rev: rev, gross: gross, cost: cost,
profit: gross, payment: payment, paid: paid,
cashflow: payment - paid,
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
pay_rate: rev && payment ? Math.round(payment / rev * 100) : 0,
paid_rate: cost && paid ? Math.round(paid / cost * 100) : 0,
});
});
var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
dataItems = sortData(dataItems, sortK);
const rows = [];
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
dataItems.forEach(d => {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
const signTotal = Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据');
}
if (state.planView === 'quarterly') {
const allPfs = pfs;
const qRanges = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
const now = new Date();
if (state.planQuarter === undefined) {
const saved = localStorage.getItem("opc-fin-quarter");
state.planQuarter = saved !== null ? parseInt(saved) : Math.floor(now.getMonth() / 3);
}
const selQ = state.planQuarter;
const qRange = qRanges[selQ];
const sumBudget = (pf, field) => {
let total = 0;
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {}
return total;
};
const sumExpense = (pf, field) => {
let total = 0;
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(e[field] || 0); }); } catch (e) {}
return total;
};
let dataItems = [];
allPfs.forEach(pf => {
const rev = sumBudget(pf, "rev");
const payment = sumBudget(pf, "payment");
const gross = sumBudget(pf, "gross");
const cost = sumExpense(pf, "cost");
const paid = sumExpense(pf, "paid");
if (!rev && !payment && !cost && !paid && !gross) return;
dataItems.push({
pf: pf,
customer_name: pf.customer_name || '',
sign_amount: pf.sign_amount || 0,
sign_month: pf.sign_month || '',
rev: rev, gross: gross, cost: cost,
profit: gross, payment: payment, paid: paid,
cashflow: payment - paid,
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
pay_rate: rev && payment ? Math.round(payment / rev * 100) : 0,
paid_rate: cost && paid ? Math.round(paid / cost * 100) : 0,
});
});
var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
dataItems = sortData(dataItems, sortK);
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
dataItems.forEach(d => {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
const signTotal = Math.round(allPfs.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 signCnt = allPfs.filter(x => qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据');
}
// 总视图
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let expense = []; try { expense = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, gross = 0;
budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; });
let cost = 0, paid = 0;
expense.forEach(e => { cost += parseFloat(e.cost || 0) || 0; paid += parseFloat(e.paid || 0) || 0; });
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
};
const allPfs = pfs;
var dataItems = allPfs.map(pf => {
var t = calcTotals(pf);
var cashflow = t.payment - t.paid;
return {
pf: pf,
customer_name: pf.customer_name || '',
sign_amount: pf.sign_amount || 0,
rev: t.rev, gross: t.gross, cost: t.cost,
profit: t.gross, payment: t.payment, paid: t.paid,
cashflow: cashflow,
exe_rate: t.rev && pf.sign_amount ? Math.round(t.rev / pf.sign_amount * 100) : 0,
gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
pay_rate: t.rev && t.payment ? Math.round(t.payment / t.rev * 100) : 0,
paid_rate: t.cost && t.paid ? Math.round(t.paid / t.cost * 100) : 0,
};
});
var sortK = null;
if (state.planSort && state.planSort.key && state.planSort.key.startsWith('sig|')) sortK = state.planSort.key.split('|')[1];
dataItems = sortData(dataItems, sortK);
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
dataItems.forEach(d => {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据');
})()}
</div>`;
if (window.lucide) window.lucide.createIcons();
}
window.openPlanModal = () => {
const modal = document.querySelector("#planModal");
const form = modal.querySelector("form");
form.querySelector('[name="project_id"]').value = state.tenant;
const dept = form.querySelector('input[disabled]');
if (dept) dept.value = state.tenant;
const pfIdInput = form.querySelector('[name="pf_id"]');
if (!pfIdInput || !pfIdInput.value) {
planInitRevpayTable(null);
planInitCostTable(null);
planInitTaskTable(null);
document.querySelector("#planDeleteBtn").classList.add("hidden");
}
modal.classList.remove("hidden");
};
window.planAddRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
const tbody = document.querySelector("#plan_revpayTbody");
if (!tbody) return;
const row = document.createElement("tr");
row.innerHTML = `<td><select name="budget_month[]" class="form-ctrl form-ctrl-sm w-full" style="min-width:140px" onchange="planUpdateRevpaySummary()">${monthOptions(month)}</select></td>
<td><input name="budget_rev[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${rev}" oninput="planUpdateRevpaySummary()"></td>
<td><input name="budget_gross[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${gross}" oninput="planUpdateRevpaySummary()"></td>
<td><input name="budget_payment[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${payment}" oninput="planUpdateRevpaySummary()"></td>
<td><input name="budget_rev_note[]" class="form-ctrl form-ctrl-sm w-full" placeholder="备注" value="${rev_note}"></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove();planUpdateRevpaySummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
tbody.appendChild(row);
if (window.lucide) window.lucide.createIcons();
};
window.planAddCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
const tbody = document.querySelector("#plan_costTbody");
if (!tbody) return;
const row = document.createElement("tr");
row.innerHTML = `<td><select name="expense_month[]" class="form-ctrl form-ctrl-sm w-full" style="min-width:140px" onchange="planUpdateCostSummary()">${monthOptions(month)}</select></td>
<td><input name="expense_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="费用类型" value="${expense_type}"></td>
<td><input name="expense_cost[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${cost}" oninput="planUpdateCostSummary()"></td>
<td><input name="expense_paid[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${paid}" oninput="planUpdateCostSummary()"></td>
<td><input name="expense_note[]" class="form-ctrl form-ctrl-sm w-full" placeholder="备注" value="${exp_note}"></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove();planUpdateCostSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
tbody.appendChild(row);
if (window.lucide) window.lucide.createIcons();
};
window.planUpdateRevpaySummary = () => {
const revEl = document.querySelector("#plan_revpayTotalRev");
const grossEl = document.querySelector("#plan_revpayTotalGross");
const paymentEl = document.querySelector("#plan_revpayTotalPayment");
if (!revEl || !paymentEl) return;
const revInputs = document.querySelectorAll('[name="budget_rev[]"]');
const grossInputs = document.querySelectorAll('[name="budget_gross[]"]');
const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]');
let totalRev = 0, totalGross = 0, totalPayment = 0;
revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; });
grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; });
paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; });
revEl.textContent = money(totalRev);
if (grossEl) grossEl.textContent = money(totalGross);
paymentEl.textContent = money(totalPayment);
};
window.planUpdateCostSummary = () => {
const costEl = document.querySelector("#plan_costTotalCost");
const paidEl = document.querySelector("#plan_costTotalPaid");
if (!costEl || !paidEl) return;
const costInputs = document.querySelectorAll('[name="expense_cost[]"]');
const paidInputs = document.querySelectorAll('[name="expense_paid[]"]');
let totalCost = 0, totalPaid = 0;
costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; });
paidInputs.forEach(el => { totalPaid += parseFloat(el.value) || 0; });
costEl.textContent = money(totalCost);
paidEl.textContent = money(totalPaid);
};
window.planInitRevpayTable = (budgetData) => {
const tbody = document.querySelector("#plan_revpayTbody");
if (!tbody) return;
tbody.innerHTML = "";
const rows = budgetData || [];
rows.forEach(r => planAddRevpayRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.rev_note || ''));
setTimeout(() => planUpdateRevpaySummary(), 50);
};
window.planInitCostTable = (expenseData) => {
const tbody = document.querySelector("#plan_costTbody");
if (!tbody) return;
tbody.innerHTML = "";
const rows = expenseData || [];
rows.forEach(r => planAddCostRow(r.month || '', r.expense_type || '', r.cost || '', r.paid || '', r.exp_note || ''));
setTimeout(() => planUpdateCostSummary(), 50);
};
// ---------- 任务管理 tab ----------
function planTaskMonthOptions(selected) {
const now = new Date();
const opts = [];
for (let i = -2; i <= 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
const v = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
}
return opts.join("");
}
window.planAddTaskRow = (taskMonth = '', taskType = '', taskCount = '', executedCount = '', unitPrice = '') => {
const tbody = document.querySelector("#plan_taskTbody");
if (!tbody) return;
const row = document.createElement("tr");
const defaultMonth = (() => { const n = new Date(); return n.getFullYear() + "-" + String(n.getMonth()+1).padStart(2,"0"); })();
const isPreset = TASK_TYPES.includes(taskType);
const typeCell = isPreset || !taskType
? `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="planOnTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option ${t === taskType ? 'selected' : ''}>${t}</option>`).join("")}<option value="__custom__" ${!isPreset && taskType ? 'selected' : ''}>自定义...</option></select>`
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
row.innerHTML = `<td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full">${planTaskMonthOptions(taskMonth || defaultMonth)}</select></td>
<td class="flex items-center">${typeCell}</td>
<td><input name="task_count[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${taskCount}" oninput="planUpdateTaskDiff(this)"></td>
<td><input name="task_executed[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${executedCount}" oninput="planUpdateTaskDiff(this)"></td>
<td><input name="task_diff[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:80px" placeholder="—" disabled></td>
<td><input name="task_unit_price[]" type="number" step="0.01" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:90px" placeholder="0" value="${unitPrice}" oninput="planUpdateTaskDiff(this)"></td>
<td><input name="task_exec_amount[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:140px" placeholder="—" disabled></td>
<td><input name="task_unexec_amount[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:140px" placeholder="—" disabled></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
tbody.appendChild(row);
if (window.lucide) window.lucide.createIcons();
planUpdateRowCalc(row);
};
window.planUpdateTaskDiff = (el) => {
const row = el.closest('tr');
if (!row) return;
planUpdateRowCalc(row);
};
function planUpdateRowCalc(row) {
const countInput = row.querySelector('[name="task_count[]"]');
const execInput = row.querySelector('[name="task_executed[]"]');
const priceInput = row.querySelector('[name="task_unit_price[]"]');
const diffInput = row.querySelector('[name="task_diff[]"]');
const execAmtInput = row.querySelector('[name="task_exec_amount[]"]');
const unexecAmtInput = row.querySelector('[name="task_unexec_amount[]"]');
const c = parseFloat(countInput.value) || 0;
const e = parseFloat(execInput.value) || 0;
const p = parseFloat(priceInput.value) || 0;
const diff = c - e;
// 差额
diffInput.value = (!c && !e) ? '' : diff;
// 执行金额 = 单价 * 已执行
const execAmt = p * e;
execAmtInput.value = execAmt ? execAmt.toFixed(2) : '';
// 未执行金额 = 单价 * 差额
const unexecAmt = p * diff;
unexecAmtInput.value = unexecAmt ? unexecAmt.toFixed(2) : '';
}
window.planToggleBtChip = (chip) => {
const cb = chip.querySelector('input');
cb.checked = !cb.checked;
chip.classList.toggle('bg-blue-50', cb.checked);
chip.classList.toggle('border-blue-400', cb.checked);
chip.classList.toggle('text-blue-600', cb.checked);
};
window.planInitTaskTable = (taskData) => {
const tbody = document.querySelector("#plan_taskTbody");
if (!tbody) return;
tbody.innerHTML = "";
const rows = taskData || [];
rows.forEach(r => planAddTaskRow(r.task_month || '', r.task_type || '', r.task_count || '', r.task_executed || '', r.unit_price || ''));
};
window.planOnTaskTypeChange = (sel) => {
if (sel.value !== '__custom__') return;
const td = sel.parentElement;
const oldVal = sel.value;
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
if (window.lucide) window.lucide.createIcons();
td.querySelector('input').focus();
};
window.planRevertTaskType = (btn) => {
const td = btn.parentElement;
td.innerHTML = `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="planOnTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option>${t}</option>`).join("")}<option value="__custom__">自定义...</option></select>`;
if (window.lucide) window.lucide.createIcons();
};
window.closePlanModal = () => {
const modal = document.querySelector("#planModal");
if (modal) modal.classList.add("hidden");
};
window.planEditSignMonth = (event, pfId) => {
event.stopPropagation();
const pf = (state.data.planFinances || []).find(x => x.id === pfId);
if (!pf) return;
const span = event.currentTarget;
const td = span.parentElement;
const currentValue = pf.sign_month || "";
const select = document.createElement("select");
select.innerHTML = monthOptions(currentValue);
select.className = "form-ctrl form-ctrl-sm w-full";
select.value = currentValue;
select.addEventListener("change", async () => {
const newValue = select.value;
try {
await api(`/api/planFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
pf.sign_month = newValue;
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-blue-600" onclick="planEditSignMonth(event, ${pfId})">${newValue || '—'}</span>`;
} catch (e) { toast("修改失败:" + e.message, "error"); }
});
select.addEventListener("blur", () => {
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-blue-600" onclick="planEditSignMonth(event, ${pfId})">${currentValue || '—'}</span>`;
});
td.innerHTML = "";
td.appendChild(select);
select.focus();
};
window.switchPlanTab = (tab) => {
document.querySelectorAll(".plan-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
document.querySelector("#plan_financeTabInfo").classList.toggle("hidden", tab !== "info");
document.querySelector("#plan_financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
document.querySelector("#plan_financeTabCost").classList.toggle("hidden", tab !== "cost");
document.querySelector("#plan_financeTabExec").classList.toggle("hidden", tab !== "exec");
document.querySelector("#plan_financeTabTasks").classList.toggle("hidden", tab !== "tasks");
document.querySelector("#plan_financeTabActivity").classList.toggle("hidden", tab !== "activity");
document.querySelector(".plan-form-actions").classList.toggle("hidden", tab === "activity");
if (tab === "activity") planInitSquire();
};
// ---------- 活动与跟进 ----------
async function planLoadFollowups(pfId) {
const list = document.querySelector("#plan_finActivityList");
if (!list || !pfId) return;
try {
const fups = await api(`/api/followups/project_finance/${pfId}`);
list.innerHTML = fups.length
? fups.map(f => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" type="button" onclick="planDeleteFollowup(event, ${f.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")
: '<p class="text-sm text-slate-400 py-4 text-center">暂无跟进记录</p>';
if (window.lucide) window.lucide.createIcons();
list.querySelectorAll(".rich-content").forEach(el => {
const html = el.dataset.html;
if (html) el.innerHTML = decodeURIComponent(html);
});
} catch (e) { /* ignore */ }
}
function planInitSquire() {
const ed = document.querySelector("#plan_squire_finance");
if (!ed || !window.Squire) return;
if (window.squireInstances["squire_finance"]) { window.squireInstances["squire_finance"].destroy(); }
const sq = new Squire(ed, { blockTag: "P" });
window.squireInstances["squire_finance"] = sq;
ed.addEventListener("focus", () => ed.classList.add("focused"));
ed.addEventListener("blur", () => { if (!ed.textContent.trim()) ed.classList.remove("focused"); });
}
window.planSubmitComment = async () => {
const pfId = document.querySelector("#plan_pf-id-input").value;
if (!pfId) return;
const sq = window.squireInstances["squire_finance"];
const content = sq ? sq.getHTML().trim() : "";
if (!content || content === "<div><br></div>" || content === "<p><br></p>") return;
const btn = document.querySelector("#financeTabActivity .comment-submit");
btn.disabled = true;
btn.textContent = "发送中…";
await api(`/api/followups/project_finance/${pfId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
sq.setHTML("");
btn.disabled = false;
btn.textContent = "评论";
await planLoadFollowups(pfId);
};
window.planDeleteFollowup = async (event, followupId) => {
event.stopPropagation();
if (!confirm("确认删除这条评论?")) return;
await api(`/api/followups/${followupId}`, { method: "DELETE" });
const pfId = document.querySelector("#plan_pf-id-input").value;
if (pfId) await planLoadFollowups(pfId);
};
window.planOpenPfEditModal = (pfId) => {
const pf = (state.data.planFinances || []).find(x => x.id === pfId);
if (!pf) return;
document.querySelector("#plan_pf-id-input").value = pf.id;
document.querySelector("#planModalTitle").textContent = "编辑项目财务";
document.querySelector("#planDeleteBtn").classList.remove("hidden");
const form = document.querySelector("#financeModal form");
form.querySelector('[name="project_id"]').value = pf.project_id || "";
const deptDisplay = form.querySelector('.bg-slate-50 [disabled]');
if (deptDisplay) deptDisplay.value = pf.project_id || "";
// 回填业务类型多选
const btValues = (pf.business_type || "").split(/[,]/).map(s => s.trim()).filter(Boolean);
form.querySelectorAll('[name="business_type[]"]').forEach(cb => {
cb.checked = btValues.includes(cb.value);
const chip = cb.closest('.bt-chip');
if (chip) {
chip.classList.toggle('bg-blue-50', cb.checked);
chip.classList.toggle('border-blue-400', cb.checked);
chip.classList.toggle('text-blue-600', cb.checked);
}
});
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
const setVal = (name, val) => { const el = form.querySelector(`[name="${name}"]`); if (el) el.value = val || ""; };
setVal("project_code", pf.project_code);
form.querySelector('[name="sign_amount"]').value = pf.sign_amount || "";
const signMonthValue = pf.sign_month || "";
const signMonthEl = form.querySelector('[name="sign_month"]');
if (signMonthEl && signMonthValue) {
signMonthEl.innerHTML = monthOptions(signMonthValue);
signMonthEl.value = signMonthValue;
}
form.querySelector('[name="sales_person"]').value = pf.sales_person || "";
form.querySelector('[name="owner"]').value = pf.owner || "";
setVal("start_date", pf.start_date);
setVal("end_date", pf.end_date);
setVal("task_type", pf.task_type);
setVal("task_count", pf.task_count);
setVal("service_fee_standard", pf.service_fee_standard || 5);
setVal("project_manager", pf.project_manager);
setVal("contact_name", pf.contact_name);
setVal("contact_phone", pf.contact_phone);
setVal("other_info", pf.other_info);
let budgetData = [];
try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; }
planInitRevpayTable(budgetData.length ? budgetData : null);
let expenseData = [];
try { expenseData = JSON.parse(pf.expense_data || "[]"); } catch (e) { expenseData = []; }
planInitCostTable(expenseData.length ? expenseData : null);
let taskData = [];
try { taskData = JSON.parse(pf.task_data || "[]"); } catch (e) { taskData = []; }
planInitTaskTable(taskData.length ? taskData : null);
setTimeout(() => updateBudgetSummary(), 100);
planLoadFollowups(pf.id);
openPlanModal();
};
window.createPlanFinance = async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
// 业务类型多选:合并为逗号分隔
const btChecked = form.querySelectorAll('[name="business_type[]"]:checked');
data.business_type = Array.from(btChecked).map(cb => cb.value).join(",");
data.tenant = state.tenant;
// 必填校验
if (!data.customer_name || !data.customer_name.trim()) { toast("项目名称必填", "error"); return; }
if (!data.sales_person || !data.sales_person.trim()) { toast("商务负责人必填", "error"); return; }
if (!data.owner || !data.owner.trim()) { toast("经营负责人必填", "error"); return; }
if (!data.sign_month) { toast("签约月份必填", "error"); return; }
data.sign_amount = parseFloat(data.sign_amount) || 0;
if (!(data.sign_amount > 0)) { toast("签约金额必须大于 0", "error"); return; }
const months = form.querySelectorAll('[name="budget_month[]"]');
const revs = form.querySelectorAll('[name="budget_rev[]"]');
const grosses = form.querySelectorAll('[name="budget_gross[]"]');
const payments = form.querySelectorAll('[name="budget_payment[]"]');
const revNotes = form.querySelectorAll('[name="budget_rev_note[]"]');
const budgetRows = [];
let totalRev = 0, totalGross = 0, totalPayment = 0;
for (let i = 0; i < months.length; i++) {
const m = months[i].value.trim();
if (!m) continue;
const rev = parseFloat(revs[i].value) || 0;
const gross = parseFloat(grosses[i].value) || 0;
const payment = parseFloat(payments[i].value) || 0;
const rev_note = (revNotes[i] ? revNotes[i].value : '') || '';
budgetRows.push({ month: m, rev, gross, payment, rev_note });
totalRev += rev;
totalGross += gross;
totalPayment += payment;
}
data.budget_data = JSON.stringify(budgetRows);
data.total_rev = totalRev;
data.total_gross = totalGross;
data.total_payment = totalPayment;
// 费用明细数据
const expMonths = form.querySelectorAll('[name="expense_month[]"]');
const expTypes = form.querySelectorAll('[name="expense_type[]"]');
const expCosts = form.querySelectorAll('[name="expense_cost[]"]');
const expPaids = form.querySelectorAll('[name="expense_paid[]"]');
const expNotes = form.querySelectorAll('[name="expense_note[]"]');
const expenseRows = [];
let totalCost = 0, totalPaid = 0;
for (let i = 0; i < expMonths.length; i++) {
const m = expMonths[i].value.trim();
if (!m) continue;
const expense_type = (expTypes[i] ? expTypes[i].value : '') || '';
const cost = parseFloat(expCosts[i].value) || 0;
const paid = parseFloat(expPaids[i].value) || 0;
const exp_note = (expNotes[i] ? expNotes[i].value : '') || '';
expenseRows.push({ month: m, expense_type, cost, paid, exp_note });
totalCost += cost;
totalPaid += paid;
}
data.expense_data = JSON.stringify(expenseRows);
data.total_paid = totalPaid;
data.total_cost = totalCost;
for (const r of budgetRows) { totalPayment += r.payment; }
data.total_payment = totalPayment;
// 收集任务管理数据
const taskTypeInputs = form.querySelectorAll('[name="task_type[]"]');
const taskCountInputs = form.querySelectorAll('[name="task_count[]"]');
const taskExecInputs = form.querySelectorAll('[name="task_executed[]"]');
const taskRows = [];
for (let i = 0; i < taskTypeInputs.length; i++) {
const tt = taskTypeInputs[i].value.trim();
if (!tt) continue;
taskRows.push({
task_month: form.querySelectorAll('[name="task_month[]"]')[i].value || '',
task_type: tt,
task_count: parseFloat(taskCountInputs[i].value) || 0,
task_executed: parseFloat(taskExecInputs[i].value) || 0,
unit_price: parseFloat(form.querySelectorAll('[name="task_unit_price[]"]')[i].value) || 0,
});
}
data.task_data = JSON.stringify(taskRows);
// 清除数组命名的字段FormData 会收集 task_type[] 等),避免后端写入不存在的列
delete data.task_type;
delete data.task_count;
for (const key of Object.keys(data)) {
if (key.endsWith('[]')) delete data[key];
}
const pfId = data.pf_id;
delete data.pf_id;
try {
if (pfId) {
await api("/api/planFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data }) });
if (data.customer_name) logActivity("finance", pfId, "更新了「" + data.customer_name + "」的财务信息");
} else {
const result = await api("/api/planFinances", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.customer_name) logActivity("finance", result.id, "创建了「" + data.customer_name + "」的财务项目");
}
form.reset();
document.querySelector("#plan_pf-id-input").value = "";
document.querySelector("#planModalTitle").textContent = "新增项目财务";
closePlanModal();
await load();
} catch (error) {
toast("保存失败:" + error.message, "error");
}
};
window.deletePlanItem = async () => {
const pfId = document.querySelector("#plan_pf-id-input").value;
if (!pfId) return;
const pf = (state.data.planFinances || []).find(x => x.id === parseInt(pfId));
const name = pf ? (pf.customer_name || "此项目") : "此项目";
if (!confirm(`确认删除「${name}」?此操作不可撤销。`)) return;
try {
await api(`/api/planFinances/${pfId}`, { method: "DELETE" });
closePlanModal();
await load();
toast("已删除", "success");
} catch (error) {
toast("删除失败:" + error.message, "error");
}
}
// 总览渲染
function renderPlanOverview() {
const { summary, financeMonthly } = state.data;
if (!summary) return;
const m = summary.metrics;
const moneyIntL = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
const moneyWan = (v) => {
const n = Number(v || 0);
return n >= 10000 ? (n / 10000).toFixed(0) + "万" : n.toLocaleString("zh-CN");
};
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
const rows1 = [["年度累计", moneyIntL(m.signed_annual || m.signed_amount)], ["本季度累计", moneyIntL(m.signed_q2 || 0)], ["本月累计", moneyIntL(m.signed_month || 0)], ["上本季度累计", moneyIntL(m.signed_prev_q || 0)], ["上月累计", moneyIntL(m.signed_prev_month || 0)]];
const rows2 = [["年度累计", moneyIntL(m.revenue_annual || 0)], ["本季度累计", moneyIntL(m.revenue_q2 || 0)], ["本月累计", moneyIntL(m.monthly_revenue || 0)], ["上本季度累计", moneyIntL(m.revenue_prev_q || 0)], ["上月累计", moneyIntL(m.revenue_prev_month || 0)]];
const rows3 = [["年度累计", moneyIntL(m.gross_annual || 0)], ["本季度累计", moneyIntL(m.gross_q2 || 0)], ["本月累计", moneyIntL(m.monthly_net_profit || 0)], ["上本季度累计", moneyIntL(m.gross_prev_q || 0)], ["上月累计", moneyIntL(m.gross_prev_month || 0)]];
const rows4 = [["年度累计", moneyIntL(m.cost_annual || 0)], ["本季度累计", moneyIntL(m.cost_q2 || 0)], ["本月累计", moneyIntL(m.cost_month || 0)], ["上本季度累计", moneyIntL(m.cost_prev_q || 0)], ["上月累计", moneyIntL(m.cost_prev_month || 0)]];
const rows5 = [["年度累计", moneyIntL(m.profit_annual || 0)], ["本季度累计", moneyIntL(m.profit_q2 || 0)], ["本月累计", moneyIntL(m.profit_month || 0)], ["上本季度累计", moneyIntL(m.profit_prev_q || 0)], ["上月累计", moneyIntL(m.profit_prev_month || 0)]];
const rows7 = [["年度累计", moneyIntL(m.payment_annual || 0)], ["本季度累计", moneyIntL(m.payment_q2 || 0)], ["本月累计", moneyIntL(m.payment_month || 0)], ["上本季度累计", moneyIntL(m.payment_prev_q || 0)], ["上月累计", moneyIntL(m.payment_prev_month || 0)]];
const rows8 = [["年度累计", moneyIntL(m.paid_annual || 0)], ["本季度累计", moneyIntL(m.paid_q2 || 0)], ["本月累计", moneyIntL(m.paid_month || 0)], ["上本季度累计", moneyIntL(m.paid_prev_q || 0)], ["上月累计", moneyIntL(m.paid_prev_month || 0)]];
const rows9 = [["年度累计", moneyIntL(m.cashflow_annual || 0)], ["本季度累计", moneyIntL(m.cashflow_q2 || 0)], ["本月累计", moneyIntL(m.cashflow_month || 0)], ["上本季度累计", moneyIntL(m.cashflow_prev_q || 0)], ["上月累计", moneyIntL(m.cashflow_prev_month || 0)]];
const qoq = (cur, prev) => { if (!prev) return '<span class="text-slate-300">—</span>'; const pct = Math.round((cur - prev) / prev * 100); const cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; const sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
const Q_FIELDS = [m.signed_q2||0, m.revenue_q2, m.gross_q2, m.cost_q2||0, m.profit_q2||0, m.payment_q2||0, m.paid_q2||0, m.cashflow_q2||0];
const Q_PREV = [m.signed_prev_q||0, m.revenue_prev_q||0, m.gross_prev_q||0, m.cost_prev_q||0, m.profit_prev_q||0, m.payment_prev_q||0, m.paid_prev_q||0, m.cashflow_prev_q||0];
const M_FIELDS = [m.signed_month||0, m.monthly_revenue, m.monthly_net_profit, m.cost_month||0, m.profit_month||0, m.payment_month||0, m.paid_month||0, m.cashflow_month||0];
const M_PREV = [m.signed_prev_month||0, m.revenue_prev_month||0, m.gross_prev_month||0, m.cost_prev_month||0, m.profit_prev_month||0, m.payment_prev_month||0, m.paid_prev_month||0, m.cashflow_prev_month||0];
const ROWS = [rows1, rows2, rows3, rows4, rows5, rows7, rows8, rows9];
const CF_RAW = [m.cashflow_annual||0, m.cashflow_q2||0, m.cashflow_month||0, m.cashflow_prev_q||0, m.cashflow_prev_month||0];
const PF_RAW = [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0];
var tdVal = function(ri, col, val) {
if (ri === 4) return '<td class="py-2 text-right font-semibold ' + (PF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
if (ri === 7) return '<td class="py-2 text-right font-semibold ' + (CF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
return '<td class="py-2 text-right font-semibold text-slate-800">' + val + '</td>';
};
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500">指标</th><th class="py-2 text-right font-semibold text-slate-700">年度累计</th><th class="py-2 text-right font-semibold text-slate-700">上季度累计</th><th class="py-2 text-right font-semibold text-slate-700">上月累计</th><th class="py-2 text-right font-semibold text-slate-700">本季度累计</th><th class="py-2 text-right font-semibold text-slate-400">季环比</th><th class="py-2 text-right font-semibold text-slate-700">本月累计</th><th class="py-2 text-right font-semibold text-slate-400">月环比</th></tr>';
var tbody = '';
for (var ri = 0; ri < 8; ri++) {
var vals = ROWS[ri];
tbody += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + LABELS[ri] + '</td>' +
tdVal(ri, 0, vals[0][1]) + tdVal(ri, 3, vals[3][1]) + tdVal(ri, 4, vals[4][1]) + tdVal(ri, 1, vals[1][1]) +
'<td class="py-2 text-right">' + qoq(Q_FIELDS[ri], Q_PREV[ri]) + '</td>' +
tdVal(ri, 2, vals[2][1]) +
'<td class="py-2 text-right">' + qoq(M_FIELDS[ri], M_PREV[ri]) + '</td></tr>';
}
document.querySelector("#planOverview").innerHTML = '<div class="grid gap-5">' +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'<div class="grid grid-cols-4 gap-5">' +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度项目利润</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartProfit"></canvas></div>', 'p-4') +
'</div></div>';
if (window.lucide) window.lucide.createIcons();
// 渲染图表(复用 home.js 的 moneyTick / chartOptions / monthLabels
if (financeMonthly && window.Chart) {
renderOverviewCharts(financeMonthly);
}
}
function renderPlanCharts(data) {
if (typeof monthLabels !== 'function') return;
const labels = monthLabels(data);
const baseOpts = typeof chartOptions === 'function' ? chartOptions(moneyTick) : { responsive: true, maintainAspectRatio: false };
var iconf = { type: "line", options: baseOpts, data: { labels: labels } };
var c1 = document.querySelector("#finChartSign");
if (c1 && window.Chart) {
if (state.planChart1) state.planChart1.destroy();
state.planChart1 = new Chart(c1, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "签约金额", data: data.map(function(x) { return x.sign || 0; }), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }] } }));
}
var c2 = document.querySelector("#finChartRev");
if (c2 && window.Chart) {
if (state.planChart2) state.planChart2.destroy();
state.planChart2 = new Chart(c2, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "确收", data: data.map(function(x) { return x.revenue || 0; }), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, { label: "毛利", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }] } }));
}
var c3 = document.querySelector("#finChartCash");
if (c3 && window.Chart) {
if (state.planChart3) state.planChart3.destroy();
state.planChart3 = new Chart(c3, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "回款", data: data.map(function(x) { return x.payment || 0; }), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, { label: "已付", data: data.map(function(x) { return x.cost || 0; }), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }] } }));
}
var c4 = document.querySelector("#finChartProfit");
if (c4 && window.Chart) {
if (state.planChart4) state.planChart4.destroy();
state.planChart4 = new Chart(c4, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "利润", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }] } }));
}
};