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

This commit is contained in:
mac
2026-07-02 17:55:40 +08:00
6 changed files with 197 additions and 27 deletions

View File

@@ -67,7 +67,7 @@ def admin_required(f):
return decorated
ALL_TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"]
ALL_TENANTS = ["总工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"]
@app.route("/login")
def login_page():
@@ -90,7 +90,7 @@ def auth_login():
session["role"] = user["role"]
# 管理员可看所有工作台OPC负责人看分配的工作台
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]
@@ -113,10 +113,14 @@ def auth_logout():
def auth_me():
if "user_id" not in session:
return jsonify({"logged_in": False})
tenants = session.get("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"]},
"tenants": session.get("tenants", []),
"tenants": tenants,
})
@@ -748,10 +752,88 @@ def bootstrap():
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
# 验证用户是否有权限访问该 workbench
allowed = session.get("tenants", [])
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 != "总工作台"]
all_metrics = []
all_monthly = []
all_recent = []
for t in real_tenants:
t_pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [t])
t_ops = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [t]))
t_sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", [t]))
t_products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [t]))
t_proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", [t]))
t_signed_pfs = [x for x in t_pfs if x["status"] == "已签约"]
def t_parse_budget(pf):
try:
budget = json.loads(pf.get("budget_data") or "[]")
except (json.JSONDecodeError, TypeError):
budget = []
return {(b.get("month") or "").replace("-", "_"): b for b in budget}
t_bm = {pf["id"]: t_parse_budget(pf) for pf in t_pfs}
def t_sum_budget(field, months_range):
total = 0
for pf in t_pfs:
bm = t_bm.get(pf["id"], {})
for m in months_range:
b = bm.get(f"2026_{m:02d}")
if b:
total += float(b.get(field) or 0)
return total
_now_month = date.today().month
_q_start = ((_now_month - 1) // 3) * 3 + 1
_q_range = range(_q_start, _q_start + 3)
_q_months = [f"2026-{m:02d}" for m in _q_range]
all_metrics.append({
"total_projects": len(t_signed_pfs),
"total_proposals": len(t_ops),
"total_products": len(t_proposals),
"upcoming_products": len(t_products),
"signed_amount": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约"),
"signed_annual": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约"),
"signed_q2": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months),
"signed_month": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"2026-{_now_month:02d}"),
"revenue_annual": t_sum_budget("rev", range(1, 13)),
"revenue_q2": t_sum_budget("rev", _q_range),
"monthly_revenue": t_sum_budget("rev", [_now_month]),
"gross_annual": t_sum_budget("gross", range(1, 13)),
"gross_q2": t_sum_budget("gross", _q_range),
"monthly_net_profit": t_sum_budget("gross", [_now_month]),
"payment_annual": t_sum_budget("payment", range(1, 13)),
"payment_q2": t_sum_budget("payment", _q_range),
"payment_month": t_sum_budget("payment", [_now_month]),
"cost_annual": t_sum_budget("cost", range(1, 13)),
"cost_q2": t_sum_budget("cost", _q_range),
"cost_month": t_sum_budget("cost", [_now_month]),
})
all_monthly.append(monthly_finance(conn, t))
all_recent.extend(rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 4", [t]))
# 合并 metrics
agg = {}
for key in ["total_projects","total_proposals","total_products","upcoming_products","signed_amount","signed_annual","signed_q2","signed_month","revenue_annual","revenue_q2","monthly_revenue","gross_annual","gross_q2","monthly_net_profit","payment_annual","payment_q2","payment_month","cost_annual","cost_q2","cost_month"]:
agg[key] = sum(m.get(key, 0) for m in all_metrics)
# 合并 monthly finance按月累加
merged_monthly = []
for i in range(12):
m = {"month": all_monthly[0][i]["month"] if all_monthly and len(all_monthly[0]) > i else f"2026-{i+1:02d}"}
for field in ["revenue","gross","payment","cost","sign"]:
m[field] = sum(tl[i][field] if i < len(tl) else 0 for tl in all_monthly)
merged_monthly.append(m)
summary = {
"project_name": "总工作台",
"metrics": agg,
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
"risks": [],
}
return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "tenant": tenant, "tenants": allowed})
def q(sql, *args):
return rows(conn, sql, args)
sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
@@ -859,7 +941,7 @@ TABLES = {
"products": ("product_versions", ["product_name", "version", "version_goal", "priority", "start_date", "plan_date", "dev_done_date", "test_date", "launch_date", "status", "notes", "tenant"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "budget_data"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data"]),
}

View File

@@ -67,6 +67,14 @@ def migrate_add_columns():
_add_column_if_missing(conn, "project_finances", col,
f"ALTER TABLE project_finances ADD COLUMN {col} DOUBLE NOT NULL DEFAULT 0")
# project_finances 总视图字段:已回款 / 应付 / 已付
_add_column_if_missing(conn, "project_finances", "total_payment",
"ALTER TABLE project_finances ADD COLUMN total_payment DOUBLE NOT NULL DEFAULT 0")
_add_column_if_missing(conn, "project_finances", "total_cost",
"ALTER TABLE project_finances ADD COLUMN total_cost DOUBLE NOT NULL DEFAULT 0")
_add_column_if_missing(conn, "project_finances", "total_paid",
"ALTER TABLE project_finances ADD COLUMN total_paid DOUBLE NOT NULL DEFAULT 0")
conn.commit()
print("[migrate] 加列迁移完成")
finally:

View File

@@ -13,8 +13,13 @@ const savedTab = localStorage.getItem("opc-active-tab");
// 初始化
applyUserTenants();
updateSidebarTabs();
load().then(() => {
if (savedTab && savedTab !== "home") switchTab(savedTab);
if (state.tenant === "总工作台") {
switchTab("home");
} else if (savedTab && savedTab !== "home") {
switchTab(savedTab);
}
}).catch((error) => {
document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${esc(error.message)}</section>`;
});

View File

@@ -73,47 +73,47 @@ function renderFinance() {
const budget = JSON.parse(pf.budget_data || "[]");
budget.forEach(b => { budgetMap[(b.month || "").replace("-", "_")] = b; });
} catch (e) {}
const isRevView = state.finView !== "cashflow";
const isRevView = state.finView !== "cashflow" && state.finView !== "overview" && state.finView !== "monthly";
const mCols = months.map(m => {
const b = budgetMap[m] || {};
if (isRevView) {
const rev = b.rev || 0;
const gross = b.gross || 0;
return `<td class="p-2 text-right whitespace-nowrap"><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="p-2 text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
} else {
const payment = b.payment || 0;
const cost = b.cost || 0;
return `<td class="p-2 text-right whitespace-nowrap"><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="p-2 text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
}
}).join("");
const totalCol = (() => {
if (isRevView) {
const totalRev = pf.total_rev || 0;
const totalGross = pf.total_gross || 0;
return `<td class="p-2 text-right whitespace-nowrap 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="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
} else {
let totalPayment = 0, totalCost = 0;
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
return `<td class="p-2 text-right whitespace-nowrap 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="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
}
})();
const sm = pf.sign_month || "";
const signMonthCell = `<td class="p-2 text-center text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center">${esc(pf.customer_name)}</td><td class="p-2 text-sm text-center">${esc(pf.business_type)}</td><td class="p-2 text-sm text-center">${pf.status === "已签约" ? badge("已签约") : pf.status === "流程中" ? badge("流程中","blue") : badge("待签约","amber")}</td>${signMonthCell}<td class="p-2 text-center text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}<td class="p-2 text-sm text-slate-500 text-center">${esc(pf.sales_person) || "—"}</td><td class="p-2 text-sm text-slate-500 text-center">${esc(pf.owner) || "—"}</td></tr>`;
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">${esc(pf.customer_name)}</td><td class="p-2 text-sm text-center">${esc(pf.business_type)}</td><td class="p-2 text-sm text-center">${pf.status === "已签约" ? badge("已签约") : pf.status === "流程中" ? badge("流程中","blue") : badge("待签约","amber")}</td>${signMonthCell}<td class="p-2 text-center text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
};
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
<div class="grid grid-cols-6 gap-3">
${[["已签项目","" + signed.length,"file-check-2"],["签约金额",moneyInt(sumSign),"coins"],["流程项目","" + inContract.length,"file-clock"],["流程金额",moneyInt(sumContract),"clock"],["待签项目","" + pending.length,"file-question"],["待签金额",moneyInt(sumPending),"hourglass"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
<div class="grid grid-cols-4 gap-3">
${[["已签项目","" + signed.length,"file-check-2"],["签约金额",moneyInt(sumSign),"coins"],["待签项目","" + pending.length,"file-question"],["待签金额",moneyInt(sumPending),"hourglass"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
</div>
<div class="grid grid-cols-5 gap-3">
${[["本月确收",moneyInt(thisMonthRev),"trending-up"],["本月毛利",moneyInt(thisMonthGross),"percent"],["本月回款",moneyInt(monthPayment),"wallet"],["本月费用",moneyInt(monthCost),"receipt"],["本月现金流",moneyInt(monthCashflow),"repeat"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
${[["本月确收",moneyInt(thisMonthRev),"trending-up"],["本月毛利",moneyInt(thisMonthGross),"percent"],["本月回款",moneyInt(monthPayment),"wallet"],["本月应付",moneyInt(monthCost),"receipt"],["本月现金流",moneyInt(monthCashflow),"repeat"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
</div>
<div class="flex justify-between items-center"><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView !== 'cashflow' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="rev" onclick="setFinView('rev')" title="确收/毛利视图"><i data-lucide="trending-up" style="width:16px;height:16px"></i><span class="text-xs ml-1">确收/毛利</span></button><button class="btn btn-sm ${state.finView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="cashflow" onclick="setFinView('cashflow')" title="回款/费用视图"><i data-lucide="wallet" style="width:16px;height:16px"></i><span class="text-xs ml-1">回款/费用</span></button></div><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
<div class="flex justify-between items-center"><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView === 'overview' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="overview" onclick="setFinView('overview')" title="视图"><i data-lucide="layout-dashboard" style="width:16px;height:16px"></i><span class="text-xs ml-1">总视图</span></button><button class="btn btn-sm ${state.finView === 'monthly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="monthly" onclick="setFinView('monthly')" title="月度视图"><i data-lucide="calendar-days" style="width:16px;height:16px"></i><span class="text-xs ml-1">月度视图</span></button></div><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
<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-5xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
<button class="finance-tab" data-tab="budget" onclick="switchFinanceTab('budget')"><i data-lucide="calendar" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>月度预算</button>
<button class="finance-tab" data-tab="budget" onclick="switchFinanceTab('budget')"><i data-lucide="calendar" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>月度流水</button>
</div>
<form onsubmit="createFinance(event)" class="p-8 grid gap-6"><input type="hidden" name="pf_id" id="pf-id-input" value="">
<div id="financeTabInfo">
@@ -143,7 +143,7 @@ function renderFinance() {
</div>
</div>
<div id="financeTabBudget" class="hidden">
<div class="grid grid-cols-4 gap-3 mb-4" id="budgetSummary">
<div class="grid grid-cols-5 gap-3 mb-4" id="budgetSummary">
<div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100">
<p class="text-xs text-blue-600 font-medium">总确收</p>
<p class="text-lg font-bold text-blue-700" id="budgetTotalRev">¥0</p>
@@ -157,18 +157,66 @@ function renderFinance() {
<p class="text-lg font-bold text-amber-700" id="budgetTotalPayment">¥0</p>
</div>
<div class="bg-rose-50 rounded-lg p-3 text-center border border-rose-100" id="budgetTotalCostCard">
<p class="text-xs text-rose-600 font-medium">总费用</p>
<p class="text-xs text-rose-600 font-medium">总应付</p>
<p class="text-lg font-bold text-rose-700" id="budgetTotalCost">¥0</p>
</div>
<div class="bg-purple-50 rounded-lg p-3 text-center border border-purple-100">
<p class="text-xs text-purple-600 font-medium">总已付</p>
<p class="text-lg font-bold text-purple-700" id="budgetTotalPaid">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="budgetTable">
<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-right font-medium text-slate-500">费用</th><th class="p-2.5 w-8"></th></tr></thead>
<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-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="budgetTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i>添加月份</button>
</div>
<div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
${card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">项目明细 <span class="text-slate-400 font-normal">(${pfs.length})</span></h3></div><div class="flex gap-2 mb-3">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold">项目名称</th><th class="p-2 text-center font-semibold">类型</th><th class="p-2 text-center font-semibold">状态</th><th class="p-2 text-center font-semibold">签约月份</th><th class="p-2 text-center font-semibold">签约金额</th>${monthLabels.map(l => `<th class="p-2 text-center font-semibold">${l}<br><span class="text-xs text-slate-400">${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}</span></th>`).join("")}<th class="p-2 text-center font-semibold">总计<br><span class="text-xs text-slate-400">${state.finView !== 'cashflow' ? '确收/毛利' : '回款/费用'}</span></th><th class="p-2 text-center font-semibold">商务负责人</th><th class="p-2 text-center font-semibold">经营负责人</th></tr></thead><tbody>${pfs.filter(x => x.status === state.finFilter).map(renderPfRow).join("")}</tbody></table></div>`, "p-4")}
${state.finView === 'monthly' ? (() => {
const allPfs = pfs.filter(x => x.status === state.finFilter);
const now = new Date();
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth()+1).padStart(2,"0");
if (!state.finMonth) state.finMonth = defaultMonth;
const selMonth = state.finMonth;
// 收集所有有数据的月份 + 当前月
const monthSet = new Set([defaultMonth]);
allPfs.forEach(pf => { let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {} bd.forEach(b => { if (b.month) monthSet.add(b.month); }); });
const sortedMonths = [...monthSet].sort().reverse();
const monthOpts = sortedMonths.map(m => `<option value="${m}" ${m === selMonth ? 'selected' : ''}>${m}</option>`).join("");
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
const rows = [];
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0;
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
const b = bd.find(x => (x.month||"") === selMonth) || {};
const rev = Math.round(parseFloat(b.rev||0)||0);
const payment = Math.round(parseFloat(b.payment||0)||0);
const cost = Math.round(parseFloat(b.cost||0)||0);
const paid = Math.round(parseFloat(b.paid||0)||0);
if (!rev && !payment && !cost && !paid) return;
const payDiff = rev - payment;
const costDiff = cost - paid;
const cashflow = payment - paid;
sumRev+=rev; sumPay+=payment; sumCost+=cost; sumPaid+=paid;
rows.push(`<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">${esc(pf.customer_name)}</td><td class="p-2 text-sm text-center text-slate-500">${esc(pf.business_type)}</td><td class="p-2 text-sm text-center">${pf.status === '已签约' ? badge('已签约') : pf.status === '流程中' ? badge('流程中','blue') : badge('待签约','amber')}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`);
});
return card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">月度视图 <span class="text-slate-400 font-normal">(${allPfs.length} 项目)</span></h3></div><div class="flex items-center justify-between mb-3"><div class="flex gap-2">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><label class="inline-flex items-center gap-1 text-sm text-slate-500 cursor-pointer relative"><i data-lucide="calendar" style="width:14px;height:14px"></i><select onchange="state.finMonth=this.value;renderFinance()" class="bg-transparent border-0 text-sm text-slate-600 font-medium cursor-pointer pr-5" style="appearance:none;-webkit-appearance:none;-moz-appearance:none;outline:none">${monthOpts}</select><i data-lucide="chevron-down" style="width:14px;height:14px;position:absolute;right:0;top:50%;transform:translateY(-50%);pointer-events:none"></i></label></div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">类型</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${rows.length ? rows.join("") : '<tr><td colspan="10" class="p-6 text-center text-slate-400">该月份暂无数据</td></tr>'}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="3">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
})() : (() => {
const calcTotals = (pf) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, cost = 0;
budget.forEach(b => { rev += parseFloat(b.rev||0)||0; payment += parseFloat(b.payment||0)||0; cost += parseFloat(b.cost||0)||0; });
const paid = parseFloat(pf.total_paid) || 0;
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid) };
};
const allPfs = pfs.filter(x => x.status === state.finFilter);
let sumRev=0, sumPay=0, sumCost=0, sumPaid=0;
allPfs.forEach(pf => { const t = calcTotals(pf); sumRev+=t.rev; sumPay+=t.payment; sumCost+=t.cost; sumPaid+=t.paid; });
const fmt = (v) => v ? `<span class="font-medium">${money(v)}</span>` : '<span class="text-slate-300">—</span>';
const fmtDiff = (v) => { if (!v) return '<span class="text-slate-300">—</span>'; return `<span class="${v > 0 ? 'text-amber-600 font-medium' : 'text-green-600 font-medium'}">${money(Math.abs(v))}</span>`; };
return card(`<div class="flex items-center justify-between mb-3"><h3 class="font-bold text-slate-700">总视图 <span class="text-slate-400 font-normal">(${allPfs.length})</span></h3></div><div class="flex gap-2 mb-3">${[["已签约","已签约"],["流程中","流程中"],["待签约","待签约"]].map(([k,v]) => `<button class="btn btn-sm ${state.finFilter === k ? 'btn-primary' : 'btn-ghost'}" onclick="state.finFilter='${k}';renderFinance()">${v} (${pfs.filter(x=>x.status===k).length})</button>`).join("")}</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold align-middle">项目名称</th><th class="p-2 text-center font-semibold align-middle">类型</th><th class="p-2 text-center font-semibold align-middle">状态</th><th class="p-2 text-center font-semibold align-middle text-blue-600">已确收</th><th class="p-2 text-center font-semibold align-middle text-amber-600">已回款</th><th class="p-2 text-center font-semibold align-middle">回款差额</th><th class="p-2 text-center font-semibold align-middle text-rose-600">应付</th><th class="p-2 text-center font-semibold align-middle text-purple-600">已付</th><th class="p-2 text-center font-semibold align-middle">应付差额</th><th class="p-2 text-center font-semibold align-middle text-slate-700">现金流</th></tr></thead><tbody>${allPfs.map(pf => { const t = calcTotals(pf); const payDiff = t.rev - t.payment; const costDiff = t.cost - t.paid; const cashflow = t.payment - t.paid; 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">${esc(pf.customer_name)}</td><td class="p-2 text-sm text-center text-slate-500">${esc(pf.business_type)}</td><td class="p-2 text-sm text-center">${pf.status === '已签约' ? badge('已签约') : pf.status === '流程中' ? badge('流程中','blue') : badge('待签约','amber')}</td><td class="p-2 text-center text-blue-700 align-middle">${fmt(t.rev)}</td><td class="p-2 text-center text-amber-700 align-middle">${fmt(t.payment)}</td><td class="p-2 text-center align-middle">${fmtDiff(payDiff)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmt(t.cost)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmt(t.paid)}</td><td class="p-2 text-center align-middle">${fmtDiff(costDiff)}</td><td class="p-2 text-center font-semibold align-middle ${cashflow >= 0 ? 'text-green-600' : 'text-red-600'}">${cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'}</td></tr>`; }).join("")}<tr class="border-t-2 border-slate-200 bg-slate-50 font-bold"><td class="p-2 text-center" colspan="3">合计</td><td class="p-2 text-center text-blue-700 align-middle">${money(sumRev)}</td><td class="p-2 text-center text-amber-700 align-middle">${money(sumPay)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumRev - sumPay)}</td><td class="p-2 text-center text-rose-700 align-middle">${money(sumCost)}</td><td class="p-2 text-center text-purple-700 align-middle">${money(sumPaid)}</td><td class="p-2 text-center align-middle">${fmtDiff(sumCost - sumPaid)}</td><td class="p-2 text-center font-semibold align-middle ${sumPay - sumPaid >= 0 ? 'text-green-600' : 'text-red-600'}">${money(sumPay - sumPaid)}</td></tr></tbody></table></div>`, "p-4");
})()}
</div>`;
if (window.lucide) window.lucide.createIcons();
}
@@ -187,7 +235,7 @@ window.openFinanceModal = () => {
modal.classList.remove("hidden");
};
window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '') => {
window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = '', paid = '') => {
const tbody = document.querySelector("#budgetTbody");
if (!tbody) return;
const row = document.createElement("tr");
@@ -196,6 +244,7 @@ window.addBudgetRow = (month = '', rev = '', gross = '', payment = '', cost = ''
<td><input name="budget_gross[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${gross}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_payment[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${payment}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_cost[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${cost}" oninput="updateBudgetSummary()"></td>
<td><input name="budget_paid[]" type="number" step="0.01" class="form-ctrl form-ctrl-sm w-full text-right" placeholder="0" value="${paid}" oninput="updateBudgetSummary()"></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove();updateBudgetSummary()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
tbody.appendChild(row);
if (window.lucide) window.lucide.createIcons();
@@ -206,20 +255,24 @@ window.updateBudgetSummary = () => {
const grossEl = document.querySelector("#budgetTotalGross");
const paymentEl = document.querySelector("#budgetTotalPayment");
const costEl = document.querySelector("#budgetTotalCost");
const paidEl = document.querySelector("#budgetTotalPaid");
if (!revEl || !grossEl) return;
const revInputs = document.querySelectorAll('[name="budget_rev[]"]');
const grossInputs = document.querySelectorAll('[name="budget_gross[]"]');
const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]');
const costInputs = document.querySelectorAll('[name="budget_cost[]"]');
let totalRev = 0, totalGross = 0, totalPayment = 0, totalCost = 0;
const paidInputs = document.querySelectorAll('[name="budget_paid[]"]');
let totalRev = 0, totalGross = 0, totalPayment = 0, totalCost = 0, totalPaid = 0;
revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; });
grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; });
paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; });
costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; });
paidInputs.forEach(el => { totalPaid += parseFloat(el.value) || 0; });
revEl.textContent = money(totalRev);
grossEl.textContent = money(totalGross);
if (paymentEl) paymentEl.textContent = money(totalPayment);
if (costEl) costEl.textContent = money(totalCost);
if (paidEl) paidEl.textContent = money(totalPaid);
};
window.initBudgetTable = (budgetData) => {
@@ -227,7 +280,7 @@ window.initBudgetTable = (budgetData) => {
if (!tbody) return;
tbody.innerHTML = "";
const rows = budgetData || [];
rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || ''));
rows.forEach(r => addBudgetRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.cost || '', r.paid || ''));
setTimeout(() => updateBudgetSummary(), 50);
};
@@ -315,8 +368,9 @@ window.createFinance = async (event) => {
const grosses = form.querySelectorAll('[name="budget_gross[]"]');
const payments = form.querySelectorAll('[name="budget_payment[]"]');
const costs = form.querySelectorAll('[name="budget_cost[]"]');
const paids = form.querySelectorAll('[name="budget_paid[]"]');
const budgetRows = [];
let totalRev = 0, totalGross = 0;
let totalRev = 0, totalGross = 0, totalPaidFromBudget = 0;
for (let i = 0; i < months.length; i++) {
const m = months[i].value.trim();
if (!m) continue;
@@ -324,13 +378,20 @@ window.createFinance = async (event) => {
const gross = parseFloat(grosses[i].value) || 0;
const payment = parseFloat(payments[i].value) || 0;
const cost = parseFloat(costs[i].value) || 0;
budgetRows.push({ month: m, rev, gross, payment, cost });
const paid = parseFloat(paids[i].value) || 0;
budgetRows.push({ month: m, rev, gross, payment, cost, paid });
totalRev += rev;
totalGross += gross;
totalPaidFromBudget += paid;
}
data.budget_data = JSON.stringify(budgetRows);
data.total_rev = totalRev;
data.total_gross = totalGross;
data.total_paid = totalPaidFromBudget;
let totalPayment = 0, totalCost = 0;
for (const r of budgetRows) { totalPayment += r.payment; totalCost += r.cost; }
data.total_payment = totalPayment;
data.total_cost = totalCost;
const pfId = data.pf_id;
delete data.pf_id;
try {

View File

@@ -32,14 +32,14 @@ function renderHome() {
const tblCard = (title, rows) => card(`<h3 class="text-sm font-bold text-slate-700 mb-3">${title}</h3><table class="w-full text-sm"><tbody>${rows.map(([label, value]) => `<tr class="border-b border-slate-100 last:border-0"><td class="py-2 pr-4 text-slate-500">${label}</td><td class="py-2 text-right font-semibold text-slate-800">${value}</td></tr>`).join("")}</tbody></table>`, "p-4");
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
<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"],
["业务方案", m.total_products, "proposals"],
["产品迭代", m.upcoming_products, "products"],
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
</div>
</div>`}
<div class="grid grid-cols-5 gap-5">${tblCard("合同金额", rows1)}${tblCard("确收金额", rows2)}${tblCard("确收毛利", rows3)}${tblCard("回款金额", rows4)}${tblCard("费用金额", rows5)}</div>
<div class="grid grid-cols-3 gap-5">
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="chartSign"></canvas></div>`, "p-4")}

View File

@@ -128,6 +128,18 @@ function switchTab(tab) {
render();
}
function updateSidebarTabs() {
const isOverview = state.tenant === "总工作台";
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
const tab = btn.dataset.tab;
if (isOverview && tab !== "home") {
btn.style.display = "none";
} else {
btn.style.display = "";
}
});
}
function render() {
if (!state.data) return;
renderHome();
@@ -181,6 +193,8 @@ window.switchTenant = (tenant) => {
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
const label = document.querySelector("#currentTenantLabel");
if (label) { label.textContent = tenant.replace("·无界", "") || "工作台"; label.title = tenant; }
updateSidebarTabs();
if (tenant === "总工作台") switchTab("home");
load();
};
window.doLogout = async () => {