Compare commits

...

10 Commits

Author SHA1 Message Date
mac
75227c51c3 fix: 已发生模块经营季报卡片顺序修正
- 修正为: 部门损益情况→部门现金流→项目经营详情
- 与预算模块保持一致的顺序
- 版本号 v1.2.0.23
2026-07-11 11:57:42 +08:00
mac
f5dd152ca4 feat: 季报环比+指标改名
- 部门损益/部门现金流季报Q2/Q3/Q4后增加环比列
- 部门损益卡片行改名: 毛利→项目毛利, 平台费用→部门费用
- 部门现金流卡片行改名: 平台费用→部门现金流, 部门现金流→经营现金流
- 版本号 v1.2.0.22
2026-07-11 11:50:18 +08:00
mac
2c20900e75 feat: 经营月报卡片改名+样式优化+切换工作台默认经营月报
- 业务线经营情况→部门损益情况,副标题改为部门利润公式
- 业务线现金流→部门现金流,副标题改为部门现金流公式
- 财务月度/季度概览→项目经营详情
- 3个卡片主副标题加大+间距对齐+颜色加深
- 签约月份/添加月份/平台费用月份默认当前月份
- 切换工作台默认显示经营月报
- 版本号 v1.2.0.21
2026-07-11 11:44:04 +08:00
mac
774cdec9e3 feat: 预算模块成本→当期成本,付款→当期流出,去除付款率
- 成本列/卡片改名为当期成本
- 付款列/卡片改名为当期流出
- 去除付款率列和卡片
- colspan和theadCols同步调整
- 展开明细表头同步改名
- 版本号 v1.2.0.20
2026-07-10 23:47:54 +08:00
mac
19fa00e220 feat: 季度/月度标签再次点击取消选中
- setPlanQuarter/setPlanMonth增加toggle逻辑
- setFinQuarter/setFinMonth增加toggle逻辑
- 再次点击已选中的季度/月份回到overview总视图
- 版本号 v1.2.0.19
2026-07-10 23:22:12 +08:00
mac
06ba9b2bff feat: 刷新恢复工作台+状态标签调整
- state.tenant从localStorage恢复(只写不读bug修复)
- 状态标签移到总视图后面,增加全部按钮
- 去除总视图按钮
- 版本号 v1.2.0.18
2026-07-10 23:02:48 +08:00
mac
f4b69449b2 feat: 预算筛选区增加状态标签(待签约/已签约/只付款)
- 月份后增加待签约|已签约|只付款3个状态筛选按钮
- 待签约: project_type为待签约
- 已签约: project_type为已签约
- 只付款: 只有成本/付款,确收/毛利/回款为空
- 三种视图(月度/季度/总视图)都支持过滤
- 版本号 v1.2.0.17
2026-07-10 22:32:27 +08:00
mac
7bdde246bc feat: 平台费用Tab切换+汇总筛选对换+卡片样式统一
- 平台费用类型改为Tab按钮切换(人力成本|研发费用|奖金|平台采购|通用费用|其他)
- 平台费用计划改名为平台费用
- 项目管理和平台费用: 汇总区在上筛选区在下
- 平台费用卡片样式对齐项目管理(紧凑px-3 py-1.5)
- 视图下拉框放在Tab按钮左边
- 版本号 v1.2.0.16
2026-07-10 21:43:19 +08:00
mac
ea1e477c31 fix: 已发生模块平台费用保存/删除后列表变空
- 根因: saveExpense/deleteExpenseItem调用bootstrap覆盖state.data
- 轻量bootstrap不返回expense数据导致列表为空
- 改为只调/api/expense/list刷新费用列表
- 版本号 v1.2.0.15
2026-07-10 21:19:53 +08:00
mac
dcc59a9bc6 feat: 待回款卡片负数显示超额回款
- 待回款<0时显示'超额回款 xx万'
- 版本号 v1.2.0.14
2026-07-10 20:14:53 +08:00
6 changed files with 143 additions and 100 deletions

View File

@@ -4,7 +4,7 @@ function renderExpense() {
var records = state.data.expense || [];
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
var esc = function(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
var STATUS_OPTIONS = ['计入费用','暂不计入'];
// 排序状态
@@ -78,12 +78,16 @@ function renderExpense() {
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.expenseQuarter + '"', 'value="' + state.expenseQuarter + '" selected') + '</select>';
}
// Toolbar
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-sm text-slate-500 ml-3">类型:</span><select onchange="setExpenseFilter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
// 类型 Tab 按钮
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setExpenseFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
EXPENSE_TYPES.forEach(function(t) {
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
var cnt = records.filter(function(r){return r.expense_type===t}).length;
typeTabs += '<button onclick="setExpenseFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
});
toolbar += '</select>' + dateSelect;
typeTabs += '</div>';
// Toolbar视图+日期选择器)
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select>' + dateSelect;
// 排序箭头
var sortArrow = function(key) {
@@ -104,11 +108,12 @@ function renderExpense() {
}
// 模态框 monthOpts for form (with current value preselected)
var curMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
var modalMonthOpts = '<option value="">请选择</option>';
for (var yr2 = thisYear - 1; yr2 <= thisYear + 1; yr2++)
for (var m2 = 1; m2 <= 12; m2++) {
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
modalMonthOpts += '<option value="' + mv2 + '">' + mv2 + '</option>';
modalMonthOpts += '<option value="' + mv2 + '"' + (mv2 === curMonth ? ' selected' : '') + '>' + mv2 + '</option>';
}
var statusOpts = STATUS_OPTIONS.map(function(s) { return '<option value="' + s + '">' + s + '</option>'; }).join('');
@@ -119,12 +124,12 @@ function renderExpense() {
var target = document.querySelector("#financeExpense") || document.querySelector("#financeTabExpense") || document.querySelector("#expense");
if (!target) return;
target.innerHTML = '<div class="grid gap-4">' +
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
'<div class="grid grid-cols-3 gap-3">' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">已发生金额</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
'</div>' +
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
modal +
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'incurred_amount\')">已发生金额' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'</div>';
@@ -194,13 +199,10 @@ window.saveExpense = async function(event) {
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();
renderExpense();
}
try {
state.data.expense = await fetch("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)).then(r => r.json());
} catch(e) {}
renderExpense();
closeExpenseModal();
};
@@ -209,11 +211,8 @@ window.deleteExpenseItem = async function() {
if (!id || !confirm("确定删除?")) return;
var resp = await fetch("/api/expense/" + 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();
renderExpense();
}
try {
state.data.expense = await fetch("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)).then(r => r.json());
} catch(e) {}
renderExpense();
};

View File

@@ -123,7 +123,7 @@ function renderFinance() {
<span class="text-sm text-yellow-900"><strong>已发生模块:</strong>只记录,已经发生的确收,毛利,回款,成本,支出</span>
</div>`}
${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>`, "py-2 px-4") : ''}
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? '' : ''}
<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>
@@ -144,7 +144,7 @@ function renderFinance() {
</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 class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions(new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'))}</select></label>
</div>
<div class="grid grid-cols-3 gap-3">
@@ -256,7 +256,7 @@ function renderFinance() {
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: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
{ label: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: v < 0 ? '超额回款 ' + moneyWan(Math.abs(v)) : moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
@@ -323,6 +323,7 @@ function renderFinance() {
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
`${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
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");
};
@@ -516,7 +517,7 @@ window.openFinanceModal = () => {
modal.classList.remove("hidden");
};
window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
window.addRevpayRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), rev = '', gross = '', payment = '', rev_note = '') => {
const tbody = document.querySelector("#revpayTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -530,7 +531,7 @@ window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note
if (window.lucide) window.lucide.createIcons();
};
window.addCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
window.addCostRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), expense_type = '', cost = '', paid = '', exp_note = '') => {
const tbody = document.querySelector("#costTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -1026,8 +1027,8 @@ function renderFinanceOverview() {
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 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) { 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 = '';
@@ -1042,7 +1043,7 @@ function renderFinanceOverview() {
}
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 deptCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><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];
@@ -1060,8 +1061,8 @@ function renderFinanceOverview() {
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
{ label: '平台费用(支出)', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
{ label: '部门现金流', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '经营现金流', vals: blCfMonthly, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
@@ -1077,12 +1078,12 @@ function renderFinanceOverview() {
}
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');
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><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-2.5">' +
deptCard +
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>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'<div class="grid grid-cols-3 gap-2.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') +
@@ -1158,11 +1159,11 @@ function renderFinanceQuarterlyOverview() {
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
var grossAnnual = m.gross_annual || 0;
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; }
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '毛利', vals: grossQ, annual: grossAnnual },
{ label: '平台费用', vals: expQ, annual: expAnnual },
{ label: '项目毛利', vals: grossQ, annual: grossAnnual },
{ label: '部门费用', vals: expQ, annual: expAnnual },
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
];
var deptTbody = '';
@@ -1175,6 +1176,7 @@ function renderFinanceQuarterlyOverview() {
for (var qi = 0; qi < 4; qi++) {
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(dr.vals[qi], dr.vals[qi - 1]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
@@ -1194,8 +1196,8 @@ function renderFinanceQuarterlyOverview() {
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
{ label: '部门现金流', vals: expPaidQ, annual: expPaidAnnual },
{ label: '经营现金流', vals: blCfQ, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
@@ -1207,14 +1209,15 @@ function renderFinanceQuarterlyOverview() {
for (var qi = 0; qi < 4; qi++) {
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(cfdr.vals[qi], cfdr.vals[qi - 1]) + '</td>';
}
cfTbody += rowHtml + '</tr>';
}
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-2.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>' + deptThead + '</thead><tbody>' + deptTbody + '</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>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><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();
}

View File

@@ -109,11 +109,11 @@ function renderPlan() {
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
const activeMonths = qMonths[activeQ];
const yearOpts = [2024,2025,2026,2027];
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setPlanView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='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><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 rounded text-sm font-medium border border-slate-200 bg-white">${yearOpts.map(y => `<option value="${y}" ${state.planYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='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 = `${state.planYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='monthly'&&state.planMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setPlanStatusFilter('all')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='all'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全部</button><button onclick="setPlanStatusFilter('unsigned')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='unsigned'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">待签约</button><button onclick="setPlanStatusFilter('signed')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='signed'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">已签约</button><button onclick="setPlanStatusFilter('paymentonly')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='paymentonly'?'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><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 rounded text-sm font-medium border border-slate-200 bg-white">${yearOpts.map(y => `<option value="${y}" ${state.planYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='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 = `${state.planYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='monthly'&&state.planMonth===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="openPlanModal()">新增财务项目</button>`;
const isOverviewMode = false;
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">经营月报</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用计划</button>`}</div>`;
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">经营月报</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
document.querySelector("#plan").innerHTML = `<div class="grid gap-2">
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#fef9c3,#fde047);border-color:#eab308">
@@ -121,7 +121,8 @@ function renderPlan() {
<span class="text-sm text-yellow-900"><strong>预算:</strong>OPC 对每一个项目确收,毛利,回款,成本,支付,现金流,按月做出规划,回头对比已发生,看预算的准确率</span>
</div>`}
${planFilterTabs}
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4") : ''}
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? '' : (state.planFilter === 'expense' ? '' : '')}
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? '' : ''}
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchPlanTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
@@ -142,7 +143,7 @@ function renderPlan() {
</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 class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions(new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'))}</select></label>
</div>
<div class="grid grid-cols-3 gap-3">
@@ -254,11 +255,11 @@ function renderPlan() {
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: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
{ label: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: v < 0 ? '超额回款 ' + moneyWan(Math.abs(v)) : moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '当期成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ 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 => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ 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' },
@@ -306,9 +307,8 @@ function renderPlan() {
{ key: 'gross_rate', label: '毛利率' },
{ key: 'payment', label: '回款' },
{ key: 'pay_rate', label: '回款率' },
{ key: 'cost', label: '成本' },
{ key: 'paid', label: '付款' },
{ key: 'paid_rate', label: '付款率' },
{ key: 'cost', label: '当期成本' },
{ key: 'paid', label: '当期流出' },
{ key: 'cashflow', label: '现金流' },
{ key: 'pending', label: '待回款' },
];
@@ -320,7 +320,7 @@ function renderPlan() {
{ key: 'sign_amount', label: '签约金额' },
{ key: 'sign_month', label: '签约月份' },
{ key: 'gross', label: '毛利' },
{ key: 'cost', label: '成本' },
{ key: 'cost', label: '当期成本' },
{ key: 'profit', label: '项目利润' },
];
@@ -331,7 +331,7 @@ function renderPlan() {
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 ? 9 : 14;
const theadCols = isUnsigned ? 9 : 13;
const tbody = rows.length ? `<tbody style="line-height:1.37">${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-5';
@@ -339,16 +339,31 @@ function renderPlan() {
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流';
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
`${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
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 filterByStatus = (items) => {
var sf = state.planStatusFilter || 'all';
if (sf === 'all') return items;
return items.filter(d => {
if (sf === 'unsigned') return (d.pf.project_type || '待签约') === '待签约';
if (sf === 'signed') return (d.pf.project_type || '待签约') === '已签约';
if (sf === 'paymentonly') {
return (!d.rev && !d.gross && !d.payment) && (d.cost || d.paid);
}
return true;
});
};
const tdRow = (pf, rev, payment, cost, paid, gross) => {
const cashflow = payment - paid;
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';
@@ -377,7 +392,7 @@ function renderPlan() {
var bCf = bPay - ePaid;
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
}).join('');
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px 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="px-2 py-px 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="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="16"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px 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="px-2 py-px 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="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">当期成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">当期流出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
@@ -420,7 +435,7 @@ function renderPlan() {
});
});
var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
dataItems = sortData(dataItems, sortK);
dataItems = filterByStatus(sortData(dataItems, sortK));
const rows = [];
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
dataItems.forEach(d => {
@@ -475,7 +490,7 @@ function renderPlan() {
});
});
var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
dataItems = sortData(dataItems, sortK);
dataItems = filterByStatus(sortData(dataItems, sortK));
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
dataItems.forEach(d => {
@@ -516,7 +531,7 @@ function renderPlan() {
});
var sortK = null;
if (state.planSort && state.planSort.key && state.planSort.key.startsWith('sig|')) sortK = state.planSort.key.split('|')[1];
dataItems = sortData(dataItems, sortK);
dataItems = filterByStatus(sortData(dataItems, sortK));
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
dataItems.forEach(d => {
@@ -547,7 +562,7 @@ window.openPlanModal = () => {
modal.classList.remove("hidden");
};
window.planAddRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
window.planAddRevpayRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), rev = '', gross = '', payment = '', rev_note = '') => {
const tbody = document.querySelector("#plan_revpayTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -561,7 +576,7 @@ window.planAddRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_n
if (window.lucide) window.lucide.createIcons();
};
window.planAddCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
window.planAddCostRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), expense_type = '', cost = '', paid = '', exp_note = '') => {
const tbody = document.querySelector("#plan_costTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -1101,8 +1116,8 @@ function renderPlanOverview() {
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 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) { 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 = '';
@@ -1118,7 +1133,7 @@ function renderPlanOverview() {
}
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 deptCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
// 业务线现金流卡片
@@ -1136,8 +1151,8 @@ function renderPlanOverview() {
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
{ label: '部门现金流', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '经营现金流', vals: blCfMonthly, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
@@ -1153,12 +1168,12 @@ function renderPlanOverview() {
}
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');
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><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-2.5">' +
deptCard +
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>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'<div class="grid grid-cols-3 gap-2.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') +
@@ -1238,11 +1253,11 @@ function renderPlanQuarterlyOverview() {
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
var grossAnnual = annualSums2.gross;
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; }
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '毛利', vals: grossQ, annual: grossAnnual },
{ label: '平台费用', vals: expQ, annual: expAnnual },
{ label: '项目毛利', vals: grossQ, annual: grossAnnual },
{ label: '部门费用', vals: expQ, annual: expAnnual },
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
];
var deptTbody = '';
@@ -1255,6 +1270,7 @@ function renderPlanQuarterlyOverview() {
for (var qi = 0; qi < 4; qi++) {
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(dr.vals[qi], dr.vals[qi - 1]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
@@ -1273,8 +1289,8 @@ function renderPlanQuarterlyOverview() {
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
{ label: '部门现金流', vals: expPaidQ, annual: expPaidAnnual },
{ label: '经营现金流', vals: blCfQ, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
@@ -1286,14 +1302,15 @@ function renderPlanQuarterlyOverview() {
for (var qi = 0; qi < 4; qi++) {
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(cfdr.vals[qi], cfdr.vals[qi - 1]) + '</td>';
}
cfTbody += rowHtml + '</tr>';
}
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-2.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>' + deptThead + '</thead><tbody>' + deptTbody + '</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>' + deptThead + '</thead><tbody>' + cfTbody + '</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') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><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();
}

View File

@@ -4,7 +4,7 @@ function renderPlanExpense() {
var records = state.data.planExpense || [];
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
var esc = function(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
var STATUS_OPTIONS = ['计入费用','暂不计入'];
// 排序状态
@@ -78,12 +78,16 @@ function renderPlanExpense() {
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setPlanExpQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.planExpQuarter + '"', 'value="' + state.planExpQuarter + '" selected') + '</select>';
}
// Toolbar
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-sm text-slate-500 ml-3">类型:</span><select onchange="setPlanExpFilter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
// 类型 Tab 按钮
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setPlanExpFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
EXPENSE_TYPES.forEach(function(t) {
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
var cnt = records.filter(function(r){return r.expense_type===t}).length;
typeTabs += '<button onclick="setPlanExpFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
});
toolbar += '</select>' + dateSelect;
typeTabs += '</div>';
// Toolbar视图+日期选择器)
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select>' + dateSelect;
// 排序箭头
var sortArrow = function(key) {
@@ -104,11 +108,12 @@ function renderPlanExpense() {
}
// 模态框 monthOpts for form (with current value preselected)
var curMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
var modalMonthOpts = '<option value="">请选择</option>';
for (var yr2 = thisYear - 1; yr2 <= thisYear + 1; yr2++)
for (var m2 = 1; m2 <= 12; m2++) {
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
modalMonthOpts += '<option value="' + mv2 + '">' + mv2 + '</option>';
modalMonthOpts += '<option value="' + mv2 + '"' + (mv2 === curMonth ? ' selected' : '') + '>' + mv2 + '</option>';
}
var statusOpts = STATUS_OPTIONS.map(function(s) { return '<option value="' + s + '">' + s + '</option>'; }).join('');
@@ -119,12 +124,12 @@ function renderPlanExpense() {
var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
if (!target) return;
target.innerHTML = '<div class="grid gap-4">' +
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
'<div class="grid grid-cols-3 gap-3">' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">现金支出</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">现金支出</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
'</div>' +
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
modal +
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">现金支出' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'</div>';

View File

@@ -3,17 +3,18 @@
const state = {
active: "home",
data: null,
tenant: "科普·无界",
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
opFilter: "all",
finFilter: "projects",
finFilter: "overview",
selectedProject: null,
taskQuery: "",
taskView: localStorage.getItem("opc-task-view") || "detail",
finView: "overview",
finSort: null, // { key: 'col', asc: true }
planFilter: "projects",
planFilter: "overview",
planYear: new Date().getFullYear(),
finYear: new Date().getFullYear(),
planStatusFilter: "all",
planView: "overview",
planSort: null,
proposalTab: "standard",
@@ -333,8 +334,16 @@ window.setFinView = (view) => {
state.finView = view;
renderFinance();
};
window.setFinQuarter = (q) => { state.finQuarter = q; state.finView = 'quarterly'; renderFinance(); };
window.setFinMonth = (month) => { state.finMonth = month; state.finView = 'monthly'; renderFinance(); };
window.setFinQuarter = (q) => {
if (state.finView === 'quarterly' && state.finQuarter === q) { state.finView = 'overview'; }
else { state.finQuarter = q; state.finView = 'quarterly'; }
renderFinance();
};
window.setFinMonth = (month) => {
if (state.finView === 'monthly' && state.finMonth === month) { state.finView = 'overview'; }
else { state.finMonth = month; state.finView = 'monthly'; }
renderFinance();
};
window.setFinYear = (year) => { state.finYear = parseInt(year); renderFinance(); };
window.switchFinFilter = (filter) => {
state.finFilter = filter;
@@ -352,9 +361,18 @@ window.setPlanView = (view) => {
state.planView = view;
renderPlan();
};
window.setPlanQuarter = (q) => { state.planQuarter = q; state.planView = 'quarterly'; renderPlan(); };
window.setPlanMonth = (month) => { state.planMonth = month; state.planView = 'monthly'; renderPlan(); };
window.setPlanQuarter = (q) => {
if (state.planView === 'quarterly' && state.planQuarter === q) { state.planView = 'overview'; }
else { state.planQuarter = q; state.planView = 'quarterly'; }
renderPlan();
};
window.setPlanMonth = (month) => {
if (state.planView === 'monthly' && state.planMonth === month) { state.planView = 'overview'; }
else { state.planMonth = month; state.planView = 'monthly'; }
renderPlan();
};
window.setPlanYear = (year) => { state.planYear = parseInt(year); renderPlan(); };
window.setPlanStatusFilter = (filter) => { state.planStatusFilter = filter; renderPlan(); };
window.switchPlanFilter = (filter) => {
state.planFilter = filter;
state.planSort = null;
@@ -372,9 +390,10 @@ window.switchTenant = async (tenant) => {
localStorage.setItem("opc-active-tenant", tenant);
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
if (typeof updateTenantLabel === "function") updateTenantLabel();
state.planFilter = 'overview';
state.finFilter = 'overview';
updateSidebarTabs();
if (tenant === "总览") { state.planFilter = 'overview'; state.finFilter = 'overview'; switchTab("home"); }
else if (state.active === 'home') { state.active = 'plan'; localStorage.setItem("opc-active-tab", "plan"); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan")); }
if (state.active === 'home') { state.active = 'plan'; localStorage.setItem("opc-active-tab", "plan"); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan")); }
await load();
};

View File

@@ -76,7 +76,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.2.0.13</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.2.0.23</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">