- 计划+实际模块月度总览: 新增业务线经营情况卡片(毛利/平台费用/利润) - 计划+实际模块月度总览: 新增业务线现金流卡片(项目现金流/平台费用已付/业务线现金流) - 实际模块月度总览: 修复deptCard/cfCard未输出到innerHTML - 计划模块项目表头: 列顺序调整(14列), 已确收→确收, 已付→付款 - 实际模块项目表头: 列顺序调整(12列) - 版本号 v1.0.0.31
1123 lines
75 KiB
JavaScript
1123 lines
75 KiB
JavaScript
// finance.js — 经营管理(财务)模块
|
||
|
||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
||
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(1)} 万`;
|
||
|
||
function renderFinance() {
|
||
const pfs = state.data.projectFinances || [];
|
||
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.finView !== "cashflow" && state.finView !== "overview" && state.finView !== "monthly" && state.finView !== "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(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><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.finMonth) state.finMonth = defaultMonth2;
|
||
if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3);
|
||
const qLabels = ['Q1','Q2','Q3','Q4'];
|
||
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
||
const nowD = new Date();
|
||
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
|
||
const activeMonths = qMonths[activeQ];
|
||
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setFinView('overview')" class="px-3 py-1 rounded text-sm font-medium ${state.finView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-1 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按月度:</span>${activeMonths.map(m => { const ms = `2026-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-1 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
||
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
||
|
||
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">月度总览</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">季度总览</button><button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button></div>`;
|
||
|
||
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
|
||
${finFilterTabs}
|
||
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "p-4") : ''}
|
||
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><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="financeModalTitle">新增项目财务</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="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
||
<div class="finance-tabs">
|
||
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('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="switchFinanceTab('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="switchFinanceTab('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="switchFinanceTab('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="switchFinanceTab('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="switchFinanceTab('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="createFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="pf-id-input" value="">
|
||
<div class="finance-tab-body">
|
||
<div id="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><label class="block"><span class="fin-label">客户名称</span><input name="client_name" 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="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="toggleBtChip(this)"><input type="checkbox" name="business_type[]" value="${t}" class="hidden"><span>${t}</span></label>`).join("")}</div></div>
|
||
</div>
|
||
</div>
|
||
<div id="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="financeTabRevpay" class="hidden">
|
||
<div class="grid grid-cols-3 gap-3 mb-4" id="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="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="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="revpayTotalPayment">¥0</p>
|
||
</div>
|
||
</div>
|
||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="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="revpayTbody"></tbody>
|
||
</table>
|
||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addRevpayRow()"><i data-lucide="plus"></i>添加月份</button>
|
||
</div>
|
||
<div id="financeTabCost" class="hidden">
|
||
<div class="grid grid-cols-2 gap-3 mb-4" id="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="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="costTotalPaid">¥0</p>
|
||
</div>
|
||
</div>
|
||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="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="costTbody"></tbody>
|
||
</table>
|
||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addCostRow()"><i data-lucide="plus"></i>添加月份</button>
|
||
</div>
|
||
<div id="financeTabTasks" class="hidden">
|
||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="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="taskTbody"></tbody>
|
||
</table>
|
||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addTaskRow()"><i data-lucide="plus"></i>添加任务</button>
|
||
</div>
|
||
<div id="financeTabActivity" class="hidden">
|
||
<div class="grid gap-2" id="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="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="submitFinComment()">评论</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="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
|
||
${(() => {
|
||
if (state.finFilter === 'overview') {
|
||
setTimeout(() => renderFinanceOverview(), 10);
|
||
return `<div id="financeOverview"></div>`;
|
||
}
|
||
if (state.finFilter === 'quarterlyOverview') {
|
||
setTimeout(() => renderFinanceQuarterlyOverview(), 10);
|
||
return `<div id="financeQuarterlyOverview"></div>`;
|
||
}
|
||
if (state.finFilter === 'expense') {
|
||
setTimeout(() => renderExpense(), 10);
|
||
return `<div id="financeExpense"></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.finSort) state.finSort = { key: null, asc: true };
|
||
const sortData = (data, key) => {
|
||
if (!key || !state.finSort.key) return data;
|
||
var asc = state.finSort.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.finSort.asc ? ' ↑' : ' ↓') : '') : '';
|
||
h += '<th class="p-2 text-center font-semibold align-middle cursor-pointer select-none" onclick="setFinSort(\'' + sortKey + '|' + c.key + '\')">' + c.label + arrow + '</th>';
|
||
});
|
||
h += '</tr></thead>';
|
||
return h;
|
||
};
|
||
const SIGNED_COLS = [
|
||
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
||
{ key: 'payment', label: '回款' }, { key: 'cashflow', label: '现金流' }, { key: 'pay_rate', label: '回款率' },
|
||
{ key: 'cost', label: '成本' }, { key: 'paid', label: '已付' }, { key: 'paid_rate', label: '付款率' },
|
||
];
|
||
const UNSIGNED_COLS = [
|
||
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||
{ key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', 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 : 12;
|
||
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';
|
||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><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 align-middle text-sm">${grossR}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="p-2 text-center align-middle text-sm">${payR}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="p-2 text-center align-middle text-sm">${paidR}</td></tr>`;
|
||
};
|
||
// 待签约简版行
|
||
const tdRowUnsigned = (pf, cost, gross) => {
|
||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${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></tr>`;
|
||
};
|
||
|
||
if (state.finView === 'monthly') {
|
||
const allPfs = pfs;
|
||
const now = new Date();
|
||
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
|
||
if (!state.finMonth) state.finMonth = defaultMonth;
|
||
const selMonth = state.finMonth;
|
||
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, 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.finSort && state.finSort.key ? state.finSort.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.finView === 'quarterly') {
|
||
const allPfs = pfs;
|
||
const qRanges = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
|
||
const now = new Date();
|
||
if (state.finQuarter === undefined) {
|
||
const saved = localStorage.getItem("opc-fin-quarter");
|
||
state.finQuarter = saved !== null ? parseInt(saved) : Math.floor(now.getMonth() / 3);
|
||
}
|
||
const selQ = state.finQuarter;
|
||
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;
|
||
};
|
||
var 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, 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.finSort && state.finSort.key ? state.finSort.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);
|
||
return {
|
||
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
||
rev: t.rev, gross: t.gross, cost: t.cost,
|
||
payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid,
|
||
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 = state.finSort && state.finSort.key ? state.finSort.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.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.openFinanceModal = () => {
|
||
const modal = document.querySelector("#financeModal");
|
||
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) {
|
||
initRevpayTable(null);
|
||
initCostTable(null);
|
||
initTaskTable(null);
|
||
document.querySelector("#financeDeleteBtn").classList.add("hidden");
|
||
}
|
||
modal.classList.remove("hidden");
|
||
};
|
||
|
||
window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
|
||
const tbody = document.querySelector("#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="updateRevpaySummary()">${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="updateRevpaySummary()"></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="updateRevpaySummary()"></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="updateRevpaySummary()"></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();updateRevpaySummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
|
||
tbody.appendChild(row);
|
||
if (window.lucide) window.lucide.createIcons();
|
||
};
|
||
|
||
window.addCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
|
||
const tbody = document.querySelector("#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="updateCostSummary()">${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="updateCostSummary()"></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="updateCostSummary()"></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();updateCostSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
|
||
tbody.appendChild(row);
|
||
if (window.lucide) window.lucide.createIcons();
|
||
};
|
||
|
||
window.updateRevpaySummary = () => {
|
||
const revEl = document.querySelector("#revpayTotalRev");
|
||
const grossEl = document.querySelector("#revpayTotalGross");
|
||
const paymentEl = document.querySelector("#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.updateCostSummary = () => {
|
||
const costEl = document.querySelector("#costTotalCost");
|
||
const paidEl = document.querySelector("#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.initRevpayTable = (budgetData) => {
|
||
const tbody = document.querySelector("#revpayTbody");
|
||
if (!tbody) return;
|
||
tbody.innerHTML = "";
|
||
const rows = budgetData || [];
|
||
rows.forEach(r => addRevpayRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.rev_note || ''));
|
||
setTimeout(() => updateRevpaySummary(), 50);
|
||
};
|
||
|
||
window.initCostTable = (expenseData) => {
|
||
const tbody = document.querySelector("#costTbody");
|
||
if (!tbody) return;
|
||
tbody.innerHTML = "";
|
||
const rows = expenseData || [];
|
||
rows.forEach(r => addCostRow(r.month || '', r.expense_type || '', r.cost || '', r.paid || '', r.exp_note || ''));
|
||
setTimeout(() => updateCostSummary(), 50);
|
||
};
|
||
|
||
// ---------- 任务管理 tab ----------
|
||
const TASK_TYPES = ["科普视频", "科普专访", "科普文章", "问卷调研", "病例征集"];
|
||
|
||
function taskMonthOptions(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.addTaskRow = (taskMonth = '', taskType = '', taskCount = '', executedCount = '', unitPrice = '') => {
|
||
const tbody = document.querySelector("#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="onTaskTypeChange(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="revertTaskType(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">${taskMonthOptions(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="updateTaskDiff(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="updateTaskDiff(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="updateTaskDiff(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();
|
||
updateRowCalc(row);
|
||
};
|
||
|
||
window.updateTaskDiff = (el) => {
|
||
const row = el.closest('tr');
|
||
if (!row) return;
|
||
updateRowCalc(row);
|
||
};
|
||
|
||
function updateRowCalc(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.toggleBtChip = (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.initTaskTable = (taskData) => {
|
||
const tbody = document.querySelector("#taskTbody");
|
||
if (!tbody) return;
|
||
tbody.innerHTML = "";
|
||
const rows = taskData || [];
|
||
rows.forEach(r => addTaskRow(r.task_month || '', r.task_type || '', r.task_count || '', r.task_executed || '', r.unit_price || ''));
|
||
};
|
||
|
||
window.onTaskTypeChange = (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="revertTaskType(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.revertTaskType = (btn) => {
|
||
const td = btn.parentElement;
|
||
td.innerHTML = `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="onTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option>${t}</option>`).join("")}<option value="__custom__">自定义...</option></select>`;
|
||
if (window.lucide) window.lucide.createIcons();
|
||
};
|
||
|
||
window.closeFinanceModal = () => {
|
||
const modal = document.querySelector("#financeModal");
|
||
modal.classList.add("hidden");
|
||
};
|
||
|
||
window.editPfSignMonth = (event, pfId) => {
|
||
event.stopPropagation();
|
||
const pf = (state.data.projectFinances || []).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/projectFinances/${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="editPfSignMonth(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="editPfSignMonth(event, ${pfId})">${currentValue || '—'}</span>`;
|
||
});
|
||
td.innerHTML = "";
|
||
td.appendChild(select);
|
||
select.focus();
|
||
};
|
||
|
||
window.switchFinanceTab = (tab) => {
|
||
document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
||
document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info");
|
||
document.querySelector("#financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
|
||
document.querySelector("#financeTabCost").classList.toggle("hidden", tab !== "cost");
|
||
document.querySelector("#financeTabExec").classList.toggle("hidden", tab !== "exec");
|
||
document.querySelector("#financeTabTasks").classList.toggle("hidden", tab !== "tasks");
|
||
document.querySelector("#financeTabActivity").classList.toggle("hidden", tab !== "activity");
|
||
document.querySelector(".finance-form-actions").classList.toggle("hidden", tab === "activity");
|
||
if (tab === "activity") initFinSquire();
|
||
};
|
||
|
||
// ---------- 活动与跟进 ----------
|
||
|
||
async function loadFinFollowups(pfId) {
|
||
const list = document.querySelector("#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="deleteFinFollowup(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 initFinSquire() {
|
||
const ed = document.querySelector("#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.submitFinComment = async () => {
|
||
const pfId = document.querySelector("#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 loadFinFollowups(pfId);
|
||
};
|
||
|
||
window.deleteFinFollowup = async (event, followupId) => {
|
||
event.stopPropagation();
|
||
if (!confirm("确认删除这条评论?")) return;
|
||
await api(`/api/followups/${followupId}`, { method: "DELETE" });
|
||
const pfId = document.querySelector("#pf-id-input").value;
|
||
if (pfId) await loadFinFollowups(pfId);
|
||
};
|
||
|
||
window.openPfEditModal = (pfId) => {
|
||
const pf = (state.data.projectFinances || []).find(x => x.id === pfId);
|
||
if (!pf) return;
|
||
document.querySelector("#pf-id-input").value = pf.id;
|
||
document.querySelector("#financeModalTitle").textContent = "编辑项目财务";
|
||
document.querySelector("#financeDeleteBtn").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 clientInput = form.querySelector('[name="client_name"]'); if (clientInput) clientInput.value = pf.client_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 = []; }
|
||
initRevpayTable(budgetData.length ? budgetData : null);
|
||
let expenseData = [];
|
||
try { expenseData = JSON.parse(pf.expense_data || "[]"); } catch (e) { expenseData = []; }
|
||
initCostTable(expenseData.length ? expenseData : null);
|
||
let taskData = [];
|
||
try { taskData = JSON.parse(pf.task_data || "[]"); } catch (e) { taskData = []; }
|
||
initTaskTable(taskData.length ? taskData : null);
|
||
setTimeout(() => updateBudgetSummary(), 100);
|
||
loadFinFollowups(pf.id);
|
||
openFinanceModal();
|
||
};
|
||
|
||
window.createFinance = 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/projectFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data }) });
|
||
if (data.customer_name) logActivity("finance", pfId, "更新了「" + data.customer_name + "」的财务信息");
|
||
} else {
|
||
const result = await api("/api/projectFinances", { method: "POST", body: JSON.stringify({ data }) });
|
||
if (result.id && data.customer_name) logActivity("finance", result.id, "创建了「" + data.customer_name + "」的财务项目");
|
||
}
|
||
form.reset();
|
||
document.querySelector("#pf-id-input").value = "";
|
||
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
|
||
closeFinanceModal();
|
||
await load();
|
||
} catch (error) {
|
||
toast("保存失败:" + error.message, "error");
|
||
}
|
||
};
|
||
|
||
window.deleteFinanceItem = async () => {
|
||
const pfId = document.querySelector("#pf-id-input").value;
|
||
if (!pfId) return;
|
||
const pf = (state.data.projectFinances || []).find(x => x.id === parseInt(pfId));
|
||
const name = pf ? (pf.customer_name || "此项目") : "此项目";
|
||
if (!confirm(`确认删除「${name}」?此操作不可撤销。`)) return;
|
||
try {
|
||
await api(`/api/projectFinances/${pfId}`, { method: "DELETE" });
|
||
closeFinanceModal();
|
||
await load();
|
||
toast("已删除", "success");
|
||
} catch (error) {
|
||
toast("删除失败:" + error.message, "error");
|
||
}
|
||
}
|
||
|
||
// 月度总览渲染
|
||
function renderFinanceOverview() {
|
||
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 fm = financeMonthly || [];
|
||
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||
const LABELS = ['合同金额','确收金额','确收毛利','成本','已付'];
|
||
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0];
|
||
const fmFields = ['sign','revenue','gross','cost','paid'];
|
||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
|
||
thead += '</tr>';
|
||
var tbody = '';
|
||
for (var ri = 0; ri < 5; ri++) {
|
||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
||
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
||
for (var mi = 0; mi < 12; mi++) {
|
||
var val = 0;
|
||
if (fm[mi]) {
|
||
var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0;
|
||
}
|
||
var cls = 'text-slate-700';
|
||
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
||
}
|
||
tbody += rowHtml + '</tr>';
|
||
}
|
||
|
||
|
||
// 业务线经营情况卡片
|
||
var finExp = state.data.expense || [];
|
||
var expMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||
for (var ei = 0; ei < finExp.length; ei++) {
|
||
if ((finExp[ei].status || '') === '暂不计入') continue;
|
||
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
||
var mi2 = parseInt(em.substring(5)) - 1;
|
||
if (mi2 >= 0 && mi2 < 12) expMonthly[mi2] += parseFloat(finExp[ei].amount || 0);
|
||
}
|
||
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
|
||
var grossAnnual = 0; for (var ei = 0; ei < 12; ei++) grossAnnual += (fm[ei] ? fm[ei].gross : 0);
|
||
var deptThead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[dmi] + '</th>'; }
|
||
deptThead += '</tr>';
|
||
var deptRows = [
|
||
{ label: '毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
|
||
{ label: '平台费用', vals: expMonthly, annual: expAnnual },
|
||
{ label: '利润', vals: fm.map(function(x, i) { return (x ? x.gross : 0) - expMonthly[i]; }), annual: grossAnnual - expAnnual }
|
||
];
|
||
var deptTbody = '';
|
||
for (var dri = 0; dri < 3; dri++) {
|
||
var dr = deptRows[dri];
|
||
var rowCls = dri === 2 ? 'font-semibold' : '';
|
||
var valCls = dri === 2 ? 'text-green-600' : 'text-slate-700';
|
||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
|
||
rowHtml += '<td class="py-2 text-right ' + rowCls + ' ' + valCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
||
for (var dmi = 0; dmi < 12; dmi++) {
|
||
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + valCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
|
||
}
|
||
deptTbody += rowHtml + '</tr>';
|
||
}
|
||
var deptCard = 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>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
|
||
|
||
// 业务线现金流卡片
|
||
var cfMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||
for (var cfi = 0; cfi < 12; cfi++) { cfMonthly[cfi] = (fm[cfi] ? (fm[cfi].payment - fm[cfi].paid) : 0); }
|
||
var cfAnnual = 0; for (var cfi = 0; cfi < 12; cfi++) cfAnnual += cfMonthly[cfi];
|
||
var expPaidMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||
for (var epi = 0; epi < finExp.length; epi++) {
|
||
if ((finExp[epi].status || '') === '暂不计入') continue;
|
||
var epm = (finExp[epi].expense_month || '').substring(0, 7);
|
||
var epmi = parseInt(epm.substring(5)) - 1;
|
||
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(finExp[epi].incurred_amount || 0);
|
||
}
|
||
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
|
||
var blCfMonthly = cfMonthly.map(function(v, i) { return v - expPaidMonthly[i]; });
|
||
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||
var cfRows = [
|
||
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
|
||
{ label: '平台费用(已付)', vals: expPaidMonthly, annual: expPaidAnnual },
|
||
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
|
||
];
|
||
var cfTbody = '';
|
||
for (var cfri = 0; cfri < 3; cfri++) {
|
||
var cfdr = cfRows[cfri];
|
||
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
|
||
var cfValCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||
var cfRowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
|
||
cfRowHtml += '<td class="py-2 text-right ' + cfRowCls + ' ' + cfValCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
|
||
for (var cfdmi = 0; cfdmi < 12; cfdmi++) {
|
||
var cellCls = cfValCls;
|
||
if (cfri === 2) cellCls = blCfMonthly[cfdmi] >= 0 ? 'text-green-600' : 'text-red-600';
|
||
cfRowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[cfdmi]) + '</td>';
|
||
}
|
||
cfTbody += cfRowHtml + '</tr>';
|
||
}
|
||
var cfCard = 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>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
||
|
||
document.querySelector("#financeOverview").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') +
|
||
deptCard +
|
||
cfCard +
|
||
'<div class="grid grid-cols-3 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="finChartSign"></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="finChartRev"></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="finChartCash"></canvas></div>', 'p-4') +
|
||
'</div></div>';
|
||
|
||
if (window.lucide) window.lucide.createIcons();
|
||
if (financeMonthly && window.Chart) {
|
||
renderOverviewCharts(financeMonthly);
|
||
}
|
||
}
|
||
|
||
// 季度总览渲染
|
||
function renderFinanceQuarterlyOverview() {
|
||
const { summary, financeMonthly } = state.data;
|
||
if (!summary) return;
|
||
const m = summary.metrics;
|
||
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
||
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
||
const fm = financeMonthly || [];
|
||
const LABELS = ['合同金额','确收金额','确收毛利','成本','已付'];
|
||
const qLbl = ['Q1','Q2','Q3','Q4'];
|
||
const qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
|
||
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0];
|
||
const fmFields = ['sign','revenue','gross','cost','paid'];
|
||
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
||
for (var qi = 0; qi < 4; qi++) {
|
||
for (var ri = 0; ri < 5; ri++) {
|
||
var sum = 0;
|
||
for (var mi = 0; mi < 3; mi++) {
|
||
var idx = qRanges[qi][mi];
|
||
if (fm[idx]) {
|
||
var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0;
|
||
}
|
||
}
|
||
qData[ri][qi] = sum;
|
||
}
|
||
}
|
||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
|
||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||
thead += '</tr>';
|
||
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
|
||
var tbody = '';
|
||
for (var ri = 0; ri < 5; ri++) {
|
||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
||
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
||
for (var qi = 0; qi < 4; qi++) {
|
||
var val = qData[ri][qi];
|
||
var cls = 'text-slate-700';
|
||
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
||
if (qi > 0) { rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
|
||
}
|
||
tbody += rowHtml + '</tr>';
|
||
}
|
||
document.querySelector("#financeQuarterlyOverview").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>';
|
||
if (window.lucide) window.lucide.createIcons();
|
||
}
|
||
|
||
function renderOverviewCharts(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.finChart1) state.finChart1.destroy();
|
||
state.finChart1 = 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.finChart2) state.finChart2.destroy();
|
||
state.finChart2 = 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.finChart3) state.finChart3.destroy();
|
||
state.finChart3 = 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.finChart4) state.finChart4.destroy();
|
||
state.finChart4 = 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 }] } }));
|
||
}
|
||
};
|