Compare commits

...

3 Commits

Author SHA1 Message Date
mac
1e82ca73d9 fix: 计划费用保存卡顿+月度总览增加回款现金流行
- plan_expense.js: savePlanExpense/deletePlanExpItem改用api()+load()
- 去掉多余的applyUserTenants和手动fetch bootstrap
- plan.js月度总览增加回款和项目现金流两行
- 版本号 v1.0.0.33
2026-07-08 17:45:48 +08:00
mac
cfd5137edc fix: 修复计划模块保存失败(client_name列重复)
- routes.py: planFinances列列表去掉重复的client_name
- plan.js: 拆分try-catch区分保存错误和刷新错误
- 版本号 v1.0.0.32
2026-07-08 17:04:47 +08:00
mac
0a0caa959c feat: 月度总览增加业务线经营+现金流卡片+表头调整
- 计划+实际模块月度总览: 新增业务线经营情况卡片(毛利/平台费用/利润)
- 计划+实际模块月度总览: 新增业务线现金流卡片(项目现金流/平台费用已付/业务线现金流)
- 实际模块月度总览: 修复deptCard/cfCard未输出到innerHTML
- 计划模块项目表头: 列顺序调整(14列), 已确收→确收, 已付→付款
- 实际模块项目表头: 列顺序调整(12列)
- 版本号 v1.0.0.31
2026-07-08 16:49:11 +08:00
5 changed files with 187 additions and 44 deletions

View File

@@ -25,7 +25,7 @@ TABLES = {
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
"expense": ("expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
"planFinances": ("plan_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info", "client_name", "project_type", "weight"]),
"planFinances": ("plan_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info", "project_type", "weight"]),
"planExpense": ("plan_expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
}

View File

@@ -957,8 +957,79 @@ function renderFinanceOverview() {
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') +
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') +

View File

@@ -294,15 +294,15 @@ function renderPlan() {
{ key: 'weight', label: '加权' },
{ key: 'project_type', label: '项目类型' },
{ key: 'sign_amount', label: '签约金额' },
{ key: 'rev', label: '确收' },
{ key: 'rev', label: '确收' },
{ key: 'gross', label: '毛利' },
{ key: 'cost', label: '成本' },
{ key: 'profit', label: '项目利润' },
{ key: 'payment', label: '已回款' },
{ key: 'paid', label: '已付' },
{ key: 'gross_rate', label: '毛利率' },
{ key: 'payment', label: '回款' },
{ key: 'cashflow', label: '现金流' },
{ key: 'exe_rate', label: '执行率' },
{ key: 'pay_rate', label: '回款率' },
{ key: 'cost', label: '成本' },
{ key: 'paid', label: '付款' },
{ key: 'paid_rate', label: '付款率' },
];
const UNSIGNED_COLS = [
{ key: 'client_name', label: '客户' },
@@ -337,18 +337,14 @@ function renderPlan() {
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 cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-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" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="p-2 text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</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">${payR}</td></tr>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${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" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="p-2 text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</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) => {
@@ -939,13 +935,18 @@ window.createPlanFinance = async (event) => {
const result = await api("/api/planFinances", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.customer_name) logActivity("finance", result.id, "创建了「" + data.customer_name + "」的财务项目");
}
} catch (error) {
toast("保存失败:" + error.message, "error");
return;
}
try {
form.reset();
document.querySelector("#plan_pf-id-input").value = "";
document.querySelector("#planModalTitle").textContent = "新增项目财务";
closePlanModal();
await load();
} catch (error) {
toast("保存失败:" + error.message, "error");
toast("刷新失败:" + error.message, "error");
}
};
@@ -1009,33 +1010,105 @@ function renderPlanOverview() {
annualSums.payment += fm[i].payment; annualSums.paid += fm[i].paid;
}
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const LABELS = ['合同金额','确收金额','确收毛利','成本','已付'];
const LABELS = ['合同金额','确收金额','确收毛利','成本','已付','回款','项目现金流'];
// 每行的年度累计 + 12个月值
const annualVals = [
annualSums.sign, annualSums.revenue, annualSums.gross, annualSums.cost, annualSums.paid,
annualSums.payment, annualSums.payment - annualSums.paid,
];
// 字段映射: sign, revenue, gross, cost, profit(=gross-cost), payment, paid, cashflow(=payment-paid)
const fmFields = ['sign','revenue','gross','cost','paid'];
// 字段映射: sign, revenue, gross, cost, paid, payment, cashflow(=payment-paid)
const fmFields = ['sign','revenue','gross','cost','paid','payment',null];
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++) {
for (var ri = 0; ri < 7; 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;
if (ri === 6) { val = (fm[mi].payment || 0) - (fm[mi].paid || 0); }
else { var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0; }
}
var cls = 'text-slate-700';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
}
tbody += rowHtml + '</tr>';
}
// 部门经营卡片:毛利、平台费用、利润
var planExp = state.data.planExpense || [];
var expMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
for (var ei = 0; ei < planExp.length; ei++) {
var em = (planExp[ei].expense_month || '').substring(0, 7);
var mi = parseInt(em.substring(5)) - 1;
if (mi >= 0 && mi < 12) expMonthly[mi] += parseFloat(planExp[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 < planExp.length; epi++) {
var epm = (planExp[epi].expense_month || '').substring(0, 7);
var epmi = parseInt(epm.substring(5)) - 1;
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(planExp[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("#planOverview").innerHTML = '<div class="grid gap-5">' +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
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="planChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +

View File

@@ -184,36 +184,35 @@ window.savePlanExpense = async function(event) {
if (data.amount === '' || data.amount === undefined) data.amount = 0;
if (data.incurred_amount === '' || data.incurred_amount === undefined) data.incurred_amount = 0;
if (!data.status) data.status = '计入费用';
var isEdit = !!data.expense_id;
var id = data.expense_id;
delete data.expense_id;
data.tenant = state.tenant;
var id = form.querySelector('[name="expense_id"]').value;
var url = isEdit ? "/api/planExpense/" + id : "/api/planExpense";
var method = isEdit ? "PUT" : "POST";
var resp = await fetch(url, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: Object.assign({}, data, { tenant: state.tenant }) }) });
if (!resp.ok) { var err = await resp.text(); alert("保存失败:" + err); return; }
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
if (bResp.ok) {
var bd = await bResp.json();
state.data = bd;
applyUserTenants();
renderPlanExpense();
try {
if (id) {
await api("/api/planExpense/" + id, { method: "PUT", body: JSON.stringify({ data }) });
} else {
await api("/api/planExpense", { method: "POST", body: JSON.stringify({ data }) });
}
} catch (error) {
toast("保存失败:" + error.message, "error");
return;
}
closePlanExpModal();
await load();
renderPlanExpense();
};
window.deletePlanExpItem = async function() {
var id = document.querySelector("#planExpenseModal form [name='expense_id']").value;
if (!id || !confirm("确定删除?")) return;
var resp = await fetch("/api/planExpense/" + id, { method: "DELETE" });
if (!resp.ok) { alert("删除失败"); return; }
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
if (bResp.ok) {
var bd = await bResp.json();
state.data = bd;
applyUserTenants();
renderPlanExpense();
try {
await api("/api/planExpense/" + id, { method: "DELETE" });
} catch (error) {
toast("删除失败:" + error.message, "error");
return;
}
closePlanExpModal();
await load();
renderPlanExpense();
};

View File

@@ -80,7 +80,7 @@
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
<div class="flex items-center gap-3 w-full">
<div class="flex-1">
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.0.0.30</span></p>
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.0.0.33</span></p>
<div class="flex items-center gap-4 mt-1">
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100 rounded-full px-4 py-1.5">