Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s

This commit is contained in:
mac
2026-07-08 20:48:52 +08:00
12 changed files with 371 additions and 164 deletions

View File

@@ -17,7 +17,7 @@ def run_migrations():
"""
from migrations.tables import migrate_create_tables
from migrations.columns import migrate_add_columns
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status, migrate_rename_xuehui_to_xueshu, migrate_rename_zong_to_overview
from migrations.data_split import migrate_data_split
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
@@ -28,6 +28,8 @@ def run_migrations():
migrate_drop_product_fields()
migrate_fix_service_fee_standard()
migrate_fix_expense_status()
migrate_rename_xuehui_to_xueshu()
migrate_rename_zong_to_overview()
migrate_data_split()
migrate_seed_users()
migrate_seed_demo_data()

View File

@@ -114,3 +114,46 @@ def migrate_fix_expense_status():
conn.close()
def migrate_rename_xuehui_to_xueshu():
"""工作台重命名:学会·无界 → 学术·无界"""
from db import db, _exec, mysql
conn = db()
try:
tables = ["project_finances", "plan_finances", "expense_records", "plan_expense_records",
"sales_leads", "business_proposals", "product_versions", "operation_projects",
"follow_up_records", "project_tasks", "finance_records", "users", "user_tenants"]
for table in tables:
try:
cur = _exec(conn, f"UPDATE {table} SET tenant='学术·无界' WHERE tenant='学会·无界'")
affected = cur.rowcount
cur.close()
if affected:
conn.commit()
print(f"[migrate] {table}: {affected} 条记录 tenant 改为 '学术·无界'")
except mysql.connector.Error:
pass
finally:
conn.close()
def migrate_rename_zong_to_overview():
"""工作台重命名:总工作台 → 总览"""
from db import db, _exec, mysql
conn = db()
try:
tables = ["project_finances", "plan_finances", "expense_records", "plan_expense_records",
"sales_leads", "business_proposals", "product_versions", "operation_projects",
"follow_up_records", "project_tasks", "finance_records", "users", "user_tenants"]
for table in tables:
try:
cur = _exec(conn, f"UPDATE {table} SET tenant='总览' WHERE tenant='总工作台'")
affected = cur.rowcount
cur.close()
if affected:
conn.commit()
print(f"[migrate] {table}: {affected} 条记录 tenant 改为 '总览'")
except mysql.connector.Error:
pass
finally:
conn.close()

View File

@@ -28,7 +28,7 @@ def migrate_seed_users():
# 绑定工作台
tenant_map = [
("kepu", "科普·无界"), ("keyan", "科研·无界"), ("yihuan", "医患·无界"),
("mcn", "MCN·无界"), ("wuji", "·无界"),
("mcn", "MCN·无界"), ("wuji", "·无界"),
]
for uname, tenant in tenant_map:
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))

View File

@@ -8,7 +8,7 @@ random.seed(42)
from datetime import date
from db import db, _exec, one
TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
# 每个工作台的差异化客户和项目模板
TENANT_CUSTOMERS = {
@@ -40,7 +40,7 @@ TENANT_CUSTOMERS = {
("B站知识区", ["深度科普纪录片", "医学动画制作"]),
("微信视频号", ["医生科普直播运营", "企业微信科普推送"]),
],
"·无界": [
"·无界": [
("中华医学会", ["学术年会数字化平台", "继续教育学分系统", "基层医生培训"]),
("中国医师协会", ["医师定考管理系统", "专科医师培训平台"]),
("中国抗癌协会", ["肿瘤科普万里行", "CACA指南推广"]),

View File

@@ -14,7 +14,7 @@ from helpers import add_file_index, attach_common, monthly_finance
bp = Blueprint("api", __name__)
ALL_TENANTS = ["工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
ALL_TENANTS = ["", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
@@ -75,10 +75,13 @@ def auth_login():
session["display_name"] = user["display_name"]
session["role"] = user["role"]
if user["role"] == "admin":
session["tenants"] = ["工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
session["tenants"] = ["", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
else:
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
session["tenants"] = [x["tenant"] for x in ut]
# 清理旧名称
session["tenants"] = [t if t != "总工作台" else "总览" for t in session["tenants"]]
session["tenants"] = [t if t != "学会·无界" else "学术·无界" for t in session["tenants"]]
return jsonify({
"ok": True,
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
@@ -123,8 +126,10 @@ def auth_me():
if "user_id" not in session:
return jsonify({"logged_in": False})
tenants = session.get("tenants", [])
if "总工作台" not in tenants:
tenants = ["总工作台"] + tenants
tenants = [t if t != "总工作台" else "总览" for t in tenants]
tenants = [t if t != "学会·无界" else "学术·无界" for t in tenants]
if "总览" not in tenants:
tenants = ["总览"] + tenants
return jsonify({
"logged_in": True,
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
@@ -257,15 +262,17 @@ def bootstrap():
_year = date.today().year
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
allowed = session.get("tenants", [])
if "总工作台" not in allowed:
allowed = ["总工作台"] + allowed
allowed = [t if t != "总工作台" else "总览" for t in allowed]
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
if "总览" not in allowed:
allowed = ["总览"] + allowed
if tenant not in allowed:
tenant = allowed[0]
conn = db()
try:
# 总工作台:聚合所有工作台的首页数据
if tenant == "工作台":
real_tenants = [t for t in allowed if t != "工作台"]
if tenant == "":
real_tenants = [t for t in allowed if t != ""]
all_metrics = []
all_monthly = []
all_recent = []
@@ -423,7 +430,7 @@ def bootstrap():
m[field] = sum(tl[i][field] if i < len(tl) else 0 for tl in all_monthly)
merged_monthly.append(m)
summary = {
"project_name": "工作台",
"project_name": "",
"metrics": agg,
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
"risks": [],

View File

@@ -15,7 +15,7 @@ const savedTab = localStorage.getItem("opc-active-tab");
applyUserTenants();
updateSidebarTabs();
load().then(() => {
if (state.tenant === "总工作台") {
if (state.tenant === "总") {
switchTab("home");
} else if (savedTab && savedTab !== "home") {
switchTab(savedTab);

View File

@@ -78,27 +78,27 @@ function renderFinance() {
if (isRevView) {
const rev = b.rev || 0;
const gross = b.gross || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
} else {
const payment = b.payment || 0;
const cost = b.cost || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
}
}).join("");
const totalCol = (() => {
if (isRevView) {
const totalRev = pf.total_rev || 0;
const totalGross = pf.total_gross || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
} else {
let totalPayment = 0, totalCost = 0;
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
}
})();
const sm = pf.sign_month || "";
const signMonthCell = `<td class="p-2 text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="p-2 text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="p-2 text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
};
const now2 = new Date();
@@ -110,14 +110,19 @@ function renderFinance() {
const nowD = new Date();
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
const activeMonths = qMonths[activeQ];
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setFinView('overview')" class="px-3 py-1 rounded text-sm font-medium ${state.finView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-1 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按月度:</span>${activeMonths.map(m => { const ms = `2026-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-1 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setFinView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按月度:</span>${activeMonths.map(m => { const ms = `2026-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">经营月报</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">季度月报</button><button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button></div>`;
const isOverviewMode = state.tenant === "总览";
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">经营月报</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
document.querySelector("#finance").innerHTML = `<div class="grid gap-2">
${finFilterTabs}
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "p-4") : ''}
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
<i data-lucide="lightbulb" class="text-blue-600 flex-shrink-0" style="width:18px;height:18px"></i>
<span class="text-sm text-blue-900"><strong>执行提示:</strong>先关注确收,毛利确保业务运营,再关注回款,回款率,最后逐步优化成本与支出</span>
</div>`}
${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") : ''}
<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>
@@ -130,7 +135,7 @@ function renderFinance() {
<form onsubmit="createFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="pf-id-input" value="">
<div class="finance-tab-body">
<div id="financeTabInfo">
<div class="grid gap-4">
<div class="grid gap-2">
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如KP-2026-001"></label>
@@ -179,7 +184,7 @@ function renderFinance() {
<p class="text-lg font-bold text-amber-700" id="revpayTotalPayment">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="revpayTbody"></tbody>
</table>
@@ -196,14 +201,14 @@ function renderFinance() {
<p class="text-lg font-bold text-purple-700" id="costTotalPaid">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="costTable">
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="costTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="costTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addCostRow()"><i data-lucide="plus"></i></button>
</div>
<div id="financeTabTasks" class="hidden">
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="taskTbody"></tbody>
</table>
@@ -250,11 +255,11 @@ 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: '毛利', value: ctx => money(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' },
{ 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' },
{ label: '支出', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { 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' },
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
@@ -304,13 +309,13 @@ function renderFinance() {
});
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
const theadCols = isUnsigned ? 6 : 12;
const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const tbody = rows.length ? `<tbody style="line-height:0.95">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
const subtitle = isUnsigned
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
return card(`<div class="grid ${cardGrid} gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">' + l + '</span><strong class="mt-2 block text-xl ' + c + '">' + v + '</strong></div>').join("")}</div>`, "p-4") +
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>` +
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");
};
@@ -325,11 +330,11 @@ function renderFinance() {
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center align-middle text-sm">${grossR}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center 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><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${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 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="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr>`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><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 font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</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 text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
};
if (state.finView === 'monthly') {
@@ -938,13 +943,13 @@ function renderFinanceOverview() {
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)];
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>'; }
var thead = '<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 mi = 0; mi < 12; mi++) { thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
thead += '</tr>';
var tbody = '';
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>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 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]) {
@@ -953,7 +958,7 @@ function renderFinanceOverview() {
}
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>';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
}
tbody += rowHtml + '</tr>';
}
@@ -970,8 +975,8 @@ function renderFinanceOverview() {
}
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>'; }
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 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 },
@@ -983,10 +988,10 @@ function renderFinanceOverview() {
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>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
rowHtml += '<td class="py-1.5 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>';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + valCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
@@ -1016,22 +1021,22 @@ function renderFinanceOverview() {
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>';
var cfRowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
cfRowHtml += '<td class="py-1.5 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>';
cfRowHtml += '<td class="py-1.5 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">' +
document.querySelector("#financeOverview").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') +
deptCard +
cfCard +
'<div class="grid grid-cols-3 gap-5">' +
'<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') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与支出</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
@@ -1043,7 +1048,7 @@ function renderFinanceOverview() {
}
}
// 季度月报渲染
// 经营季报渲染
function renderFinanceQuarterlyOverview() {
const { summary, financeMonthly } = state.data;
if (!summary) return;
@@ -1051,45 +1056,118 @@ function renderFinanceQuarterlyOverview() {
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
const fm = financeMonthly || [];
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出'];
const qLbl = ['Q1','Q2','Q3','Q4'];
const qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0];
const fmFields = ['sign','revenue','gross','cost','paid'];
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
var qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
// 财务概览: 7 rows
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
var annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)];
var fmFields = ['sign','revenue','gross','cost','paid','payment',null];
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
for (var qi = 0; qi < 4; qi++) {
for (var ri = 0; ri < 5; ri++) {
for (var ri = 0; ri < 7; ri++) {
var sum = 0;
for (var mi = 0; mi < 3; mi++) {
var idx = qRanges[qi][mi];
if (fm[idx]) {
var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0;
if (ri === 6) { sum += (fm[idx].payment||0) - (fm[idx].paid||0); }
else { var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0; }
}
}
qData[ri][qi] = sum;
}
}
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
var thead = '<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>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '</tr>';
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
var tbody = '';
for (var ri = 0; ri < 5; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
for (var ri = 0; ri < 7; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
for (var qi = 0; qi < 4; qi++) {
var val = qData[ri][qi];
var cls = 'text-slate-700';
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
if (qi > 0) { rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
if (ri === 2) cls = 'text-green-600';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
if (qi > 0) { rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
}
tbody += rowHtml + '</tr>';
}
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-5">' +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
// 业务线经营情况: quarterly
var finExp = state.data.expense || [];
var expQ = [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 emi = parseInt(em.substring(5)) - 1;
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expQ[eqi] += parseFloat(finExp[ei].amount || 0); }
}
var expAnnual = 0; for (var ei = 0; ei < 4; ei++) expAnnual += expQ[ei];
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>'; }
deptThead += '</tr>';
var deptRows = [
{ 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 = '';
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri];
var rowCls = dri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
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>';
}
deptTbody += rowHtml + '</tr>';
}
// 业务线现金流: quarterly
var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]];
var cfAnnual = m.payment_annual || 0;
var expPaidQ = [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 emi = parseInt(em.substring(5)) - 1;
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expPaidQ[eqi] += parseFloat(finExp[ei].incurred_amount || 0); }
}
var expPaidAnnual = 0; for (var ei = 0; ei < 4; ei++) expPaidAnnual += expPaidQ[ei];
var blCfQ = cfQ.map(function(v, i) { return v - expPaidQ[i]; });
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
var cfdr = cfRows[cfri];
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
var annualCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
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>';
}
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') +
'</div>';
if (window.lucide) window.lucide.createIcons();
}

View File

@@ -117,7 +117,7 @@ function renderHome() {
}
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
${state.tenant === "总工作台" ? "" : `<div class="grid grid-cols-4 gap-3">
${state.tenant === "总" ? "" : `<div class="grid grid-cols-4 gap-3">
${[
["项目管理", m.total_projects, "finance"],
["重点工作与台账", m.total_proposals, "projects"],

View File

@@ -76,27 +76,27 @@ function renderPlan() {
if (isRevView) {
const rev = b.rev || 0;
const gross = b.gross || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
} else {
const payment = b.payment || 0;
const cost = b.cost || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
}
}).join("");
const totalCol = (() => {
if (isRevView) {
const totalRev = pf.total_rev || 0;
const totalGross = pf.total_gross || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
} else {
let totalPayment = 0, totalCost = 0;
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
}
})();
const sm = pf.sign_month || "";
const signMonthCell = `<td class="p-2 text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="p-2 text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="p-2 text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="p-2 text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
};
const now2 = new Date();
@@ -108,18 +108,19 @@ function renderPlan() {
const nowD = new Date();
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
const activeMonths = qMonths[activeQ];
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setPlanView('overview')" class="px-3 py-1 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>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-1 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 = `2026-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-1 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="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>${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 = `2026-${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 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><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 isOverviewMode = state.tenant === "总览";
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-4">
document.querySelector("#plan").innerHTML = `<div class="grid gap-2">
${planFilterTabs}
<div class="flex items-center gap-3 px-4 py-3 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
<i data-lucide="lightbulb" class="text-blue-600 flex-shrink-0" style="width:18px;height:18px"></i>
<span class="text-sm text-blue-900"><strong>计划提醒:</strong>计划也是预算,只有真正能够列出计划的预算,才有靠谱的落地可能性</span>
</div>
${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>`, "p-4") : ''}
</div>`}
${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") : ''}
<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>
@@ -132,7 +133,7 @@ function renderPlan() {
<form onsubmit="createPlanFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="plan_pf-id-input" value="">
<div class="finance-tab-body">
<div id="plan_financeTabInfo">
<div class="grid gap-4">
<div class="grid gap-2">
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如KP-2026-001"></label>
@@ -181,7 +182,7 @@ function renderPlan() {
<p class="text-lg font-bold text-amber-700" id="plan_revpayTotalPayment">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_revpayTable">
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_revpayTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="plan_revpayTbody"></tbody>
</table>
@@ -198,14 +199,14 @@ function renderPlan() {
<p class="text-lg font-bold text-purple-700" id="plan_costTotalPaid">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_costTable">
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_costTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="plan_costTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="planAddCostRow()"><i data-lucide="plus"></i></button>
</div>
<div id="plan_financeTabTasks" class="hidden">
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_taskTable">
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_taskTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="plan_taskTbody"></tbody>
</table>
@@ -252,11 +253,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: '毛利', value: ctx => money(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' },
{ 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' },
{ label: '已付', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { 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' },
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
@@ -277,11 +278,13 @@ function renderPlan() {
});
};
const buildThead = (cols, sortKey) => {
var noSortKeys = ['weight', 'project_type'];
var h = '<thead><tr class="bg-slate-50 border-b border-slate-200">';
cols.forEach(function(c) {
var arrow = sortKey ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
var clickable = sortKey ? ' cursor-pointer select-none' : '';
h += '<th class="p-2 text-center font-semibold align-middle' + clickable + '"' + (sortKey ? ' onclick="setPlanSort(\'' + sortKey + '|' + c.key + '\')"' : '') + '>' + c.label + arrow + '</th>';
var canSort = sortKey && noSortKeys.indexOf(c.key) < 0;
var arrow = canSort ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
var clickable = canSort ? ' cursor-pointer select-none' : '';
h += '<th class="p-2 text-center font-semibold align-middle' + clickable + '"' + (canSort ? ' onclick="setPlanSort(\'' + sortKey + '|' + c.key + '\')"' : '') + '>' + c.label + arrow + '</th>';
});
h += '</tr></thead>';
return h;
@@ -322,13 +325,13 @@ function renderPlan() {
});
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
const theadCols = isUnsigned ? 9 : 14;
const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const tbody = rows.length ? `<tbody style="line-height:0.95">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
const subtitle = isUnsigned
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流';
return card(`<div class="grid ${cardGrid} gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">' + l + '</span><strong class="mt-2 block text-xl ' + c + '">' + v + '</strong></div>').join("")}</div>`, "p-4") +
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>` +
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");
};
@@ -342,14 +345,14 @@ function renderPlan() {
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="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 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><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</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">${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="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr>`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-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 align-middle text-sm">${pf.sign_month || '—'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
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">${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 align-middle text-sm">${pf.sign_month || '—'}</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 text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
};
if (state.planView === 'monthly') {
@@ -1016,13 +1019,13 @@ function renderPlanOverview() {
];
// 字段映射: 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>'; }
var thead = '<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 mi = 0; mi < 12; mi++) { thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
thead += '</tr>';
var tbody = '';
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>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 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]) {
@@ -1032,7 +1035,7 @@ function renderPlanOverview() {
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
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>';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
}
tbody += rowHtml + '</tr>';
}
@@ -1047,8 +1050,8 @@ function renderPlanOverview() {
}
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>'; }
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 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 },
@@ -1059,12 +1062,12 @@ function renderPlanOverview() {
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri];
var rowCls = dri === 2 ? 'font-semibold' : '';
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>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-2 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
for (var dmi = 0; dmi < 12; dmi++) {
var cellCls = dri === 2 ? (dr.vals[dmi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
@@ -1094,22 +1097,22 @@ function renderPlanOverview() {
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>';
var cfRowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
cfRowHtml += '<td class="py-1.5 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>';
cfRowHtml += '<td class="py-1.5 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">' +
document.querySelector("#planOverview").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') +
deptCard +
cfCard +
'<div class="grid grid-cols-3 gap-5">' +
'<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') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
@@ -1128,59 +1131,122 @@ function renderPlanQuarterlyOverview() {
const fm = computePlanMonthly(planFinances);
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出'];
const qLbl = ['Q1','Q2','Q3','Q4'];
const qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
var annualSums2 = { sign: 0, revenue: 0, gross: 0, cost: 0, payment: 0, paid: 0 };
for (var i2 = 0; i2 < fm.length; i2++) {
annualSums2.sign += fm[i2].sign; annualSums2.revenue += fm[i2].revenue;
annualSums2.gross += fm[i2].gross; annualSums2.cost += fm[i2].cost;
annualSums2.payment += fm[i2].payment; annualSums2.paid += fm[i2].paid;
}
const annualVals = [annualSums2.sign, annualSums2.revenue, annualSums2.gross, annualSums2.cost, annualSums2.paid];
const fmFields = ['sign','revenue','gross','cost','paid'];
var qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
// Calculate quarterly sums
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
// 财务概览: 7 rows
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
var annualVals = [annualSums2.sign, annualSums2.revenue, annualSums2.gross, annualSums2.cost, annualSums2.paid, annualSums2.payment, annualSums2.payment - annualSums2.paid];
var fmFields = ['sign','revenue','gross','cost','paid','payment',null];
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
for (var qi = 0; qi < 4; qi++) {
for (var ri = 0; ri < 5; ri++) {
for (var ri = 0; ri < 7; ri++) {
var sum = 0;
for (var mi = 0; mi < 3; mi++) {
var idx = qRanges[qi][mi];
if (fm[idx]) {
var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0;
if (ri === 6) { sum += (fm[idx].payment||0) - (fm[idx].paid||0); }
else { var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0; }
}
}
qData[ri][qi] = sum;
}
}
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
var thead = '<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>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '</tr>';
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
var tbody = '';
for (var ri = 0; ri < 5; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
for (var ri = 0; ri < 7; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
for (var qi = 0; qi < 4; qi++) {
var val = qData[ri][qi];
var cls = 'text-slate-700';
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
if (qi > 0) {
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>';
}
if (ri === 2) cls = 'text-green-600';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
if (qi > 0) { rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
}
tbody += rowHtml + '</tr>';
}
document.querySelector("#planQuarterlyOverview").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') +
// 业务线经营情况: quarterly
var planExp = state.data.planExpense || [];
var expQ = [0,0,0,0];
for (var ei = 0; ei < planExp.length; ei++) {
var em = (planExp[ei].expense_month || '').substring(0, 7);
var emi = parseInt(em.substring(5)) - 1;
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expQ[eqi] += parseFloat(planExp[ei].amount || 0); }
}
var expAnnual = 0; for (var ei = 0; ei < 4; ei++) expAnnual += expQ[ei];
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>'; }
deptThead += '</tr>';
var deptRows = [
{ 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 = '';
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri];
var rowCls = dri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
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>';
}
deptTbody += rowHtml + '</tr>';
}
// 业务线现金流: quarterly
var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]];
var cfAnnual = annualSums2.payment;
var expPaidQ = [0,0,0,0];
for (var ei = 0; ei < planExp.length; ei++) {
var em = (planExp[ei].expense_month || '').substring(0, 7);
var emi = parseInt(em.substring(5)) - 1;
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expPaidQ[eqi] += parseFloat(planExp[ei].incurred_amount || 0); }
}
var expPaidAnnual = 0; for (var ei = 0; ei < 4; ei++) expPaidAnnual += expPaidQ[ei];
var blCfQ = cfQ.map(function(v, i) { return v - expPaidQ[i]; });
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
var cfdr = cfRows[cfri];
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
var annualCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
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>';
}
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>' + 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') +
'</div>';
if (window.lucide) window.lucide.createIcons();
}
@@ -1213,7 +1279,7 @@ function planInlineSelect(name, pfId, field, currentVal, options) {
for (var i = 0; i < options.length; i++) {
opts += '<option value="' + options[i] + '"' + (options[i] === currentVal ? ' selected' : '') + '>' + options[i] + '</option>';
}
return '<select onchange="planInlineUpdate(' + pfId + ', \'' + field + '\'' + ', this.value)" class="text-sm bg-transparent border-0 cursor-pointer text-center" style="appearance:auto;-webkit-appearance:auto;vertical-align:middle">' + opts + '</select>';
return '<select onchange="planInlineUpdate(' + pfId + ', \'' + field + '\'' + ', this.value)" class="text-sm bg-transparent border-0 cursor-pointer text-center" style="appearance:none;-webkit-appearance:none;vertical-align:middle;padding-right:14px;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2210%22 height=%2210%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%23999%22 stroke-width=%222%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right center">' + opts + '</select>';
}
window.planInlineUpdate = async (pfId, field, value) => {

View File

@@ -25,7 +25,7 @@ window.toggleTenantMenu = (event) => {
if (menu) { menu.remove(); return; }
const btn = event.currentTarget;
const rect = btn.getBoundingClientRect();
const tenants = state.allowedTenants || [];
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
menu = document.createElement("div");
menu.id = "tenantMenu";
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]";
@@ -52,15 +52,31 @@ window.toggleTenantMenu = (event) => {
};
window.switchTenantFromMenu = (tenant) => {
document.getElementById("tenantMenu")?.remove();
switchTenant(tenant);
};
function updateTenantLabel() {
const label = document.querySelector("#currentTenantLabel");
if (label) {
label.textContent = state.tenant.replace("·无界", "") || "工作台";
label.title = state.tenant;
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
var tenantIcons = {
'学术·无界': 'graduation-cap',
'MCN·无界': 'video',
'科普·无界': 'flask-conical',
'医患·无界': 'stethoscope',
'科研·无界': 'microscope',
'总览': 'layout-dashboard'
};
const container = document.querySelector("#tenantList");
if (container) {
container.innerHTML = tenants.map(t => {
const shortName = t.replace("·无界", "");
const isActive = t === state.tenant;
var icon = tenantIcons[t] || 'circle';
return '<div class="flex flex-col items-center cursor-pointer rounded-lg py-1.5 px-1 w-14 transition-colors ' + (isActive ? 'bg-blue-600' : 'hover:bg-slate-800') + '" onclick="switchTenantFromMenu(\'' + t.replace(/'/g, "\\'") + '\')" title="' + esc(t) + '">' +
'<i data-lucide="' + icon + '" style="width:20px;height:20px;color:' + (isActive ? '#fff' : '#94a3b8') + '"></i>' +
'<span class="text-[10px] mt-1 max-w-[56px] truncate text-center ' + (isActive ? 'text-white' : 'text-slate-400') + '">' + esc(shortName) + '</span>' +
'</div>';
}).join('');
if (window.lucide) window.lucide.createIcons();
}
}

View File

@@ -144,13 +144,13 @@ function switchTab(tab) {
}
function updateSidebarTabs() {
const isOverview = state.tenant === "总工作台";
const isOverview = state.tenant === "总";
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
const tab = btn.dataset.tab;
if (isOverview && tab !== "home") {
btn.style.display = "none";
if (isOverview) {
btn.style.display = (tab === "home" || tab === "plan" || tab === "finance") ? "" : "none";
} else {
btn.style.display = "";
btn.style.display = tab === "home" ? "none" : "";
}
});
}
@@ -240,10 +240,9 @@ window.switchTenant = (tenant) => {
state.selectedProject = null;
localStorage.setItem("opc-active-tenant", tenant);
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
const label = document.querySelector("#currentTenantLabel");
if (label) { label.textContent = tenant.replace("·无界", "") || "工作台"; label.title = tenant; }
if (typeof updateTenantLabel === "function") updateTenantLabel();
updateSidebarTabs();
if (tenant === "总工作台") switchTab("home");
if (tenant === "总") { state.planFilter = 'overview'; state.finFilter = 'overview'; switchTab("home"); }
load();
};
window.doLogout = async () => {

View File

@@ -35,12 +35,8 @@
</div>
<!-- 分隔线 -->
<div class="w-8 h-px bg-slate-700 my-2"></div>
<!-- 工作台切换按钮 -->
<div class="flex flex-col items-center cursor-pointer hover:bg-slate-800 rounded-lg py-2 px-1 w-14 transition-colors" onclick="toggleTenantMenu(event)">
<i data-lucide="layout-grid" style="width:18px;height:18px;color:#94a3b8"></i>
<span class="text-[10px] text-slate-400 mt-1 max-w-[56px] truncate text-center" id="currentTenantLabel">工作台</span>
<i data-lucide="chevron-down" style="width:12px;height:12px;color:#64748b;margin-top:2px"></i>
</div>
<!-- 工作台列表(竖向平铺) -->
<div class="flex flex-col items-center gap-1 w-14" id="tenantList"></div>
<!-- 分隔线 -->
<div class="w-8 h-px bg-slate-700 my-2"></div>
<!-- 导航 Tab 图标 -->
@@ -80,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.0.0.34</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.36</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">