Files
opc-manager/static/modules/finance.js
mac f44f7eab9e feat: 项目流水拆分为确收与回款+费用明细两Tab + 数据迁移
后端:
- 新增 expense_data TEXT 列(columns.py迁移)
- 新增 data_split 迁移:旧 budget_data cost/paid → expense_data(幂等)
- sum_budget cost/paid → sum_expense cost/paid(routes.py/helpers.py)
- TABLES 配置加入 expense_data

前端:
- 项目详情弹窗Tab:基本信息→确收与回款→费用明细→执行信息→任务管理→活动
- 确收与回款Tab:月份/确收/毛利/回款/备注
- 费用明细Tab:月份/费用类型/应付/已付/备注
- 项目表格三视图(月/季/总)cost/paid来源切换为expense_data
- 保存逻辑分两路: budget_data(rev/gross/payment)+expense_data(cost/paid)
2026-07-06 21:34:48 +08:00

846 lines
58 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.
// 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.filter(x => x.status === "已签约");
const pending = pfs.filter(x => x.status === "待签约");
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-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 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.finMonth?'selected':'')+'>'+m+'</option>').join("");
const toolQuarterSelect = qLabels2.map((l,i) => '<option value="'+i+'" '+(i===state.finQuarter?'selected':'')+'>'+l+'</option>').join("");
const toolDateSelect = state.finView==='monthly'?'<span class="text-xs text-slate-500 ml-2">月份:</span><select onchange="state.finMonth=this.value;renderFinance()" class="text-xs 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.finView==='quarterly'?'<span class="text-xs text-slate-500 ml-2">季度:</span><select onchange="state.finQuarter=parseInt(this.value);localStorage.setItem(\'opc-fin-quarter\',this.value);renderFinance()" class="text-xs 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-xs text-slate-500">视图:</span><select onchange="setFinView(this.value)" class="text-xs 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.finView==='overview'?'selected':''}>总视图</option><option value="quarterly" ${state.finView==='quarterly'?'selected':''}>季度视图</option><option value="monthly" ${state.finView==='monthly'?'selected':''}>月度视图</option></select><span class="text-xs text-slate-500 ml-3">状态:</span><select onchange="state.finFilter=this.value;renderFinance()" class="text-xs 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="已签约" ${state.finFilter==='已签约'?'selected':''}>已签约 (${pfs.filter(x=>x.status==='已签约').length})</option><option value="待签约" ${state.finFilter==='待签约'?'selected':''}>待签约 (${pfs.filter(x=>x.status==='待签约').length})</option></select>${toolDateSelect}`;
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "p-3")}
<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>
</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>
<label class="block"><span class="fin-label">项目状态</span><select name="status" class="form-ctrl bg-white"><option>已签约</option><option>待签约</option></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>
${(() => {
const METRIC_CARDS = [
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
{ label: '已确收', 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: '已回款', value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
{ label: '已付', value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流', value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '执行率', value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '毛利率', value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '回款率', value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '付款率', value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
];
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 = '<thead><tr class="bg-slate-50 border-b border-slate-200">' +
'<th class="p-2 text-center font-semibold align-middle">项目名称</th>' +
'<th class="p-2 text-center font-semibold align-middle">状态</th>' +
'<th class="p-2 text-center font-semibold align-middle">签约金额</th>' +
'<th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th>' +
'<th class="p-2 text-center font-semibold align-middle">毛利</th>' +
'<th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th>' +
'<th class="p-2 text-center font-semibold align-middle">项目利润</th>' +
'<th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th>' +
'<th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th>' +
'<th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th>' +
'<th class="p-2 text-center font-semibold align-middle">执行率</th>' +
'<th class="p-2 text-center font-semibold align-middle">毛利率</th>' +
'<th class="p-2 text-center font-semibold align-middle">回款率</th>' +
'<th class="p-2 text-center font-semibold align-middle">付款率</th>' +
'</tr></thead>';
const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="15" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
return card(`<div class="grid grid-cols-6 gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-xs 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">合同 → 确收 → 毛利 → 应付 → 项目利润 → 回款 → 已付 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率</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 payR = rev && payment ? Math.round(payment / rev * 100) + '%' : '<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 st = pf.status === '已签约' ? '<span class="text-green-600">已签约</span>' : '<span class="text-amber-600">待签约</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="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">${st}</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>`;
};
if (state.finView === 'monthly') {
const allPfs = pfs.filter(x => x.status === state.finFilter);
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;
const rows = [];
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
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;
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
rows.push(tdRow(pf, rev, payment, cost, paid, 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.status === "已签约" && (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.filter(x => x.status === state.finFilter);
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;
};
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
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");
sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
rows.push(tdRow(pf, rev, payment, cost, paid, 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 => x.status === "已签约" && 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.filter(x => x.status === state.finFilter);
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
allPfs.forEach(pf => {
const t = calcTotals(pf);
sumRev += t.rev; sumPay += t.payment; sumCost += t.cost; sumPaid += t.paid; sumGross += t.gross;
rows.push(tdRow(pf, t.rev, t.payment, t.cost, t.paid, t.gross));
});
const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约").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 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="status"]').value = pf.status || "待签约";
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");
}
};