Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5c92d09af | ||
|
|
7bf30a7cc2 | ||
|
|
e18626105b | ||
|
|
44f313050e | ||
|
|
b587530437 | ||
|
|
fad6a0e2fb | ||
|
|
9a1260db68 | ||
|
|
19d1aceb9f | ||
|
|
7c33bc05f2 | ||
|
|
153835b184 | ||
|
|
9de0a63be2 | ||
|
|
2f759b42cd | ||
|
|
f3c94c57d6 | ||
|
|
b076cabb83 | ||
|
|
9db627770b | ||
|
|
75b988c77a | ||
|
|
2b7f67436e | ||
|
|
ed5455f309 | ||
|
|
452d410843 | ||
|
|
c95373b85e | ||
|
|
6be10481d5 | ||
|
|
4ce0e66d90 | ||
|
|
0f9e1b6cf2 | ||
|
|
dc8d3d59d2 | ||
|
|
1e5c5b5706 | ||
|
|
b68144ebb0 | ||
|
|
3cae1d85cf | ||
|
|
ca0cd9eab1 | ||
|
|
2bdc0e0779 | ||
|
|
93dca56c3f | ||
|
|
8c4f50fc52 | ||
|
|
13864f1968 | ||
|
|
d11e8f9313 | ||
|
|
02a12a208c | ||
|
|
b977627895 | ||
|
|
edf8e6179c | ||
|
|
3148af05b3 | ||
|
|
4029933ada | ||
|
|
95d3f9809c | ||
|
|
8b040489e2 | ||
|
|
db9700c8ee | ||
|
|
11e30f33b9 | ||
|
|
95f52b61d0 | ||
|
|
a4012f2189 | ||
|
|
e1c5e65c1e | ||
|
|
a3d78b3ca0 | ||
|
|
9dc5631574 | ||
|
|
c5a59508d2 | ||
|
|
ab1d57492c | ||
|
|
9a81a30feb | ||
|
|
d6d21b03dd | ||
|
|
b9a05e8732 | ||
|
|
440707e19a | ||
|
|
e0dc1a56b2 | ||
|
|
f45afd954d | ||
|
|
664da22329 | ||
|
|
73a6ac126c | ||
|
|
878e26d4ca | ||
|
|
e12ed0936b | ||
|
|
8edb74f173 | ||
|
|
1a42c6e6f3 | ||
|
|
d21497bce3 | ||
|
|
574e3c5700 | ||
|
|
c70293b447 | ||
|
|
adeff08827 | ||
|
|
8bd40de41d | ||
|
|
ac6eacea82 | ||
|
|
83c2a5ca94 | ||
|
|
fbd2290d29 | ||
|
|
97fcf88c61 | ||
|
|
a01afef599 | ||
|
|
28fa244fe5 | ||
|
|
ad3885e0be | ||
|
|
0fb7ee2992 | ||
|
|
f6792cad39 | ||
|
|
bed6e9192a | ||
|
|
aaa213a765 | ||
|
|
207629a9bb |
@@ -53,3 +53,4 @@ curl http://127.0.0.1:5177/api/health
|
|||||||
- 产品:慰心斋产品路线图中的 5 个产品版本
|
- 产品:慰心斋产品路线图中的 5 个产品版本
|
||||||
- 财务:首版财务样例和原财务 manager 合并方向
|
- 财务:首版财务样例和原财务 manager 合并方向
|
||||||
|
|
||||||
|
# test trigger
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ def index():
|
|||||||
|
|
||||||
@bp.route("/api/bootstrap")
|
@bp.route("/api/bootstrap")
|
||||||
def bootstrap():
|
def bootstrap():
|
||||||
|
"""轻量级 bootstrap:只返回 summary + financeMonthly(首页用),其他模块按需加载"""
|
||||||
if "user_id" not in session:
|
if "user_id" not in session:
|
||||||
return jsonify({"error": "未登录"}), 401
|
return jsonify({"error": "未登录"}), 401
|
||||||
_year = date.today().year
|
_year = date.today().year
|
||||||
@@ -270,154 +271,20 @@ def bootstrap():
|
|||||||
tenant = allowed[0]
|
tenant = allowed[0]
|
||||||
conn = db()
|
conn = db()
|
||||||
try:
|
try:
|
||||||
# 总工作台:聚合所有工作台的首页数据
|
|
||||||
if tenant == "总览":
|
if tenant == "总览":
|
||||||
real_tenants = [t for t in allowed if t != "总览"]
|
real_tenants = [t for t in allowed if t != "总览"]
|
||||||
all_metrics = []
|
all_metrics = []
|
||||||
all_monthly = []
|
all_monthly = []
|
||||||
all_recent = []
|
all_recent = []
|
||||||
for t in real_tenants:
|
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_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_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_products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [t]))
|
||||||
t_expense = rows(conn, "SELECT * FROM expense_records WHERE tenant=?", [t])
|
|
||||||
t_proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals 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 = t_pfs
|
t_pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [t])
|
||||||
def t_parse_budget(pf):
|
t_expense = rows(conn, "SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", [t])
|
||||||
try:
|
t_metrics = compute_metrics(t_pfs, t_ops, t_sales, t_products, t_proposals, t_expense, _year, conn, t)
|
||||||
budget = json.loads(pf.get("budget_data") or "[]")
|
all_metrics.append(t_metrics)
|
||||||
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"{_year}_{m:02d}")
|
|
||||||
if b:
|
|
||||||
total += float(b.get(field) or 0)
|
|
||||||
return total
|
|
||||||
|
|
||||||
def t_parse_expense(pf):
|
|
||||||
try:
|
|
||||||
ed = json.loads(pf.get("expense_data") or "[]")
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
ed = []
|
|
||||||
return {(e.get("month") or "").replace("-", "_"): e for e in ed}
|
|
||||||
|
|
||||||
t_em = {pf["id"]: t_parse_expense(pf) for pf in t_pfs}
|
|
||||||
|
|
||||||
def t_sum_expense(field, months_range):
|
|
||||||
total = 0
|
|
||||||
for pf in t_pfs:
|
|
||||||
em = t_em.get(pf["id"], {})
|
|
||||||
for m in months_range:
|
|
||||||
e = em.get(f"{_year}_{m:02d}")
|
|
||||||
if e:
|
|
||||||
total += float(e.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"{_year}-{m:02d}" for m in _q_range]
|
|
||||||
_prev_q_start = ((_now_month - 4) // 3) * 3 + 1
|
|
||||||
_prev_q_range = range(max(_prev_q_start, 1), _prev_q_start + 3)
|
|
||||||
_prev_q_months = [f"{_year}-{m:02d}" for m in _prev_q_range]
|
|
||||||
_prev_month = _now_month - 1 if _now_month > 1 else None
|
|
||||||
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),
|
|
||||||
"signed_annual": sum(x["sign_amount"] or 0 for x in t_pfs),
|
|
||||||
"signed_q2": sum(x["sign_amount"] or 0 for x in t_pfs if (x.get("sign_month") or "")[:7] in _q_months),
|
|
||||||
"signed_month": sum(x["sign_amount"] or 0 for x in t_pfs if (x.get("sign_month") or "")[:7] == f"{_year}-{_now_month:02d}"),
|
|
||||||
"signed_prev_q": sum(x["sign_amount"] or 0 for x in t_pfs if (x.get("sign_month") or "")[:7] in _prev_q_months),
|
|
||||||
"signed_prev_month": sum(x["sign_amount"] or 0 for x in t_pfs if (x.get("sign_month") or "")[:7] == f"{_year}-{_prev_month:02d}") if _prev_month else 0,
|
|
||||||
"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]),
|
|
||||||
"revenue_prev_q": t_sum_budget("rev", _prev_q_range),
|
|
||||||
"revenue_prev_month": t_sum_budget("rev", [_prev_month]) if _prev_month else 0,
|
|
||||||
"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]),
|
|
||||||
"gross_prev_q": t_sum_budget("gross", _prev_q_range),
|
|
||||||
"gross_prev_month": t_sum_budget("gross", [_prev_month]) if _prev_month else 0,
|
|
||||||
"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]),
|
|
||||||
"payment_prev_q": t_sum_budget("payment", _prev_q_range),
|
|
||||||
"payment_prev_month": t_sum_budget("payment", [_prev_month]) if _prev_month else 0,
|
|
||||||
"cost_annual": t_sum_expense("cost", range(1, 13)),
|
|
||||||
"cost_q2": t_sum_expense("cost", _q_range),
|
|
||||||
"cost_month": t_sum_expense("cost", [_now_month]),
|
|
||||||
"cost_prev_q": t_sum_expense("cost", _prev_q_range),
|
|
||||||
"cost_prev_month": t_sum_expense("cost", [_prev_month]) if _prev_month else 0,
|
|
||||||
})
|
|
||||||
# 费用汇总(从 expense_records 表计算)
|
|
||||||
def t_expense_sum(m_range):
|
|
||||||
total = 0
|
|
||||||
for r in t_expense:
|
|
||||||
if (r.get("status") or "") == "暂不计入": continue
|
|
||||||
m = (r.get("expense_month") or "").strip()
|
|
||||||
if not m or "-" not in m: continue
|
|
||||||
try:
|
|
||||||
if int(m.split("-")[1]) in m_range:
|
|
||||||
total += float(r.get("amount") or 0)
|
|
||||||
except: pass
|
|
||||||
return total
|
|
||||||
all_metrics[-1]["expense_annual"] = t_expense_sum(range(1, 13))
|
|
||||||
all_metrics[-1]["expense_q2"] = t_expense_sum(_q_range)
|
|
||||||
all_metrics[-1]["expense_month"] = t_expense_sum([_now_month])
|
|
||||||
all_metrics[-1]["expense_prev_q"] = t_expense_sum(_prev_q_range)
|
|
||||||
all_metrics[-1]["expense_prev_month"] = t_expense_sum([_prev_month]) if _prev_month else 0
|
|
||||||
all_metrics[-1]["paid_annual"] = t_sum_expense("paid", range(1, 13))
|
|
||||||
all_metrics[-1]["paid_q2"] = t_sum_expense("paid", _q_range)
|
|
||||||
all_metrics[-1]["paid_month"] = t_sum_expense("paid", [_now_month])
|
|
||||||
all_metrics[-1]["paid_prev_q"] = t_sum_expense("paid", _prev_q_range)
|
|
||||||
all_metrics[-1]["paid_prev_month"] = t_sum_expense("paid", [_prev_month]) if _prev_month else 0
|
|
||||||
all_metrics[-1]["proj_expense_annual"] = t_sum_budget("expense", range(1, 13))
|
|
||||||
all_metrics[-1]["proj_expense_q2"] = t_sum_budget("expense", _q_range)
|
|
||||||
all_metrics[-1]["proj_expense_month"] = t_sum_budget("expense", [_now_month])
|
|
||||||
all_metrics[-1]["proj_expense_prev_q"] = t_sum_budget("expense", _prev_q_range)
|
|
||||||
all_metrics[-1]["proj_expense_prev_month"] = t_sum_budget("expense", [_prev_month]) if _prev_month else 0
|
|
||||||
all_metrics[-1]["cashflow_annual"] = all_metrics[-1]["payment_annual"] - all_metrics[-1]["paid_annual"]
|
|
||||||
all_metrics[-1]["cashflow_q2"] = all_metrics[-1]["payment_q2"] - all_metrics[-1]["paid_q2"]
|
|
||||||
all_metrics[-1]["cashflow_month"] = all_metrics[-1]["payment_month"] - all_metrics[-1]["paid_month"]
|
|
||||||
all_metrics[-1]["cashflow_prev_q"] = all_metrics[-1]["payment_prev_q"] - all_metrics[-1]["paid_prev_q"]
|
|
||||||
all_metrics[-1]["cashflow_prev_month"] = all_metrics[-1]["payment_prev_month"] - all_metrics[-1]["paid_prev_month"]
|
|
||||||
def t_expense_paid_sum(m_range):
|
|
||||||
total = 0
|
|
||||||
for r in t_expense:
|
|
||||||
if (r.get("status") or "") == "暂不计入": continue
|
|
||||||
m = (r.get("expense_month") or "").strip()
|
|
||||||
if not m or "-" not in m: continue
|
|
||||||
try:
|
|
||||||
if int(m.split("-")[1]) in m_range:
|
|
||||||
total += float(r.get("incurred_amount") or 0)
|
|
||||||
except: pass
|
|
||||||
return total
|
|
||||||
all_metrics[-1]["expense_paid_annual"] = t_expense_paid_sum(range(1, 13))
|
|
||||||
all_metrics[-1]["expense_paid_q2"] = t_expense_paid_sum(_q_range)
|
|
||||||
all_metrics[-1]["expense_paid_month"] = t_expense_paid_sum([_now_month])
|
|
||||||
all_metrics[-1]["expense_paid_prev_q"] = t_expense_paid_sum(_prev_q_range)
|
|
||||||
all_metrics[-1]["expense_paid_prev_month"] = t_expense_paid_sum([_prev_month]) if _prev_month else 0
|
|
||||||
all_metrics[-1]["dept_cf_annual"] = all_metrics[-1]["cashflow_annual"] - all_metrics[-1]["expense_paid_annual"]
|
|
||||||
all_metrics[-1]["dept_cf_q2"] = all_metrics[-1]["cashflow_q2"] - all_metrics[-1]["expense_paid_q2"]
|
|
||||||
all_metrics[-1]["dept_cf_month"] = all_metrics[-1]["cashflow_month"] - all_metrics[-1]["expense_paid_month"]
|
|
||||||
all_metrics[-1]["dept_cf_prev_q"] = all_metrics[-1]["cashflow_prev_q"] - all_metrics[-1]["expense_paid_prev_q"]
|
|
||||||
all_metrics[-1]["dept_cf_prev_month"] = all_metrics[-1]["cashflow_prev_month"] - all_metrics[-1]["expense_paid_prev_month"]
|
|
||||||
all_metrics[-1]["profit_annual"] = all_metrics[-1]["gross_annual"] - all_metrics[-1]["proj_expense_annual"]
|
|
||||||
all_metrics[-1]["profit_q2"] = all_metrics[-1]["gross_q2"] - all_metrics[-1]["proj_expense_q2"]
|
|
||||||
all_metrics[-1]["profit_month"] = all_metrics[-1]["monthly_net_profit"] - all_metrics[-1]["proj_expense_month"]
|
|
||||||
all_metrics[-1]["profit_prev_q"] = all_metrics[-1]["gross_prev_q"] - all_metrics[-1]["proj_expense_prev_q"]
|
|
||||||
all_metrics[-1]["profit_prev_month"] = all_metrics[-1]["gross_prev_month"] - all_metrics[-1]["proj_expense_prev_month"]
|
|
||||||
all_monthly.append(monthly_finance(conn, t))
|
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]))
|
all_recent.extend(rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 4", [t]))
|
||||||
agg = {}
|
agg = {}
|
||||||
@@ -435,20 +302,15 @@ def bootstrap():
|
|||||||
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
|
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
|
||||||
"risks": [],
|
"risks": [],
|
||||||
}
|
}
|
||||||
return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "expense": [], "planFinances": [], "planExpense": [], "tenant": tenant, "tenants": allowed})
|
return jsonify({"summary": summary, "financeMonthly": merged_monthly, "tenant": tenant, "tenants": allowed})
|
||||||
|
|
||||||
def q(sql, *args):
|
# 普通工作台:只查 summary 所需的数据(project_finances + operations + sales + products + proposals + expense)
|
||||||
return rows(conn, sql, args)
|
pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [tenant])
|
||||||
sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
|
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [tenant]))
|
||||||
proposals = attach_common(conn, "proposals", q("SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", tenant))
|
sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", [tenant]))
|
||||||
operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", tenant))
|
products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [tenant]))
|
||||||
products = attach_common(conn, "products", q("SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", tenant))
|
proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", [tenant]))
|
||||||
finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant)
|
expense = rows(conn, "SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", [tenant])
|
||||||
tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant)
|
|
||||||
pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant)
|
|
||||||
expense = q("SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", tenant)
|
|
||||||
planPfs = q("SELECT * FROM plan_finances WHERE tenant=? ORDER BY id DESC", tenant)
|
|
||||||
planExpense = q("SELECT * FROM plan_expense_records WHERE tenant=? ORDER BY id DESC", tenant)
|
|
||||||
signed_pfs = pfs
|
signed_pfs = pfs
|
||||||
|
|
||||||
def parse_budget(pf):
|
def parse_budget(pf):
|
||||||
@@ -581,7 +443,7 @@ def bootstrap():
|
|||||||
pipeline_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] not in ["已签约","已丢单","已归档","已完成"])
|
pipeline_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] not in ["已签约","已丢单","已归档","已完成"])
|
||||||
signed_not_executed = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_type"] == "execution" and x["execution_progress"] < 100)
|
signed_not_executed = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_type"] == "execution" and x["execution_progress"] < 100)
|
||||||
summary = {
|
summary = {
|
||||||
"project_name": "科普(慰心斋)",
|
"project_name": tenant,
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"p0_customers": len([x for x in sales if x["priority"] == "P0"]),
|
"p0_customers": len([x for x in sales if x["priority"] == "P0"]),
|
||||||
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
|
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
|
||||||
@@ -656,10 +518,10 @@ def bootstrap():
|
|||||||
"proj_expense_prev_month": proj_expense_prev_month,
|
"proj_expense_prev_month": proj_expense_prev_month,
|
||||||
"signed_not_executed": signed_not_executed,
|
"signed_not_executed": signed_not_executed,
|
||||||
},
|
},
|
||||||
"recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
|
"recent": rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", [tenant]),
|
||||||
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
|
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
|
||||||
}
|
}
|
||||||
return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "expense": expense, "planFinances": planPfs, "planExpense": planExpense, "tenant": tenant, "tenants": allowed})
|
return jsonify({"summary": summary, "financeMonthly": monthly_finance(conn, tenant), "tenant": tenant, "tenants": allowed})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -110,11 +110,12 @@ function renderFinance() {
|
|||||||
const nowD = new Date();
|
const nowD = new Date();
|
||||||
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
|
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
|
||||||
const activeMonths = qMonths[activeQ];
|
const activeMonths = qMonths[activeQ];
|
||||||
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 yearOpts = [2024,2025,2026,2027];
|
||||||
|
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><select onchange="setFinYear(this.value)" class="px-2 py-0.5 rounded text-sm font-medium border border-slate-200 bg-white">${yearOpts.map(y => `<option value="${y}" ${state.finYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="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 = `${state.finYear}-${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 finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
||||||
|
|
||||||
const isOverviewMode = state.tenant === "总览";
|
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>`;
|
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-2">
|
document.querySelector("#finance").innerHTML = `<div class="grid gap-2">
|
||||||
${finFilterTabs}
|
${finFilterTabs}
|
||||||
@@ -289,6 +290,7 @@ function renderFinance() {
|
|||||||
return h;
|
return h;
|
||||||
};
|
};
|
||||||
const SIGNED_COLS = [
|
const SIGNED_COLS = [
|
||||||
|
{ key: '_expand', label: '' },
|
||||||
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||||
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
||||||
{ key: 'payment', label: '回款' }, { key: 'pay_rate', label: '回款率' },
|
{ key: 'payment', label: '回款' }, { key: 'pay_rate', label: '回款率' },
|
||||||
@@ -330,7 +332,24 @@ function renderFinance() {
|
|||||||
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||||
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
||||||
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
const 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="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>`;
|
var budgetArr = [];
|
||||||
|
var expenseArr = [];
|
||||||
|
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
||||||
|
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
|
||||||
|
var detailRows = budgetArr.filter(function(b) {
|
||||||
|
var exp = expenseArr.find(function(e) { return e.month === (b.month || ''); }) || {};
|
||||||
|
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||||
|
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
|
return bRev || bGross || bPay || eCost || ePaid;
|
||||||
|
}).map(function(b) {
|
||||||
|
var m = b.month || '';
|
||||||
|
var exp = expenseArr.find(function(e) { return e.month === m; }) || {};
|
||||||
|
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||||
|
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
|
var bCf = bPay - ePaid;
|
||||||
|
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
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" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><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><tr class="hidden" id="fin_expand_${pf.id}"><td colspan="13"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||||
};
|
};
|
||||||
// 待签约简版行
|
// 待签约简版行
|
||||||
const tdRowUnsigned = (pf, cost, gross) => {
|
const tdRowUnsigned = (pf, cost, gross) => {
|
||||||
@@ -390,12 +409,12 @@ function renderFinance() {
|
|||||||
const qRange = qRanges[selQ];
|
const qRange = qRanges[selQ];
|
||||||
const sumBudget = (pf, field) => {
|
const sumBudget = (pf, field) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {}
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.finYear) total += parseFloat(b[field] || 0); }); } catch (e) {}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
const sumExpense = (pf, field) => {
|
const sumExpense = (pf, field) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(e[field] || 0); }); } catch (e) {}
|
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.finYear) total += parseFloat(e[field] || 0); }); } catch (e) {}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
var dataItems = [];
|
var dataItems = [];
|
||||||
@@ -751,6 +770,19 @@ window.deleteFinFollowup = async (event, followupId) => {
|
|||||||
if (pfId) await loadFinFollowups(pfId);
|
if (pfId) await loadFinFollowups(pfId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.finToggleExpand = (pfId) => {
|
||||||
|
var row = document.getElementById('fin_expand_' + pfId);
|
||||||
|
var icon = document.getElementById('fin_expand_icon_' + pfId);
|
||||||
|
if (!row) return;
|
||||||
|
if (row.classList.contains('hidden')) {
|
||||||
|
row.classList.remove('hidden');
|
||||||
|
if (icon) { icon.setAttribute('data-lucide', 'chevron-down'); if (window.lucide) window.lucide.createIcons(); }
|
||||||
|
} else {
|
||||||
|
row.classList.add('hidden');
|
||||||
|
if (icon) { icon.setAttribute('data-lucide', 'chevron-right'); if (window.lucide) window.lucide.createIcons(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.openPfEditModal = (pfId) => {
|
window.openPfEditModal = (pfId) => {
|
||||||
const pf = (state.data.projectFinances || []).find(x => x.id === pfId);
|
const pf = (state.data.projectFinances || []).find(x => x.id === pfId);
|
||||||
if (!pf) return;
|
if (!pf) return;
|
||||||
@@ -1035,9 +1067,9 @@ function renderFinanceOverview() {
|
|||||||
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(支出)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
var cfCard = card('<h3 class="text-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-2.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 +
|
deptCard +
|
||||||
cfCard +
|
cfCard +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务月度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
'<div class="grid grid-cols-3 gap-2.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="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="finChartRev"></canvas></div>', 'p-4') +
|
||||||
|
|||||||
@@ -108,11 +108,12 @@ function renderPlan() {
|
|||||||
const nowD = new Date();
|
const nowD = new Date();
|
||||||
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
|
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
|
||||||
const activeMonths = qMonths[activeQ];
|
const activeMonths = qMonths[activeQ];
|
||||||
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 yearOpts = [2024,2025,2026,2027];
|
||||||
|
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setPlanView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">年份:</span><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 rounded text-sm font-medium border border-slate-200 bg-white">${yearOpts.map(y => `<option value="${y}" ${state.planYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">月度:</span>${activeMonths.map(m => { const ms = `${state.planYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='monthly'&&state.planMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
||||||
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
||||||
|
|
||||||
const isOverviewMode = state.tenant === "总览";
|
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>`;
|
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">经营月报</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用计划</button>`}</div>`;
|
||||||
|
|
||||||
document.querySelector("#plan").innerHTML = `<div class="grid gap-2">
|
document.querySelector("#plan").innerHTML = `<div class="grid gap-2">
|
||||||
${planFilterTabs}
|
${planFilterTabs}
|
||||||
@@ -290,6 +291,7 @@ function renderPlan() {
|
|||||||
return h;
|
return h;
|
||||||
};
|
};
|
||||||
const SIGNED_COLS = [
|
const SIGNED_COLS = [
|
||||||
|
{ key: '_expand', label: '' },
|
||||||
{ key: 'client_name', label: '客户' },
|
{ key: 'client_name', label: '客户' },
|
||||||
{ key: 'customer_name', label: '项目名称' },
|
{ key: 'customer_name', label: '项目名称' },
|
||||||
{ key: 'weight', label: '加权' },
|
{ key: 'weight', label: '加权' },
|
||||||
@@ -345,7 +347,24 @@ function renderPlan() {
|
|||||||
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||||
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
||||||
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
const 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="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>`;
|
var budgetArr = [];
|
||||||
|
var expenseArr = [];
|
||||||
|
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
||||||
|
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
|
||||||
|
var detailRows = budgetArr.filter(function(b) {
|
||||||
|
var exp = expenseArr.find(function(e) { return e.month === (b.month || ''); }) || {};
|
||||||
|
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||||
|
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
|
return bRev || bGross || bPay || eCost || ePaid;
|
||||||
|
}).map(function(b) {
|
||||||
|
var m = b.month || '';
|
||||||
|
var exp = expenseArr.find(function(e) { return e.month === m; }) || {};
|
||||||
|
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||||
|
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
|
var bCf = bPay - ePaid;
|
||||||
|
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm">${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><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||||
};
|
};
|
||||||
// 待签约简版行
|
// 待签约简版行
|
||||||
const tdRowUnsigned = (pf, cost, gross) => {
|
const tdRowUnsigned = (pf, cost, gross) => {
|
||||||
@@ -412,12 +431,12 @@ function renderPlan() {
|
|||||||
const qRange = qRanges[selQ];
|
const qRange = qRanges[selQ];
|
||||||
const sumBudget = (pf, field) => {
|
const sumBudget = (pf, field) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {}
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.planYear) total += parseFloat(b[field] || 0); }); } catch (e) {}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
const sumExpense = (pf, field) => {
|
const sumExpense = (pf, field) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(e[field] || 0); }); } catch (e) {}
|
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.planYear) total += parseFloat(e[field] || 0); }); } catch (e) {}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
let dataItems = [];
|
let dataItems = [];
|
||||||
@@ -718,7 +737,7 @@ window.planEditSignMonth = (event, pfId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.switchPlanTab = (tab) => {
|
window.switchPlanTab = (tab) => {
|
||||||
document.querySelectorAll(".plan-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
document.querySelectorAll("#planModal .finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
||||||
document.querySelector("#plan_financeTabInfo").classList.toggle("hidden", tab !== "info");
|
document.querySelector("#plan_financeTabInfo").classList.toggle("hidden", tab !== "info");
|
||||||
document.querySelector("#plan_financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
|
document.querySelector("#plan_financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
|
||||||
document.querySelector("#plan_financeTabCost").classList.toggle("hidden", tab !== "cost");
|
document.querySelector("#plan_financeTabCost").classList.toggle("hidden", tab !== "cost");
|
||||||
@@ -781,6 +800,19 @@ window.planDeleteFollowup = async (event, followupId) => {
|
|||||||
if (pfId) await planLoadFollowups(pfId);
|
if (pfId) await planLoadFollowups(pfId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.planToggleExpand = (pfId) => {
|
||||||
|
var row = document.getElementById('plan_expand_' + pfId);
|
||||||
|
var icon = document.getElementById('plan_expand_icon_' + pfId);
|
||||||
|
if (!row) return;
|
||||||
|
if (row.classList.contains('hidden')) {
|
||||||
|
row.classList.remove('hidden');
|
||||||
|
if (icon) { icon.setAttribute('data-lucide', 'chevron-down'); if (window.lucide) window.lucide.createIcons(); }
|
||||||
|
} else {
|
||||||
|
row.classList.add('hidden');
|
||||||
|
if (icon) { icon.setAttribute('data-lucide', 'chevron-right'); if (window.lucide) window.lucide.createIcons(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.planOpenPfEditModal = (pfId) => {
|
window.planOpenPfEditModal = (pfId) => {
|
||||||
const pf = (state.data.planFinances || []).find(x => x.id === pfId);
|
const pf = (state.data.planFinances || []).find(x => x.id === pfId);
|
||||||
if (!pf) return;
|
if (!pf) return;
|
||||||
@@ -1111,9 +1143,9 @@ function renderPlanOverview() {
|
|||||||
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
var cfCard = card('<h3 class="text-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-2.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 +
|
deptCard +
|
||||||
cfCard +
|
cfCard +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 已付</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
'<div class="grid grid-cols-3 gap-2.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="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="planChartRev"></canvas></div>', 'p-4') +
|
||||||
@@ -1246,9 +1278,9 @@ function renderPlanQuarterlyOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
|
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>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
||||||
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出 → 回款 → 项目现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
'</div>';
|
'</div>';
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function renderPlanExpense() {
|
|||||||
|
|
||||||
var modal = '<div id="planExpenseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanExpModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between"><h3 class="text-lg font-bold text-slate-800" id="planExpenseModalTitle">新增费用</h3><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planExpDeleteBtn" onclick="deletePlanExpItem()">删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanExpModal()"><i data-lucide="x"></i></button></div></div><form onsubmit="savePlanExpense(event)" class="p-6 grid gap-4" novalidate><input type="hidden" name="expense_id" value=""><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用类型 <span class="text-red-500">*</span></span><select name="expense_type" required class="form-ctrl bg-white"><option value="">请选择</option>';
|
var modal = '<div id="planExpenseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanExpModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between"><h3 class="text-lg font-bold text-slate-800" id="planExpenseModalTitle">新增费用</h3><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planExpDeleteBtn" onclick="deletePlanExpItem()">删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanExpModal()"><i data-lucide="x"></i></button></div></div><form onsubmit="savePlanExpense(event)" class="p-6 grid gap-4" novalidate><input type="hidden" name="expense_id" value=""><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用类型 <span class="text-red-500">*</span></span><select name="expense_type" required class="form-ctrl bg-white"><option value="">请选择</option>';
|
||||||
EXPENSE_TYPES.forEach(function(t) { modal += '<option>' + t + '</option>'; });
|
EXPENSE_TYPES.forEach(function(t) { modal += '<option>' + t + '</option>'; });
|
||||||
modal += '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">月份 <span class="text-red-500">*</span></span><select name="expense_month" required class="form-ctrl bg-white">' + modalMonthOpts + '</select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">金额(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">已发生金额(元)</span><input name="incurred_amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><label class="block"><span class="text-sm text-slate-500 mb-1 block">状态</span><select name="status" class="form-ctrl bg-white">' + statusOpts + '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用说明</span><textarea name="notes" class="form-ctrl" rows="3" placeholder="备注信息"></textarea></label><div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closePlanExpModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>';
|
modal += '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">月份 <span class="text-red-500">*</span></span><select name="expense_month" required class="form-ctrl bg-white">' + modalMonthOpts + '</select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用计提(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">已发生费用计提(元)</span><input name="incurred_amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><label class="block"><span class="text-sm text-slate-500 mb-1 block">状态</span><select name="status" class="form-ctrl bg-white">' + statusOpts + '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用说明</span><textarea name="notes" class="form-ctrl" rows="3" placeholder="备注信息"></textarea></label><div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closePlanExpModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>';
|
||||||
|
|
||||||
var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
|
var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -123,10 +123,10 @@ function renderPlanExpense() {
|
|||||||
'<div class="grid grid-cols-3 gap-3">' +
|
'<div class="grid grid-cols-3 gap-3">' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
|
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
|
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
|
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">现金支出</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
modal +
|
modal +
|
||||||
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">已发生金额' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
|
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">现金支出' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const state = {
|
|||||||
finView: "overview",
|
finView: "overview",
|
||||||
finSort: null, // { key: 'col', asc: true }
|
finSort: null, // { key: 'col', asc: true }
|
||||||
planFilter: "projects",
|
planFilter: "projects",
|
||||||
|
planYear: new Date().getFullYear(),
|
||||||
|
finYear: new Date().getFullYear(),
|
||||||
planView: "overview",
|
planView: "overview",
|
||||||
planSort: null,
|
planSort: null,
|
||||||
proposalTab: "standard",
|
proposalTab: "standard",
|
||||||
@@ -116,13 +118,13 @@ function renderTable(headers, rows, rowClicks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _loadingSteps = [
|
var _loadingSteps = [
|
||||||
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)' },
|
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return state.tenant !== '总览'; } },
|
||||||
{ fn: 'renderProjects', label: '正在加载业务机会列表' },
|
{ fn: 'renderProjects', label: '正在加载业务机会列表' },
|
||||||
{ fn: 'renderProposals', label: '正在加载业务方案列表' },
|
{ fn: 'renderProposals', label: '正在加载业务方案列表' },
|
||||||
{ fn: 'renderProducts', label: '正在加载产品迭代列表' },
|
{ fn: 'renderProducts', label: '正在加载产品迭代列表' },
|
||||||
{ fn: 'renderPerformance', label: '正在加载绩效管理' },
|
{ fn: 'renderPerformance', label: '正在加载绩效管理' },
|
||||||
{ fn: 'renderFinance', label: '正在加载财务模块(表格+总览+图表)' },
|
{ fn: 'renderFinance', label: '正在加载执行模块(表格+总览+图表)' },
|
||||||
{ fn: 'renderPlan', label: '正在加载计划预算' },
|
{ fn: 'renderPlan', label: '正在加载预算模块' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function showLoadingOverlay(steps) {
|
function showLoadingOverlay(steps) {
|
||||||
@@ -167,31 +169,91 @@ function hideLoadingOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
showLoadingOverlay(_loadingSteps);
|
var allSteps = [{ label: '正在加载工作台数据(查询10+张表+计算财务汇总)' }].concat(_loadingSteps);
|
||||||
|
showLoadingOverlay(allSteps);
|
||||||
state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
|
state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
|
||||||
// 首次加载时确保标准资料库 7 项已初始化
|
// 初始化空数据,按需加载
|
||||||
if (typeof ensureStandardProposals === "function") {
|
if (!state.data.sales) state.data.sales = [];
|
||||||
const existing = (state.data.proposals || []).filter(p => p.proposal_type === "标准资料");
|
if (!state.data.proposals) state.data.proposals = [];
|
||||||
|
if (!state.data.operations) state.data.operations = [];
|
||||||
|
if (!state.data.products) state.data.products = [];
|
||||||
|
if (!state.data.finance) state.data.finance = [];
|
||||||
|
if (!state.data.tasks) state.data.tasks = [];
|
||||||
|
if (!state.data.projectFinances) state.data.projectFinances = [];
|
||||||
|
if (!state.data.expense) state.data.expense = [];
|
||||||
|
if (!state.data.planFinances) state.data.planFinances = [];
|
||||||
|
if (!state.data.planExpense) state.data.planExpense = [];
|
||||||
|
clearTabCache();
|
||||||
|
updateLoadingStep(0, allSteps.length);
|
||||||
|
// 按需加载当前 tab 的数据(必须在 ensureStandardProposals 之前,否则会被 return 跳过)
|
||||||
|
await ensureTabData(state.active);
|
||||||
|
// 首次加载时确保标准资料库 7 项已初始化(仅首次,避免每次切换工作台都触发)
|
||||||
|
if (!_proposalsInitialized && typeof ensureStandardProposals === "function") {
|
||||||
|
_proposalsInitialized = true;
|
||||||
|
try {
|
||||||
|
const proposalsData = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant));
|
||||||
|
state.data.proposals = proposalsData;
|
||||||
|
const existing = (proposalsData || []).filter(p => p.proposal_type === "标准资料");
|
||||||
const missingCount = 7 - existing.length;
|
const missingCount = 7 - existing.length;
|
||||||
if (missingCount > 0) {
|
if (missingCount > 0) {
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
await ensureStandardProposals();
|
await ensureStandardProposals();
|
||||||
return; // ensureStandardProposals 内部会再次 render
|
return;
|
||||||
}
|
}
|
||||||
|
} catch(e) { /* non-critical */ }
|
||||||
}
|
}
|
||||||
// 逐步渲染
|
// 逐步渲染
|
||||||
for (var i = 0; i < _loadingSteps.length; i++) {
|
for (var i = 0; i < _loadingSteps.length; i++) {
|
||||||
var step = _loadingSteps[i];
|
var step = _loadingSteps[i];
|
||||||
|
if (step.skip && step.skip()) {
|
||||||
|
updateLoadingStep(i + 1, allSteps.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (typeof window[step.fn] === 'function') {
|
if (typeof window[step.fn] === 'function') {
|
||||||
window[step.fn]();
|
window[step.fn]();
|
||||||
}
|
}
|
||||||
updateLoadingStep(i, _loadingSteps.length);
|
updateLoadingStep(i + 1, allSteps.length);
|
||||||
await new Promise(function(r) { setTimeout(r, 50); });
|
await new Promise(function(r) { setTimeout(r, 50); });
|
||||||
}
|
}
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
setTimeout(hideLoadingOverlay, 300);
|
setTimeout(hideLoadingOverlay, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _tabResourceMap = {
|
||||||
|
projects: 'operations',
|
||||||
|
proposals: 'proposals',
|
||||||
|
products: 'products',
|
||||||
|
finance: 'projectFinances',
|
||||||
|
plan: 'planFinances',
|
||||||
|
};
|
||||||
|
|
||||||
|
var _tabLoaded = {};
|
||||||
|
var _proposalsInitialized = false;
|
||||||
|
|
||||||
|
async function ensureTabData(tab) {
|
||||||
|
if (tab === 'home' || tab === 'performance') return;
|
||||||
|
var resource = _tabResourceMap[tab];
|
||||||
|
if (!resource) return;
|
||||||
|
var dataKey = resource;
|
||||||
|
if (_tabLoaded[dataKey + '_' + state.tenant]) return;
|
||||||
|
_tabLoaded[dataKey + '_' + state.tenant] = true;
|
||||||
|
try {
|
||||||
|
state.data[dataKey] = await api("/api/" + resource + "/list?tenant=" + encodeURIComponent(state.tenant));
|
||||||
|
} catch(e) { console.error('ensureTabData failed:', resource, e.message); }
|
||||||
|
// Load related expense data for finance/plan tabs
|
||||||
|
if (tab === 'finance') {
|
||||||
|
try { state.data.expense = await api("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
|
try { state.data.finance = await api("/api/finance/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
|
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
|
} else if (tab === 'plan') {
|
||||||
|
try { state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTabCache() {
|
||||||
|
_tabLoaded = {};
|
||||||
|
}
|
||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
state.active = tab;
|
state.active = tab;
|
||||||
localStorage.setItem("opc-active-tab", tab);
|
localStorage.setItem("opc-active-tab", tab);
|
||||||
@@ -199,11 +261,15 @@ function switchTab(tab) {
|
|||||||
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
|
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
|
||||||
|
|
||||||
// 更新顶部标题
|
// 更新顶部标题
|
||||||
const titles = { home: 'OPC 工作台', finance: '实际管理', plan: '计划管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核' };
|
const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核' };
|
||||||
const titleEl = document.querySelector('#workspaceTitle');
|
const titleEl = document.querySelector('#workspaceTitle');
|
||||||
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
|
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
|
||||||
|
|
||||||
render();
|
// 按需加载 tab 数据
|
||||||
|
ensureTabData(tab).then(function() {
|
||||||
|
renderActive();
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSidebarTabs() {
|
function updateSidebarTabs() {
|
||||||
@@ -220,7 +286,7 @@ function updateSidebarTabs() {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!state.data) return;
|
if (!state.data) return;
|
||||||
renderHome();
|
if (state.tenant === '总览') renderHome();
|
||||||
renderProjects();
|
renderProjects();
|
||||||
renderProposals();
|
renderProposals();
|
||||||
renderProducts();
|
renderProducts();
|
||||||
@@ -269,6 +335,7 @@ window.setFinView = (view) => {
|
|||||||
};
|
};
|
||||||
window.setFinQuarter = (q) => { state.finQuarter = q; state.finView = 'quarterly'; renderFinance(); };
|
window.setFinQuarter = (q) => { state.finQuarter = q; state.finView = 'quarterly'; renderFinance(); };
|
||||||
window.setFinMonth = (month) => { state.finMonth = month; state.finView = 'monthly'; renderFinance(); };
|
window.setFinMonth = (month) => { state.finMonth = month; state.finView = 'monthly'; renderFinance(); };
|
||||||
|
window.setFinYear = (year) => { state.finYear = parseInt(year); renderFinance(); };
|
||||||
window.switchFinFilter = (filter) => {
|
window.switchFinFilter = (filter) => {
|
||||||
state.finFilter = filter;
|
state.finFilter = filter;
|
||||||
state.finSort = null;
|
state.finSort = null;
|
||||||
@@ -287,6 +354,7 @@ window.setPlanView = (view) => {
|
|||||||
};
|
};
|
||||||
window.setPlanQuarter = (q) => { state.planQuarter = q; state.planView = 'quarterly'; renderPlan(); };
|
window.setPlanQuarter = (q) => { state.planQuarter = q; state.planView = 'quarterly'; renderPlan(); };
|
||||||
window.setPlanMonth = (month) => { state.planMonth = month; state.planView = 'monthly'; renderPlan(); };
|
window.setPlanMonth = (month) => { state.planMonth = month; state.planView = 'monthly'; renderPlan(); };
|
||||||
|
window.setPlanYear = (year) => { state.planYear = parseInt(year); renderPlan(); };
|
||||||
window.switchPlanFilter = (filter) => {
|
window.switchPlanFilter = (filter) => {
|
||||||
state.planFilter = filter;
|
state.planFilter = filter;
|
||||||
state.planSort = null;
|
state.planSort = null;
|
||||||
@@ -298,7 +366,7 @@ window.setPlanSort = (key) => {
|
|||||||
else { state.planSort.key = key; state.planSort.asc = true; }
|
else { state.planSort.key = key; state.planSort.asc = true; }
|
||||||
renderPlan();
|
renderPlan();
|
||||||
};
|
};
|
||||||
window.switchTenant = (tenant) => {
|
window.switchTenant = async (tenant) => {
|
||||||
state.tenant = tenant;
|
state.tenant = tenant;
|
||||||
state.selectedProject = null;
|
state.selectedProject = null;
|
||||||
localStorage.setItem("opc-active-tenant", tenant);
|
localStorage.setItem("opc-active-tenant", tenant);
|
||||||
@@ -306,8 +374,18 @@ window.switchTenant = (tenant) => {
|
|||||||
if (typeof updateTenantLabel === "function") updateTenantLabel();
|
if (typeof updateTenantLabel === "function") updateTenantLabel();
|
||||||
updateSidebarTabs();
|
updateSidebarTabs();
|
||||||
if (tenant === "总览") { state.planFilter = 'overview'; state.finFilter = 'overview'; switchTab("home"); }
|
if (tenant === "总览") { state.planFilter = 'overview'; state.finFilter = 'overview'; switchTab("home"); }
|
||||||
load();
|
else if (state.active === 'home') { state.active = 'plan'; localStorage.setItem("opc-active-tab", "plan"); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan")); }
|
||||||
|
await load();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 轻量刷新辅助函数
|
||||||
|
async function refreshResource(resource, renderFn) {
|
||||||
|
try {
|
||||||
|
var dataKey = resource;
|
||||||
|
state.data[dataKey] = await api("/api/" + resource + "/list?tenant=" + encodeURIComponent(state.tenant));
|
||||||
|
if (renderFn && typeof window[renderFn] === 'function') window[renderFn]();
|
||||||
|
} catch(e) { /* non-critical */ }
|
||||||
|
}
|
||||||
window.doLogout = async () => {
|
window.doLogout = async () => {
|
||||||
await api("/api/auth/logout", { method: "POST" });
|
await api("/api/auth/logout", { method: "POST" });
|
||||||
location.href = "/login";
|
location.href = "/login";
|
||||||
|
|||||||
@@ -45,13 +45,13 @@
|
|||||||
<i data-lucide="trending-up" style="width:20px;height:20px"></i>
|
<i data-lucide="trending-up" style="width:20px;height:20px"></i>
|
||||||
<span class="text-[10px] mt-1">部门</span>
|
<span class="text-[10px] mt-1">部门</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-tab" data-tab="plan" onclick="switchTab('plan')" title="计划">
|
<div class="sidebar-tab" data-tab="plan" onclick="switchTab('plan')" title="预算">
|
||||||
<i data-lucide="calendar-range" style="width:20px;height:20px"></i>
|
<i data-lucide="calendar-range" style="width:20px;height:20px"></i>
|
||||||
<span class="text-[10px] mt-1">计划</span>
|
<span class="text-[10px] mt-1">预算</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-tab" data-tab="finance" onclick="switchTab('finance')" title="实际">
|
<div class="sidebar-tab" data-tab="finance" onclick="switchTab('finance')" title="执行">
|
||||||
<i data-lucide="folder-open-dot" style="width:20px;height:20px"></i>
|
<i data-lucide="folder-open-dot" style="width:20px;height:20px"></i>
|
||||||
<span class="text-[10px] mt-1">实际</span>
|
<span class="text-[10px] mt-1">执行</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-tab" data-tab="projects" onclick="switchTab('projects')" title="Focus">
|
<div class="sidebar-tab" data-tab="projects" onclick="switchTab('projects')" title="Focus">
|
||||||
<i data-lucide="file-text" style="width:20px;height:20px"></i>
|
<i data-lucide="file-text" style="width:20px;height:20px"></i>
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
|
<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 items-center gap-3 w-full">
|
||||||
<div class="flex-1">
|
<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.39</span></p>
|
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.7</span></p>
|
||||||
<div class="flex items-center gap-4 mt-1">
|
<div class="flex items-center gap-4 mt-1">
|
||||||
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
|
<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">
|
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user