Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
236d43052e | ||
|
|
c0fb382d78 | ||
|
|
e1b549b3f9 | ||
|
|
1e82ca73d9 | ||
|
|
cfd5137edc | ||
|
|
0a0caa959c | ||
|
|
b313c1b6a2 | ||
|
|
13e63b98e3 |
@@ -17,7 +17,7 @@ def run_migrations():
|
|||||||
"""
|
"""
|
||||||
from migrations.tables import migrate_create_tables
|
from migrations.tables import migrate_create_tables
|
||||||
from migrations.columns import migrate_add_columns
|
from migrations.columns import migrate_add_columns
|
||||||
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status
|
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status, migrate_rename_xuehui_to_xueshu, migrate_rename_zong_to_overview
|
||||||
from migrations.data_split import migrate_data_split
|
from migrations.data_split import migrate_data_split
|
||||||
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
|
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
|
||||||
|
|
||||||
@@ -28,6 +28,8 @@ def run_migrations():
|
|||||||
migrate_drop_product_fields()
|
migrate_drop_product_fields()
|
||||||
migrate_fix_service_fee_standard()
|
migrate_fix_service_fee_standard()
|
||||||
migrate_fix_expense_status()
|
migrate_fix_expense_status()
|
||||||
|
migrate_rename_xuehui_to_xueshu()
|
||||||
|
migrate_rename_zong_to_overview()
|
||||||
migrate_data_split()
|
migrate_data_split()
|
||||||
migrate_seed_users()
|
migrate_seed_users()
|
||||||
migrate_seed_demo_data()
|
migrate_seed_demo_data()
|
||||||
|
|||||||
@@ -114,3 +114,46 @@ def migrate_fix_expense_status():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_rename_xuehui_to_xueshu():
|
||||||
|
"""工作台重命名:学会·无界 → 学术·无界"""
|
||||||
|
from db import db, _exec, mysql
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
tables = ["project_finances", "plan_finances", "expense_records", "plan_expense_records",
|
||||||
|
"sales_leads", "business_proposals", "product_versions", "operation_projects",
|
||||||
|
"follow_up_records", "project_tasks", "finance_records", "users", "user_tenants"]
|
||||||
|
for table in tables:
|
||||||
|
try:
|
||||||
|
cur = _exec(conn, f"UPDATE {table} SET tenant='学术·无界' WHERE tenant='学会·无界'")
|
||||||
|
affected = cur.rowcount
|
||||||
|
cur.close()
|
||||||
|
if affected:
|
||||||
|
conn.commit()
|
||||||
|
print(f"[migrate] {table}: {affected} 条记录 tenant 改为 '学术·无界'")
|
||||||
|
except mysql.connector.Error:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_rename_zong_to_overview():
|
||||||
|
"""工作台重命名:总工作台 → 总览"""
|
||||||
|
from db import db, _exec, mysql
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
tables = ["project_finances", "plan_finances", "expense_records", "plan_expense_records",
|
||||||
|
"sales_leads", "business_proposals", "product_versions", "operation_projects",
|
||||||
|
"follow_up_records", "project_tasks", "finance_records", "users", "user_tenants"]
|
||||||
|
for table in tables:
|
||||||
|
try:
|
||||||
|
cur = _exec(conn, f"UPDATE {table} SET tenant='总览' WHERE tenant='总工作台'")
|
||||||
|
affected = cur.rowcount
|
||||||
|
cur.close()
|
||||||
|
if affected:
|
||||||
|
conn.commit()
|
||||||
|
print(f"[migrate] {table}: {affected} 条记录 tenant 改为 '总览'")
|
||||||
|
except mysql.connector.Error:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def migrate_seed_users():
|
|||||||
# 绑定工作台
|
# 绑定工作台
|
||||||
tenant_map = [
|
tenant_map = [
|
||||||
("kepu", "科普·无界"), ("keyan", "科研·无界"), ("yihuan", "医患·无界"),
|
("kepu", "科普·无界"), ("keyan", "科研·无界"), ("yihuan", "医患·无界"),
|
||||||
("mcn", "MCN·无界"), ("wuji", "学会·无界"),
|
("mcn", "MCN·无界"), ("wuji", "学术·无界"),
|
||||||
]
|
]
|
||||||
for uname, tenant in tenant_map:
|
for uname, tenant in tenant_map:
|
||||||
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))
|
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ random.seed(42)
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from db import db, _exec, one
|
from db import db, _exec, one
|
||||||
|
|
||||||
TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"]
|
TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界"]
|
||||||
|
|
||||||
# 每个工作台的差异化客户和项目模板
|
# 每个工作台的差异化客户和项目模板
|
||||||
TENANT_CUSTOMERS = {
|
TENANT_CUSTOMERS = {
|
||||||
@@ -40,7 +40,7 @@ TENANT_CUSTOMERS = {
|
|||||||
("B站知识区", ["深度科普纪录片", "医学动画制作"]),
|
("B站知识区", ["深度科普纪录片", "医学动画制作"]),
|
||||||
("微信视频号", ["医生科普直播运营", "企业微信科普推送"]),
|
("微信视频号", ["医生科普直播运营", "企业微信科普推送"]),
|
||||||
],
|
],
|
||||||
"学会·无界": [
|
"学术·无界": [
|
||||||
("中华医学会", ["学术年会数字化平台", "继续教育学分系统", "基层医生培训"]),
|
("中华医学会", ["学术年会数字化平台", "继续教育学分系统", "基层医生培训"]),
|
||||||
("中国医师协会", ["医师定考管理系统", "专科医师培训平台"]),
|
("中国医师协会", ["医师定考管理系统", "专科医师培训平台"]),
|
||||||
("中国抗癌协会", ["肿瘤科普万里行", "CACA指南推广"]),
|
("中国抗癌协会", ["肿瘤科普万里行", "CACA指南推广"]),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from helpers import add_file_index, attach_common, monthly_finance
|
|||||||
|
|
||||||
bp = Blueprint("api", __name__)
|
bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
ALL_TENANTS = ["总工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"]
|
ALL_TENANTS = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界"]
|
||||||
|
|
||||||
TABLES = {
|
TABLES = {
|
||||||
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
|
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
|
||||||
@@ -25,7 +25,7 @@ TABLES = {
|
|||||||
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "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", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
|
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
|
||||||
"expense": ("expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
|
"expense": ("expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
|
||||||
"planFinances": ("plan_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info", "client_name", "project_type", "weight"]),
|
"planFinances": ("plan_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info", "project_type", "weight"]),
|
||||||
"planExpense": ("plan_expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
|
"planExpense": ("plan_expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,10 +75,13 @@ def auth_login():
|
|||||||
session["display_name"] = user["display_name"]
|
session["display_name"] = user["display_name"]
|
||||||
session["role"] = user["role"]
|
session["role"] = user["role"]
|
||||||
if user["role"] == "admin":
|
if user["role"] == "admin":
|
||||||
session["tenants"] = ["总工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"]
|
session["tenants"] = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界"]
|
||||||
else:
|
else:
|
||||||
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
|
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
|
||||||
session["tenants"] = [x["tenant"] for x in ut]
|
session["tenants"] = [x["tenant"] for x in ut]
|
||||||
|
# 清理旧名称
|
||||||
|
session["tenants"] = [t if t != "总工作台" else "总览" for t in session["tenants"]]
|
||||||
|
session["tenants"] = [t if t != "学会·无界" else "学术·无界" for t in session["tenants"]]
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
|
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
|
||||||
@@ -123,8 +126,10 @@ def auth_me():
|
|||||||
if "user_id" not in session:
|
if "user_id" not in session:
|
||||||
return jsonify({"logged_in": False})
|
return jsonify({"logged_in": False})
|
||||||
tenants = session.get("tenants", [])
|
tenants = session.get("tenants", [])
|
||||||
if "总工作台" not in tenants:
|
tenants = [t if t != "总工作台" else "总览" for t in tenants]
|
||||||
tenants = ["总工作台"] + tenants
|
tenants = [t if t != "学会·无界" else "学术·无界" for t in tenants]
|
||||||
|
if "总览" not in tenants:
|
||||||
|
tenants = ["总览"] + tenants
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"logged_in": True,
|
"logged_in": True,
|
||||||
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
|
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
|
||||||
@@ -257,15 +262,17 @@ def bootstrap():
|
|||||||
_year = date.today().year
|
_year = date.today().year
|
||||||
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
|
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
|
||||||
allowed = session.get("tenants", [])
|
allowed = session.get("tenants", [])
|
||||||
if "总工作台" not in allowed:
|
allowed = [t if t != "总工作台" else "总览" for t in allowed]
|
||||||
allowed = ["总工作台"] + allowed
|
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
|
||||||
|
if "总览" not in allowed:
|
||||||
|
allowed = ["总览"] + allowed
|
||||||
if tenant not in allowed:
|
if tenant not in allowed:
|
||||||
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 = []
|
||||||
@@ -423,7 +430,7 @@ def bootstrap():
|
|||||||
m[field] = sum(tl[i][field] if i < len(tl) else 0 for tl in all_monthly)
|
m[field] = sum(tl[i][field] if i < len(tl) else 0 for tl in all_monthly)
|
||||||
merged_monthly.append(m)
|
merged_monthly.append(m)
|
||||||
summary = {
|
summary = {
|
||||||
"project_name": "总工作台",
|
"project_name": "总览",
|
||||||
"metrics": agg,
|
"metrics": agg,
|
||||||
"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": [],
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const savedTab = localStorage.getItem("opc-active-tab");
|
|||||||
applyUserTenants();
|
applyUserTenants();
|
||||||
updateSidebarTabs();
|
updateSidebarTabs();
|
||||||
load().then(() => {
|
load().then(() => {
|
||||||
if (state.tenant === "总工作台") {
|
if (state.tenant === "总览") {
|
||||||
switchTab("home");
|
switchTab("home");
|
||||||
} else if (savedTab && savedTab !== "home") {
|
} else if (savedTab && savedTab !== "home") {
|
||||||
switchTab(savedTab);
|
switchTab(savedTab);
|
||||||
|
|||||||
@@ -78,27 +78,27 @@ function renderFinance() {
|
|||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
const rev = b.rev || 0;
|
const rev = b.rev || 0;
|
||||||
const gross = b.gross || 0;
|
const gross = b.gross || 0;
|
||||||
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
|
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
|
||||||
} else {
|
} else {
|
||||||
const payment = b.payment || 0;
|
const payment = b.payment || 0;
|
||||||
const cost = b.cost || 0;
|
const cost = b.cost || 0;
|
||||||
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
|
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
|
||||||
}
|
}
|
||||||
}).join("");
|
}).join("");
|
||||||
const totalCol = (() => {
|
const totalCol = (() => {
|
||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
const totalRev = pf.total_rev || 0;
|
const totalRev = pf.total_rev || 0;
|
||||||
const totalGross = pf.total_gross || 0;
|
const totalGross = pf.total_gross || 0;
|
||||||
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
|
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
|
||||||
} else {
|
} else {
|
||||||
let totalPayment = 0, totalCost = 0;
|
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) {}
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
|
||||||
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
|
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const sm = pf.sign_month || "";
|
const sm = pf.sign_month || "";
|
||||||
const signMonthCell = `<td class="p-2 text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="p-2 text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="p-2 text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const now2 = new Date();
|
const now2 = new Date();
|
||||||
@@ -110,14 +110,19 @@ 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-1 rounded text-sm font-medium ${state.finView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-1 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按月度:</span>${activeMonths.map(m => { const ms = `2026-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-1 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setFinView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按月度:</span>${activeMonths.map(m => { const ms = `2026-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
||||||
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
||||||
|
|
||||||
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">月度总览</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">季度总览</button><button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button></div>`;
|
const isOverviewMode = state.tenant === "总览";
|
||||||
|
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">经营月报</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
|
||||||
|
|
||||||
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
|
document.querySelector("#finance").innerHTML = `<div class="grid gap-2">
|
||||||
${finFilterTabs}
|
${finFilterTabs}
|
||||||
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "p-4") : ''}
|
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
|
||||||
|
<i data-lucide="lightbulb" class="text-blue-600 flex-shrink-0" style="width:18px;height:18px"></i>
|
||||||
|
<span class="text-sm text-blue-900"><strong>执行提示:</strong>先关注确收,毛利确保业务运营,再关注回款,回款率,最后逐步优化成本与支出</span>
|
||||||
|
</div>`}
|
||||||
|
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4") : ''}
|
||||||
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
||||||
<div class="finance-tabs">
|
<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 active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
||||||
@@ -130,7 +135,7 @@ function renderFinance() {
|
|||||||
<form onsubmit="createFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="pf-id-input" value="">
|
<form onsubmit="createFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="pf-id-input" value="">
|
||||||
<div class="finance-tab-body">
|
<div class="finance-tab-body">
|
||||||
<div id="financeTabInfo">
|
<div id="financeTabInfo">
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-2">
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
|
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
|
||||||
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如:KP-2026-001"></label>
|
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如:KP-2026-001"></label>
|
||||||
@@ -179,7 +184,7 @@ function renderFinance() {
|
|||||||
<p class="text-lg font-bold text-amber-700" id="revpayTotalPayment">¥0</p>
|
<p class="text-lg font-bold text-amber-700" id="revpayTotalPayment">¥0</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
|
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
|
||||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500">确收</th><th class="p-2.5 text-right font-medium text-slate-500">毛利</th><th class="p-2.5 text-right font-medium text-slate-500">回款</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500">确收</th><th class="p-2.5 text-right font-medium text-slate-500">毛利</th><th class="p-2.5 text-right font-medium text-slate-500">回款</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||||
<tbody id="revpayTbody"></tbody>
|
<tbody id="revpayTbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -192,18 +197,18 @@ function renderFinance() {
|
|||||||
<p class="text-lg font-bold text-rose-700" id="costTotalCost">¥0</p>
|
<p class="text-lg font-bold text-rose-700" id="costTotalCost">¥0</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-purple-50 rounded-lg p-3 text-center border border-purple-100">
|
<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-xs text-purple-600 font-medium">总支出</p>
|
||||||
<p class="text-lg font-bold text-purple-700" id="costTotalPaid">¥0</p>
|
<p class="text-lg font-bold text-purple-700" id="costTotalPaid">¥0</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="costTable">
|
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="costTable">
|
||||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">费用类型</th><th class="p-2.5 text-right font-medium text-slate-500">成本</th><th class="p-2.5 text-right font-medium text-slate-500">已付</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">费用类型</th><th class="p-2.5 text-right font-medium text-slate-500">成本</th><th class="p-2.5 text-right font-medium text-slate-500">支出</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||||
<tbody id="costTbody"></tbody>
|
<tbody id="costTbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addCostRow()"><i data-lucide="plus"></i>添加月份</button>
|
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addCostRow()"><i data-lucide="plus"></i>添加月份</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="financeTabTasks" class="hidden">
|
<div id="financeTabTasks" class="hidden">
|
||||||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
|
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
|
||||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">任务类型</th><th class="p-2.5 text-right font-medium text-slate-500">任务数量</th><th class="p-2.5 text-right font-medium text-slate-500">已执行</th><th class="p-2.5 text-right font-medium text-slate-500">差额</th><th class="p-2.5 text-right font-medium text-slate-500">单价</th><th class="p-2.5 text-right font-medium text-slate-500">执行金额</th><th class="p-2.5 text-right font-medium text-slate-500">未执行金额</th><th class="p-2.5 w-8"></th></tr></thead>
|
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">任务类型</th><th class="p-2.5 text-right font-medium text-slate-500">任务数量</th><th class="p-2.5 text-right font-medium text-slate-500">已执行</th><th class="p-2.5 text-right font-medium text-slate-500">差额</th><th class="p-2.5 text-right font-medium text-slate-500">单价</th><th class="p-2.5 text-right font-medium text-slate-500">执行金额</th><th class="p-2.5 text-right font-medium text-slate-500">未执行金额</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||||
<tbody id="taskTbody"></tbody>
|
<tbody id="taskTbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -250,13 +255,11 @@ function renderFinance() {
|
|||||||
const METRIC_CARDS = [
|
const METRIC_CARDS = [
|
||||||
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
|
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
|
||||||
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
|
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
|
||||||
{ label: '毛利', value: ctx => money(ctx.sumGross), color: 'text-green-700' },
|
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
|
||||||
{ label: '成本', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
||||||
{ label: '项目利润', value: ctx => { const v = ctx.sumGross; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
||||||
{ label: '已回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
{ label: '支出', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
|
||||||
{ label: '已付', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
|
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
||||||
{ label: '现金流', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
|
||||||
{ label: '执行率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' },
|
|
||||||
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
||||||
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
||||||
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
|
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
|
||||||
@@ -287,15 +290,15 @@ function renderFinance() {
|
|||||||
};
|
};
|
||||||
const SIGNED_COLS = [
|
const SIGNED_COLS = [
|
||||||
{ 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: 'cost', label: '成本' },
|
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
||||||
{ key: 'profit', label: '项目利润' }, { key: 'payment', label: '已回款' }, { key: 'paid', label: '已付' },
|
{ key: 'payment', label: '回款' }, { key: 'pay_rate', label: '回款率' },
|
||||||
{ key: 'cashflow', label: '现金流' }, { key: 'exe_rate', label: '执行率' }, { key: 'gross_rate', label: '毛利率' },
|
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'paid_rate', label: '付款率' },
|
||||||
{ key: 'pay_rate', label: '回款率' }, { key: 'paid_rate', label: '付款率' },
|
{ key: 'cashflow', label: '现金流' },
|
||||||
];
|
];
|
||||||
const UNSIGNED_COLS = [
|
const UNSIGNED_COLS = [
|
||||||
{ 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: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
|
{ key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
|
||||||
{ key: 'profit', label: '项目利润' },
|
,
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
||||||
@@ -305,14 +308,14 @@ function renderFinance() {
|
|||||||
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
|
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
|
||||||
});
|
});
|
||||||
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
||||||
const theadCols = isUnsigned ? 7 : 14;
|
const theadCols = isUnsigned ? 6 : 12;
|
||||||
const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
const tbody = rows.length ? `<tbody style="line-height:0.95">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
||||||
|
|
||||||
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
|
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
|
||||||
const subtitle = isUnsigned
|
const subtitle = isUnsigned
|
||||||
? '合同 → 毛利 → 成本 → 项目利润'
|
? '合同 → 毛利 → 成本 → 项目利润'
|
||||||
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
|
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
|
||||||
return card(`<div class="grid ${cardGrid} gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">' + l + '</span><strong class="mt-2 block text-xl ' + c + '">' + v + '</strong></div>').join("")}</div>`, "p-4") +
|
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
|
||||||
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,17 +330,11 @@ 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';
|
||||||
const profit = gross;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr>`;
|
||||||
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
|
||||||
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="p-2 text-center align-middle text-sm">${exe}</td><td class="p-2 text-center align-middle text-sm">${grossR}</td><td class="p-2 text-center align-middle text-sm">${payR}</td><td class="p-2 text-center align-middle text-sm">${paidR}</td></tr>`;
|
|
||||||
};
|
};
|
||||||
// 待签约简版行
|
// 待签约简版行
|
||||||
const tdRowUnsigned = (pf, cost, gross) => {
|
const tdRowUnsigned = (pf, cost, gross) => {
|
||||||
const profit = gross;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
|
||||||
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
|
||||||
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.finView === 'monthly') {
|
if (state.finView === 'monthly') {
|
||||||
@@ -360,7 +357,7 @@ function renderFinance() {
|
|||||||
if (!rev && !payment && !cost && !paid && !gross) return;
|
if (!rev && !payment && !cost && !paid && !gross) return;
|
||||||
dataItems.push({
|
dataItems.push({
|
||||||
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
||||||
rev: rev, gross: gross, cost: cost, profit: gross, payment: payment, paid: paid,
|
rev: rev, gross: gross, cost: cost, payment: payment, paid: paid,
|
||||||
cashflow: payment - paid,
|
cashflow: payment - paid,
|
||||||
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
|
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
|
||||||
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
|
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
|
||||||
@@ -411,7 +408,7 @@ function renderFinance() {
|
|||||||
if (!rev && !payment && !cost && !paid && !gross) return;
|
if (!rev && !payment && !cost && !paid && !gross) return;
|
||||||
dataItems.push({
|
dataItems.push({
|
||||||
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
||||||
rev: rev, gross: gross, cost: cost, profit: gross, payment: payment, paid: paid,
|
rev: rev, gross: gross, cost: cost, payment: payment, paid: paid,
|
||||||
cashflow: payment - paid,
|
cashflow: payment - paid,
|
||||||
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
|
exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
|
||||||
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
|
gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
|
||||||
@@ -447,7 +444,7 @@ function renderFinance() {
|
|||||||
var t = calcTotals(pf);
|
var t = calcTotals(pf);
|
||||||
return {
|
return {
|
||||||
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
||||||
rev: t.rev, gross: t.gross, cost: t.cost, profit: t.gross,
|
rev: t.rev, gross: t.gross, cost: t.cost,
|
||||||
payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid,
|
payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid,
|
||||||
exe_rate: t.rev && pf.sign_amount ? Math.round(t.rev / pf.sign_amount * 100) : 0,
|
exe_rate: t.rev && pf.sign_amount ? Math.round(t.rev / pf.sign_amount * 100) : 0,
|
||||||
gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
|
gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
|
||||||
@@ -933,7 +930,7 @@ window.deleteFinanceItem = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 月度总览渲染
|
// 经营月报渲染
|
||||||
function renderFinanceOverview() {
|
function renderFinanceOverview() {
|
||||||
const { summary, financeMonthly } = state.data;
|
const { summary, financeMonthly } = state.data;
|
||||||
if (!summary) return;
|
if (!summary) return;
|
||||||
@@ -943,38 +940,106 @@ function renderFinanceOverview() {
|
|||||||
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
||||||
const fm = financeMonthly || [];
|
const fm = financeMonthly || [];
|
||||||
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||||||
const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
|
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
||||||
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.profit_annual||0, m.payment_annual||0, m.paid_annual||0, m.cashflow_annual||0];
|
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)];
|
||||||
const fmFields = ['sign','revenue','gross','cost',null,'payment','paid',null];
|
const fmFields = ['sign','revenue','gross','cost','paid','payment',null];
|
||||||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
|
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
|
||||||
thead += '</tr>';
|
thead += '</tr>';
|
||||||
var tbody = '';
|
var tbody = '';
|
||||||
for (var ri = 0; ri < 8; ri++) {
|
for (var ri = 0; ri < 7; ri++) {
|
||||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
||||||
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
||||||
for (var mi = 0; mi < 12; mi++) {
|
for (var mi = 0; mi < 12; mi++) {
|
||||||
var val = 0;
|
var val = 0;
|
||||||
if (fm[mi]) {
|
if (fm[mi]) {
|
||||||
if (ri === 4) { val = (fm[mi].gross || 0) - (fm[mi].cost || 0); }
|
if (ri === 6) { val = (fm[mi].payment || 0) - (fm[mi].paid || 0); }
|
||||||
else if (ri === 7) { val = (fm[mi].payment || 0) - (fm[mi].paid || 0); }
|
|
||||||
else { var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0; }
|
else { var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0; }
|
||||||
}
|
}
|
||||||
var cls = 'text-slate-700';
|
var cls = 'text-slate-700';
|
||||||
if (ri === 4) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
if (ri === 7) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
||||||
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
|
||||||
}
|
}
|
||||||
tbody += rowHtml + '</tr>';
|
tbody += rowHtml + '</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#financeOverview").innerHTML = '<div class="grid gap-5">' +
|
|
||||||
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务月度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
// 业务线经营情况卡片
|
||||||
'<div class="grid grid-cols-4 gap-5">' +
|
var finExp = state.data.expense || [];
|
||||||
|
var expMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||||||
|
for (var ei = 0; ei < finExp.length; ei++) {
|
||||||
|
if ((finExp[ei].status || '') === '暂不计入') continue;
|
||||||
|
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
||||||
|
var mi2 = parseInt(em.substring(5)) - 1;
|
||||||
|
if (mi2 >= 0 && mi2 < 12) expMonthly[mi2] += parseFloat(finExp[ei].amount || 0);
|
||||||
|
}
|
||||||
|
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
|
||||||
|
var grossAnnual = 0; for (var ei = 0; ei < 12; ei++) grossAnnual += (fm[ei] ? fm[ei].gross : 0);
|
||||||
|
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
|
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[dmi] + '</th>'; }
|
||||||
|
deptThead += '</tr>';
|
||||||
|
var deptRows = [
|
||||||
|
{ label: '毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
|
||||||
|
{ label: '平台费用', vals: expMonthly, annual: expAnnual },
|
||||||
|
{ label: '利润', vals: fm.map(function(x, i) { return (x ? x.gross : 0) - expMonthly[i]; }), annual: grossAnnual - expAnnual }
|
||||||
|
];
|
||||||
|
var deptTbody = '';
|
||||||
|
for (var dri = 0; dri < 3; dri++) {
|
||||||
|
var dr = deptRows[dri];
|
||||||
|
var rowCls = dri === 2 ? 'font-semibold' : '';
|
||||||
|
var valCls = dri === 2 ? 'text-green-600' : 'text-slate-700';
|
||||||
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + valCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
||||||
|
for (var dmi = 0; dmi < 12; dmi++) {
|
||||||
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + valCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
|
||||||
|
}
|
||||||
|
deptTbody += rowHtml + '</tr>';
|
||||||
|
}
|
||||||
|
var deptCard = card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
|
||||||
|
|
||||||
|
// 业务线现金流卡片
|
||||||
|
var cfMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||||||
|
for (var cfi = 0; cfi < 12; cfi++) { cfMonthly[cfi] = (fm[cfi] ? (fm[cfi].payment - fm[cfi].paid) : 0); }
|
||||||
|
var cfAnnual = 0; for (var cfi = 0; cfi < 12; cfi++) cfAnnual += cfMonthly[cfi];
|
||||||
|
var expPaidMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||||||
|
for (var epi = 0; epi < finExp.length; epi++) {
|
||||||
|
if ((finExp[epi].status || '') === '暂不计入') continue;
|
||||||
|
var epm = (finExp[epi].expense_month || '').substring(0, 7);
|
||||||
|
var epmi = parseInt(epm.substring(5)) - 1;
|
||||||
|
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(finExp[epi].incurred_amount || 0);
|
||||||
|
}
|
||||||
|
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
|
||||||
|
var blCfMonthly = cfMonthly.map(function(v, i) { return v - expPaidMonthly[i]; });
|
||||||
|
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||||||
|
var cfRows = [
|
||||||
|
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
|
||||||
|
{ label: '平台费用(支出)', vals: expPaidMonthly, annual: expPaidAnnual },
|
||||||
|
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
|
||||||
|
];
|
||||||
|
var cfTbody = '';
|
||||||
|
for (var cfri = 0; cfri < 3; cfri++) {
|
||||||
|
var cfdr = cfRows[cfri];
|
||||||
|
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
|
||||||
|
var cfValCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
var cfRowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
|
||||||
|
cfRowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cfValCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
|
||||||
|
for (var cfdmi = 0; cfdmi < 12; cfdmi++) {
|
||||||
|
var cellCls = cfValCls;
|
||||||
|
if (cfri === 2) cellCls = blCfMonthly[cfdmi] >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
|
cfRowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[cfdmi]) + '</td>';
|
||||||
|
}
|
||||||
|
cfTbody += cfRowHtml + '</tr>';
|
||||||
|
}
|
||||||
|
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(支出)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
||||||
|
|
||||||
|
document.querySelector("#financeOverview").innerHTML = '<div class="grid gap-2.5">' +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务月度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
|
deptCard +
|
||||||
|
cfCard +
|
||||||
|
'<div class="grid grid-cols-3 gap-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') +
|
||||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与支出</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
|
||||||
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="finChartProfit"></canvas></div>', 'p-4') +
|
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -983,7 +1048,7 @@ function renderFinanceOverview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 季度总览渲染
|
// 经营季报渲染
|
||||||
function renderFinanceQuarterlyOverview() {
|
function renderFinanceQuarterlyOverview() {
|
||||||
const { summary, financeMonthly } = state.data;
|
const { summary, financeMonthly } = state.data;
|
||||||
if (!summary) return;
|
if (!summary) return;
|
||||||
@@ -991,49 +1056,118 @@ function renderFinanceQuarterlyOverview() {
|
|||||||
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
||||||
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
||||||
const fm = financeMonthly || [];
|
const fm = financeMonthly || [];
|
||||||
const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
|
var qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
|
||||||
const qLbl = ['Q1','Q2','Q3','Q4'];
|
|
||||||
const qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
|
// 财务概览: 7 rows
|
||||||
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.profit_annual||0, m.payment_annual||0, m.paid_annual||0, m.cashflow_annual||0];
|
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
||||||
const fmFields = ['sign','revenue','gross','cost',null,'payment','paid',null];
|
var annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)];
|
||||||
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
var fmFields = ['sign','revenue','gross','cost','paid','payment',null];
|
||||||
|
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
||||||
for (var qi = 0; qi < 4; qi++) {
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
for (var ri = 0; ri < 8; ri++) {
|
for (var ri = 0; ri < 7; ri++) {
|
||||||
var sum = 0;
|
var sum = 0;
|
||||||
for (var mi = 0; mi < 3; mi++) {
|
for (var mi = 0; mi < 3; mi++) {
|
||||||
var idx = qRanges[qi][mi];
|
var idx = qRanges[qi][mi];
|
||||||
if (fm[idx]) {
|
if (fm[idx]) {
|
||||||
if (ri === 4) { sum += (fm[idx].gross||0) - (fm[idx].cost||0); }
|
if (ri === 6) { sum += (fm[idx].payment||0) - (fm[idx].paid||0); }
|
||||||
else if (ri === 7) { sum += (fm[idx].payment||0) - (fm[idx].paid||0); }
|
|
||||||
else { var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0; }
|
else { var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
qData[ri][qi] = sum;
|
qData[ri][qi] = sum;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
|
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
|
||||||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||||
thead += '</tr>';
|
thead += '</tr>';
|
||||||
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
|
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
|
||||||
var tbody = '';
|
var tbody = '';
|
||||||
for (var ri = 0; ri < 8; ri++) {
|
for (var ri = 0; ri < 7; ri++) {
|
||||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
||||||
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
||||||
for (var qi = 0; qi < 4; qi++) {
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
var val = qData[ri][qi];
|
var val = qData[ri][qi];
|
||||||
var cls = 'text-slate-700';
|
var cls = 'text-slate-700';
|
||||||
if (ri === 4) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
if (ri === 2) cls = 'text-green-600';
|
||||||
if (ri === 7) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
||||||
if (qi > 0) { rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
|
if (qi > 0) { rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
|
||||||
}
|
}
|
||||||
tbody += rowHtml + '</tr>';
|
tbody += rowHtml + '</tr>';
|
||||||
}
|
}
|
||||||
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-5">' +
|
|
||||||
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
// 业务线经营情况: quarterly
|
||||||
|
var finExp = state.data.expense || [];
|
||||||
|
var expQ = [0,0,0,0];
|
||||||
|
for (var ei = 0; ei < finExp.length; ei++) {
|
||||||
|
if ((finExp[ei].status || '') === '暂不计入') continue;
|
||||||
|
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
||||||
|
var emi = parseInt(em.substring(5)) - 1;
|
||||||
|
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expQ[eqi] += parseFloat(finExp[ei].amount || 0); }
|
||||||
|
}
|
||||||
|
var expAnnual = 0; for (var ei = 0; ei < 4; ei++) expAnnual += expQ[ei];
|
||||||
|
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
|
||||||
|
var grossAnnual = m.gross_annual || 0;
|
||||||
|
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
|
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; }
|
||||||
|
deptThead += '</tr>';
|
||||||
|
var deptRows = [
|
||||||
|
{ label: '毛利', vals: grossQ, annual: grossAnnual },
|
||||||
|
{ label: '平台费用', vals: expQ, annual: expAnnual },
|
||||||
|
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
|
||||||
|
];
|
||||||
|
var deptTbody = '';
|
||||||
|
for (var dri = 0; dri < 3; dri++) {
|
||||||
|
var dr = deptRows[dri];
|
||||||
|
var rowCls = dri === 2 ? 'font-semibold' : '';
|
||||||
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
|
||||||
|
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
||||||
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
|
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
|
||||||
|
}
|
||||||
|
deptTbody += rowHtml + '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务线现金流: quarterly
|
||||||
|
var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]];
|
||||||
|
var cfAnnual = m.payment_annual || 0;
|
||||||
|
var expPaidQ = [0,0,0,0];
|
||||||
|
for (var ei = 0; ei < finExp.length; ei++) {
|
||||||
|
if ((finExp[ei].status || '') === '暂不计入') continue;
|
||||||
|
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
||||||
|
var emi = parseInt(em.substring(5)) - 1;
|
||||||
|
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expPaidQ[eqi] += parseFloat(finExp[ei].incurred_amount || 0); }
|
||||||
|
}
|
||||||
|
var expPaidAnnual = 0; for (var ei = 0; ei < 4; ei++) expPaidAnnual += expPaidQ[ei];
|
||||||
|
var blCfQ = cfQ.map(function(v, i) { return v - expPaidQ[i]; });
|
||||||
|
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||||||
|
var cfRows = [
|
||||||
|
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
|
||||||
|
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
|
||||||
|
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
|
||||||
|
];
|
||||||
|
var cfTbody = '';
|
||||||
|
for (var cfri = 0; cfri < 3; cfri++) {
|
||||||
|
var cfdr = cfRows[cfri];
|
||||||
|
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
|
||||||
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
|
||||||
|
var annualCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
|
||||||
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
|
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
|
||||||
|
}
|
||||||
|
cfTbody += rowHtml + '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出 → 回款 → 项目现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
||||||
'</div>';
|
'</div>';
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
@@ -1057,7 +1191,7 @@ function renderOverviewCharts(data) {
|
|||||||
var c3 = document.querySelector("#finChartCash");
|
var c3 = document.querySelector("#finChartCash");
|
||||||
if (c3 && window.Chart) {
|
if (c3 && window.Chart) {
|
||||||
if (state.finChart3) state.finChart3.destroy();
|
if (state.finChart3) state.finChart3.destroy();
|
||||||
state.finChart3 = new Chart(c3, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "回款", data: data.map(function(x) { return x.payment || 0; }), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, { label: "已付", data: data.map(function(x) { return x.cost || 0; }), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }] } }));
|
state.finChart3 = new Chart(c3, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "回款", data: data.map(function(x) { return x.payment || 0; }), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, { label: "支出", data: data.map(function(x) { return x.cost || 0; }), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }] } }));
|
||||||
}
|
}
|
||||||
var c4 = document.querySelector("#finChartProfit");
|
var c4 = document.querySelector("#finChartProfit");
|
||||||
if (c4 && window.Chart) {
|
if (c4 && window.Chart) {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function renderHome() {
|
|||||||
}
|
}
|
||||||
document.querySelector("#home").innerHTML = `
|
document.querySelector("#home").innerHTML = `
|
||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
${state.tenant === "总工作台" ? "" : `<div class="grid grid-cols-4 gap-3">
|
${state.tenant === "总览" ? "" : `<div class="grid grid-cols-4 gap-3">
|
||||||
${[
|
${[
|
||||||
["项目管理", m.total_projects, "finance"],
|
["项目管理", m.total_projects, "finance"],
|
||||||
["重点工作与台账", m.total_proposals, "projects"],
|
["重点工作与台账", m.total_proposals, "projects"],
|
||||||
|
|||||||
@@ -76,27 +76,27 @@ function renderPlan() {
|
|||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
const rev = b.rev || 0;
|
const rev = b.rev || 0;
|
||||||
const gross = b.gross || 0;
|
const gross = b.gross || 0;
|
||||||
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
|
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
|
||||||
} else {
|
} else {
|
||||||
const payment = b.payment || 0;
|
const payment = b.payment || 0;
|
||||||
const cost = b.cost || 0;
|
const cost = b.cost || 0;
|
||||||
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
|
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
|
||||||
}
|
}
|
||||||
}).join("");
|
}).join("");
|
||||||
const totalCol = (() => {
|
const totalCol = (() => {
|
||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
const totalRev = pf.total_rev || 0;
|
const totalRev = pf.total_rev || 0;
|
||||||
const totalGross = pf.total_gross || 0;
|
const totalGross = pf.total_gross || 0;
|
||||||
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
|
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
|
||||||
} else {
|
} else {
|
||||||
let totalPayment = 0, totalCost = 0;
|
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) {}
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
|
||||||
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
|
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const sm = pf.sign_month || "";
|
const sm = pf.sign_month || "";
|
||||||
const signMonthCell = `<td class="p-2 text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="p-2 text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="p-2 text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="p-2 text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const now2 = new Date();
|
const now2 = new Date();
|
||||||
@@ -108,18 +108,19 @@ 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-1 rounded text-sm font-medium ${state.planView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按季度:</span>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-1 rounded text-sm font-medium ${state.planView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按月度:</span>${activeMonths.map(m => { const ms = `2026-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-1 rounded text-sm font-medium ${state.planView==='monthly'&&state.planMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setPlanView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按季度:</span>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">按月度:</span>${activeMonths.map(m => { const ms = `2026-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='monthly'&&state.planMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
||||||
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
||||||
|
|
||||||
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">月度总览</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">季度总览</button><button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目计划 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用计划</button></div>`;
|
const isOverviewMode = state.tenant === "总览";
|
||||||
|
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">经营月报</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目计划 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用计划</button>`}</div>`;
|
||||||
|
|
||||||
document.querySelector("#plan").innerHTML = `<div class="grid gap-4">
|
document.querySelector("#plan").innerHTML = `<div class="grid gap-2">
|
||||||
${planFilterTabs}
|
${planFilterTabs}
|
||||||
<div class="flex items-center gap-3 px-4 py-3 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
|
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
|
||||||
<i data-lucide="lightbulb" class="text-blue-600 flex-shrink-0" style="width:18px;height:18px"></i>
|
<i data-lucide="lightbulb" class="text-blue-600 flex-shrink-0" style="width:18px;height:18px"></i>
|
||||||
<span class="text-sm text-blue-900"><strong>计划提醒:</strong>计划也是预算,只有真正能够列出计划的预算,才有靠谱的落地可能性</span>
|
<span class="text-sm text-blue-900"><strong>计划提醒:</strong>计划也是预算,只有真正能够列出计划的预算,才有靠谱的落地可能性</span>
|
||||||
</div>
|
</div>`}
|
||||||
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "p-4") : ''}
|
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4") : ''}
|
||||||
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
|
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
|
||||||
<div class="finance-tabs">
|
<div class="finance-tabs">
|
||||||
<button class="finance-tab active" data-tab="info" onclick="switchPlanTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
<button class="finance-tab active" data-tab="info" onclick="switchPlanTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
||||||
@@ -132,7 +133,7 @@ function renderPlan() {
|
|||||||
<form onsubmit="createPlanFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="plan_pf-id-input" value="">
|
<form onsubmit="createPlanFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="plan_pf-id-input" value="">
|
||||||
<div class="finance-tab-body">
|
<div class="finance-tab-body">
|
||||||
<div id="plan_financeTabInfo">
|
<div id="plan_financeTabInfo">
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-2">
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
|
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
|
||||||
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如:KP-2026-001"></label>
|
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如:KP-2026-001"></label>
|
||||||
@@ -181,7 +182,7 @@ function renderPlan() {
|
|||||||
<p class="text-lg font-bold text-amber-700" id="plan_revpayTotalPayment">¥0</p>
|
<p class="text-lg font-bold text-amber-700" id="plan_revpayTotalPayment">¥0</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_revpayTable">
|
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_revpayTable">
|
||||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500">确收</th><th class="p-2.5 text-right font-medium text-slate-500">毛利</th><th class="p-2.5 text-right font-medium text-slate-500">回款</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500">确收</th><th class="p-2.5 text-right font-medium text-slate-500">毛利</th><th class="p-2.5 text-right font-medium text-slate-500">回款</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||||
<tbody id="plan_revpayTbody"></tbody>
|
<tbody id="plan_revpayTbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -198,14 +199,14 @@ function renderPlan() {
|
|||||||
<p class="text-lg font-bold text-purple-700" id="plan_costTotalPaid">¥0</p>
|
<p class="text-lg font-bold text-purple-700" id="plan_costTotalPaid">¥0</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_costTable">
|
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_costTable">
|
||||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">费用类型</th><th class="p-2.5 text-right font-medium text-slate-500">成本</th><th class="p-2.5 text-right font-medium text-slate-500">已付</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">费用类型</th><th class="p-2.5 text-right font-medium text-slate-500">成本</th><th class="p-2.5 text-right font-medium text-slate-500">已付</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||||
<tbody id="plan_costTbody"></tbody>
|
<tbody id="plan_costTbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="planAddCostRow()"><i data-lucide="plus"></i>添加月份</button>
|
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="planAddCostRow()"><i data-lucide="plus"></i>添加月份</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="plan_financeTabTasks" class="hidden">
|
<div id="plan_financeTabTasks" class="hidden">
|
||||||
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="plan_taskTable">
|
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_taskTable">
|
||||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">任务类型</th><th class="p-2.5 text-right font-medium text-slate-500">任务数量</th><th class="p-2.5 text-right font-medium text-slate-500">已执行</th><th class="p-2.5 text-right font-medium text-slate-500">差额</th><th class="p-2.5 text-right font-medium text-slate-500">单价</th><th class="p-2.5 text-right font-medium text-slate-500">执行金额</th><th class="p-2.5 text-right font-medium text-slate-500">未执行金额</th><th class="p-2.5 w-8"></th></tr></thead>
|
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">任务类型</th><th class="p-2.5 text-right font-medium text-slate-500">任务数量</th><th class="p-2.5 text-right font-medium text-slate-500">已执行</th><th class="p-2.5 text-right font-medium text-slate-500">差额</th><th class="p-2.5 text-right font-medium text-slate-500">单价</th><th class="p-2.5 text-right font-medium text-slate-500">执行金额</th><th class="p-2.5 text-right font-medium text-slate-500">未执行金额</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||||
<tbody id="plan_taskTbody"></tbody>
|
<tbody id="plan_taskTbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -252,13 +253,11 @@ function renderPlan() {
|
|||||||
const METRIC_CARDS = [
|
const METRIC_CARDS = [
|
||||||
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
|
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
|
||||||
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
|
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
|
||||||
{ label: '毛利', value: ctx => money(ctx.sumGross), color: 'text-green-700' },
|
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
|
||||||
{ label: '成本', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
||||||
{ label: '项目利润', value: ctx => { const v = ctx.sumGross; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
||||||
{ label: '已回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
|
||||||
{ label: '已付', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
|
{ label: '已付', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
|
||||||
{ label: '现金流', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
||||||
{ label: '执行率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' },
|
|
||||||
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
||||||
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
||||||
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
|
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
|
||||||
@@ -279,11 +278,13 @@ function renderPlan() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
const buildThead = (cols, sortKey) => {
|
const buildThead = (cols, sortKey) => {
|
||||||
|
var noSortKeys = ['weight', 'project_type'];
|
||||||
var h = '<thead><tr class="bg-slate-50 border-b border-slate-200">';
|
var h = '<thead><tr class="bg-slate-50 border-b border-slate-200">';
|
||||||
cols.forEach(function(c) {
|
cols.forEach(function(c) {
|
||||||
var arrow = sortKey ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
|
var canSort = sortKey && noSortKeys.indexOf(c.key) < 0;
|
||||||
var clickable = sortKey ? ' cursor-pointer select-none' : '';
|
var arrow = canSort ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
|
||||||
h += '<th class="p-2 text-center font-semibold align-middle' + clickable + '"' + (sortKey ? ' onclick="setPlanSort(\'' + sortKey + '|' + c.key + '\')"' : '') + '>' + c.label + arrow + '</th>';
|
var clickable = canSort ? ' cursor-pointer select-none' : '';
|
||||||
|
h += '<th class="p-2 text-center font-semibold align-middle' + clickable + '"' + (canSort ? ' onclick="setPlanSort(\'' + sortKey + '|' + c.key + '\')"' : '') + '>' + c.label + arrow + '</th>';
|
||||||
});
|
});
|
||||||
h += '</tr></thead>';
|
h += '</tr></thead>';
|
||||||
return h;
|
return h;
|
||||||
@@ -294,15 +295,15 @@ function renderPlan() {
|
|||||||
{ key: 'weight', label: '加权' },
|
{ key: 'weight', label: '加权' },
|
||||||
{ key: 'project_type', label: '项目类型' },
|
{ key: 'project_type', label: '项目类型' },
|
||||||
{ key: 'sign_amount', label: '签约金额' },
|
{ key: 'sign_amount', label: '签约金额' },
|
||||||
{ key: 'rev', label: '已确收' },
|
{ key: 'rev', label: '确收' },
|
||||||
{ key: 'gross', label: '毛利' },
|
{ key: 'gross', label: '毛利' },
|
||||||
{ key: 'cost', label: '成本' },
|
{ key: 'gross_rate', label: '毛利率' },
|
||||||
{ key: 'profit', label: '项目利润' },
|
{ key: 'payment', label: '回款' },
|
||||||
{ key: 'payment', label: '已回款' },
|
|
||||||
{ key: 'paid', label: '已付' },
|
|
||||||
{ key: 'cashflow', label: '现金流' },
|
|
||||||
{ key: 'exe_rate', label: '执行率' },
|
|
||||||
{ key: 'pay_rate', label: '回款率' },
|
{ key: 'pay_rate', label: '回款率' },
|
||||||
|
{ key: 'cost', label: '成本' },
|
||||||
|
{ key: 'paid', label: '付款' },
|
||||||
|
{ key: 'paid_rate', label: '付款率' },
|
||||||
|
{ key: 'cashflow', label: '现金流' },
|
||||||
];
|
];
|
||||||
const UNSIGNED_COLS = [
|
const UNSIGNED_COLS = [
|
||||||
{ key: 'client_name', label: '客户' },
|
{ key: 'client_name', label: '客户' },
|
||||||
@@ -324,38 +325,34 @@ function renderPlan() {
|
|||||||
});
|
});
|
||||||
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
||||||
const theadCols = isUnsigned ? 9 : 14;
|
const theadCols = isUnsigned ? 9 : 14;
|
||||||
const tbody = rows.length ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
const tbody = rows.length ? `<tbody style="line-height:0.95">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
||||||
|
|
||||||
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
|
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
|
||||||
const subtitle = isUnsigned
|
const subtitle = isUnsigned
|
||||||
? '合同 → 毛利 → 成本 → 项目利润'
|
? '合同 → 毛利 → 成本 → 项目利润'
|
||||||
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流';
|
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流';
|
||||||
return card(`<div class="grid ${cardGrid} gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">' + l + '</span><strong class="mt-2 block text-xl ' + c + '">' + v + '</strong></div>').join("")}</div>`, "p-4") +
|
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
|
||||||
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>';
|
const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>';
|
||||||
const tdRow = (pf, rev, payment, cost, paid, gross) => {
|
const tdRow = (pf, rev, payment, cost, paid, gross) => {
|
||||||
const cashflow = payment - paid;
|
const cashflow = payment - paid;
|
||||||
const exe = rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) + '%' : '<span class="text-slate-300">—</span>';
|
|
||||||
const payRVal = rev && payment ? Math.round(payment / rev * 100) : null;
|
const payRVal = rev && payment ? Math.round(payment / rev * 100) : null;
|
||||||
const payRCls = payRVal === null ? '' : payRVal < 30 ? 'text-red-600' : payRVal < 80 ? 'text-amber-600' : 'text-green-600';
|
const payRCls = payRVal === null ? '' : payRVal < 30 ? 'text-red-600' : payRVal < 80 ? 'text-amber-600' : 'text-green-600';
|
||||||
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
|
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
|
||||||
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
|
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||||
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
const 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';
|
||||||
const profit = gross;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr>`;
|
||||||
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
|
||||||
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="p-2 text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="p-2 text-center align-middle text-sm">${exe}</td><td class="p-2 text-center align-middle text-sm">${payR}</td></tr>`;
|
|
||||||
};
|
};
|
||||||
// 待签约简版行
|
// 待签约简版行
|
||||||
const tdRowUnsigned = (pf, cost, gross) => {
|
const tdRowUnsigned = (pf, cost, gross) => {
|
||||||
const profit = gross;
|
const profit = gross;
|
||||||
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="p-2 text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="p-2 text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.planView === 'monthly') {
|
if (state.planView === 'monthly') {
|
||||||
@@ -939,13 +936,18 @@ window.createPlanFinance = async (event) => {
|
|||||||
const result = await api("/api/planFinances", { method: "POST", body: JSON.stringify({ data }) });
|
const result = await api("/api/planFinances", { method: "POST", body: JSON.stringify({ data }) });
|
||||||
if (result.id && data.customer_name) logActivity("finance", result.id, "创建了「" + data.customer_name + "」的财务项目");
|
if (result.id && data.customer_name) logActivity("finance", result.id, "创建了「" + data.customer_name + "」的财务项目");
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast("保存失败:" + error.message, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
form.reset();
|
form.reset();
|
||||||
document.querySelector("#plan_pf-id-input").value = "";
|
document.querySelector("#plan_pf-id-input").value = "";
|
||||||
document.querySelector("#planModalTitle").textContent = "新增项目财务";
|
document.querySelector("#planModalTitle").textContent = "新增项目财务";
|
||||||
closePlanModal();
|
closePlanModal();
|
||||||
await load();
|
await load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("保存失败:" + error.message, "error");
|
toast("刷新失败:" + error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1009,44 +1011,111 @@ function renderPlanOverview() {
|
|||||||
annualSums.payment += fm[i].payment; annualSums.paid += fm[i].paid;
|
annualSums.payment += fm[i].payment; annualSums.paid += fm[i].paid;
|
||||||
}
|
}
|
||||||
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||||||
const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
|
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
||||||
// 每行的年度累计 + 12个月值
|
// 每行的年度累计 + 12个月值
|
||||||
const annualVals = [
|
const annualVals = [
|
||||||
annualSums.sign, annualSums.revenue, annualSums.gross, annualSums.cost,
|
annualSums.sign, annualSums.revenue, annualSums.gross, annualSums.cost, annualSums.paid,
|
||||||
annualSums.gross - annualSums.cost, annualSums.payment, annualSums.paid,
|
annualSums.payment, annualSums.payment - annualSums.paid,
|
||||||
annualSums.payment - annualSums.paid,
|
|
||||||
];
|
];
|
||||||
// 字段映射: sign, revenue, gross, cost, profit(=gross-cost), payment, paid, cashflow(=payment-paid)
|
// 字段映射: sign, revenue, gross, cost, paid, payment, cashflow(=payment-paid)
|
||||||
const fmFields = ['sign','revenue','gross','cost',null,'payment','paid',null];
|
const fmFields = ['sign','revenue','gross','cost','paid','payment',null];
|
||||||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
|
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
|
||||||
thead += '</tr>';
|
thead += '</tr>';
|
||||||
var tbody = '';
|
var tbody = '';
|
||||||
for (var ri = 0; ri < 8; ri++) {
|
for (var ri = 0; ri < 7; ri++) {
|
||||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
||||||
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
||||||
for (var mi = 0; mi < 12; mi++) {
|
for (var mi = 0; mi < 12; mi++) {
|
||||||
var val = 0;
|
var val = 0;
|
||||||
if (fm[mi]) {
|
if (fm[mi]) {
|
||||||
if (ri === 4) { val = (fm[mi].gross || 0) - (fm[mi].cost || 0); }
|
if (ri === 6) { val = (fm[mi].payment || 0) - (fm[mi].paid || 0); }
|
||||||
else if (ri === 7) { val = (fm[mi].payment || 0) - (fm[mi].paid || 0); }
|
|
||||||
else { var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0; }
|
else { var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0; }
|
||||||
}
|
}
|
||||||
var cls = 'text-slate-700';
|
var cls = 'text-slate-700';
|
||||||
if (ri === 4) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
if (ri === 2) cls = 'text-green-600';
|
||||||
if (ri === 7) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
||||||
}
|
}
|
||||||
tbody += rowHtml + '</tr>';
|
tbody += rowHtml + '</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#planOverview").innerHTML = '<div class="grid gap-5">' +
|
// 部门经营卡片:毛利、平台费用、利润
|
||||||
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
var planExp = state.data.planExpense || [];
|
||||||
'<div class="grid grid-cols-4 gap-5">' +
|
var expMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||||||
|
for (var ei = 0; ei < planExp.length; ei++) {
|
||||||
|
var em = (planExp[ei].expense_month || '').substring(0, 7);
|
||||||
|
var mi = parseInt(em.substring(5)) - 1;
|
||||||
|
if (mi >= 0 && mi < 12) expMonthly[mi] += parseFloat(planExp[ei].amount || 0);
|
||||||
|
}
|
||||||
|
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
|
||||||
|
var grossAnnual = 0; for (var ei = 0; ei < 12; ei++) grossAnnual += (fm[ei] ? fm[ei].gross : 0);
|
||||||
|
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
|
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[dmi] + '</th>'; }
|
||||||
|
deptThead += '</tr>';
|
||||||
|
var deptRows = [
|
||||||
|
{ label: '毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
|
||||||
|
{ label: '平台费用', vals: expMonthly, annual: expAnnual },
|
||||||
|
{ label: '利润', vals: fm.map(function(x, i) { return (x ? x.gross : 0) - expMonthly[i]; }), annual: grossAnnual - expAnnual }
|
||||||
|
];
|
||||||
|
var deptTbody = '';
|
||||||
|
for (var dri = 0; dri < 3; dri++) {
|
||||||
|
var dr = deptRows[dri];
|
||||||
|
var rowCls = dri === 2 ? 'font-semibold' : '';
|
||||||
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
|
||||||
|
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
||||||
|
for (var dmi = 0; dmi < 12; dmi++) {
|
||||||
|
var cellCls = dri === 2 ? (dr.vals[dmi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
|
||||||
|
}
|
||||||
|
deptTbody += rowHtml + '</tr>';
|
||||||
|
}
|
||||||
|
var deptCard = card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
|
||||||
|
|
||||||
|
|
||||||
|
// 业务线现金流卡片
|
||||||
|
var cfMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||||||
|
for (var cfi = 0; cfi < 12; cfi++) { cfMonthly[cfi] = (fm[cfi] ? (fm[cfi].payment - fm[cfi].paid) : 0); }
|
||||||
|
var cfAnnual = 0; for (var cfi = 0; cfi < 12; cfi++) cfAnnual += cfMonthly[cfi];
|
||||||
|
var expPaidMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||||||
|
for (var epi = 0; epi < planExp.length; epi++) {
|
||||||
|
var epm = (planExp[epi].expense_month || '').substring(0, 7);
|
||||||
|
var epmi = parseInt(epm.substring(5)) - 1;
|
||||||
|
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(planExp[epi].incurred_amount || 0);
|
||||||
|
}
|
||||||
|
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
|
||||||
|
var blCfMonthly = cfMonthly.map(function(v, i) { return v - expPaidMonthly[i]; });
|
||||||
|
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||||||
|
var cfRows = [
|
||||||
|
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
|
||||||
|
{ label: '平台费用(已付)', vals: expPaidMonthly, annual: expPaidAnnual },
|
||||||
|
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
|
||||||
|
];
|
||||||
|
var cfTbody = '';
|
||||||
|
for (var cfri = 0; cfri < 3; cfri++) {
|
||||||
|
var cfdr = cfRows[cfri];
|
||||||
|
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
|
||||||
|
var cfValCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
var cfRowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
|
||||||
|
cfRowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cfValCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
|
||||||
|
for (var cfdmi = 0; cfdmi < 12; cfdmi++) {
|
||||||
|
var cellCls = cfValCls;
|
||||||
|
if (cfri === 2) cellCls = blCfMonthly[cfdmi] >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
|
cfRowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[cfdmi]) + '</td>';
|
||||||
|
}
|
||||||
|
cfTbody += cfRowHtml + '</tr>';
|
||||||
|
}
|
||||||
|
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
||||||
|
|
||||||
|
document.querySelector("#planOverview").innerHTML = '<div class="grid gap-2.5">' +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 已付</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
|
deptCard +
|
||||||
|
cfCard +
|
||||||
|
'<div class="grid grid-cols-3 gap-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') +
|
||||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
|
||||||
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="planChartProfit"></canvas></div>', 'p-4') +
|
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -1062,63 +1131,122 @@ function renderPlanQuarterlyOverview() {
|
|||||||
const fm = computePlanMonthly(planFinances);
|
const fm = computePlanMonthly(planFinances);
|
||||||
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
||||||
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
||||||
const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
|
|
||||||
const qLbl = ['Q1','Q2','Q3','Q4'];
|
|
||||||
const qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
|
|
||||||
var annualSums2 = { sign: 0, revenue: 0, gross: 0, cost: 0, payment: 0, paid: 0 };
|
var annualSums2 = { sign: 0, revenue: 0, gross: 0, cost: 0, payment: 0, paid: 0 };
|
||||||
for (var i2 = 0; i2 < fm.length; i2++) {
|
for (var i2 = 0; i2 < fm.length; i2++) {
|
||||||
annualSums2.sign += fm[i2].sign; annualSums2.revenue += fm[i2].revenue;
|
annualSums2.sign += fm[i2].sign; annualSums2.revenue += fm[i2].revenue;
|
||||||
annualSums2.gross += fm[i2].gross; annualSums2.cost += fm[i2].cost;
|
annualSums2.gross += fm[i2].gross; annualSums2.cost += fm[i2].cost;
|
||||||
annualSums2.payment += fm[i2].payment; annualSums2.paid += fm[i2].paid;
|
annualSums2.payment += fm[i2].payment; annualSums2.paid += fm[i2].paid;
|
||||||
}
|
}
|
||||||
const annualVals = [annualSums2.sign, annualSums2.revenue, annualSums2.gross, annualSums2.cost, annualSums2.gross - annualSums2.cost, annualSums2.payment, annualSums2.paid, annualSums2.payment - annualSums2.paid];
|
var qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
|
||||||
const fmFields = ['sign','revenue','gross','cost',null,'payment','paid',null];
|
|
||||||
|
|
||||||
// Calculate quarterly sums
|
// 财务概览: 7 rows
|
||||||
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
||||||
|
var annualVals = [annualSums2.sign, annualSums2.revenue, annualSums2.gross, annualSums2.cost, annualSums2.paid, annualSums2.payment, annualSums2.payment - annualSums2.paid];
|
||||||
|
var fmFields = ['sign','revenue','gross','cost','paid','payment',null];
|
||||||
|
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
||||||
for (var qi = 0; qi < 4; qi++) {
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
for (var ri = 0; ri < 8; ri++) {
|
for (var ri = 0; ri < 7; ri++) {
|
||||||
var sum = 0;
|
var sum = 0;
|
||||||
for (var mi = 0; mi < 3; mi++) {
|
for (var mi = 0; mi < 3; mi++) {
|
||||||
var idx = qRanges[qi][mi];
|
var idx = qRanges[qi][mi];
|
||||||
if (fm[idx]) {
|
if (fm[idx]) {
|
||||||
if (ri === 4) { sum += (fm[idx].gross||0) - (fm[idx].cost||0); }
|
if (ri === 6) { sum += (fm[idx].payment||0) - (fm[idx].paid||0); }
|
||||||
else if (ri === 7) { sum += (fm[idx].payment||0) - (fm[idx].paid||0); }
|
|
||||||
else { var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0; }
|
else { var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
qData[ri][qi] = sum;
|
qData[ri][qi] = sum;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-2 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
|
||||||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
|
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||||
thead += '<th class="py-2 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-2 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
|
||||||
thead += '</tr>';
|
thead += '</tr>';
|
||||||
|
|
||||||
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
|
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
|
||||||
|
|
||||||
var tbody = '';
|
var tbody = '';
|
||||||
for (var ri = 0; ri < 8; ri++) {
|
for (var ri = 0; ri < 7; ri++) {
|
||||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
|
||||||
rowHtml += '<td class="py-2 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
|
||||||
for (var qi = 0; qi < 4; qi++) {
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
var val = qData[ri][qi];
|
var val = qData[ri][qi];
|
||||||
var cls = 'text-slate-700';
|
var cls = 'text-slate-700';
|
||||||
if (ri === 4) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
if (ri === 2) cls = 'text-green-600';
|
||||||
if (ri === 7) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
||||||
if (qi > 0) {
|
if (qi > 0) { rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
|
||||||
rowHtml += '<td class="py-2 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
tbody += rowHtml + '</tr>';
|
tbody += rowHtml + '</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-5">' +
|
// 业务线经营情况: quarterly
|
||||||
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') +
|
var planExp = state.data.planExpense || [];
|
||||||
|
var expQ = [0,0,0,0];
|
||||||
|
for (var ei = 0; ei < planExp.length; ei++) {
|
||||||
|
var em = (planExp[ei].expense_month || '').substring(0, 7);
|
||||||
|
var emi = parseInt(em.substring(5)) - 1;
|
||||||
|
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expQ[eqi] += parseFloat(planExp[ei].amount || 0); }
|
||||||
|
}
|
||||||
|
var expAnnual = 0; for (var ei = 0; ei < 4; ei++) expAnnual += expQ[ei];
|
||||||
|
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
|
||||||
|
var grossAnnual = annualSums2.gross;
|
||||||
|
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
|
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; }
|
||||||
|
deptThead += '</tr>';
|
||||||
|
var deptRows = [
|
||||||
|
{ label: '毛利', vals: grossQ, annual: grossAnnual },
|
||||||
|
{ label: '平台费用', vals: expQ, annual: expAnnual },
|
||||||
|
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
|
||||||
|
];
|
||||||
|
var deptTbody = '';
|
||||||
|
for (var dri = 0; dri < 3; dri++) {
|
||||||
|
var dr = deptRows[dri];
|
||||||
|
var rowCls = dri === 2 ? 'font-semibold' : '';
|
||||||
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
|
||||||
|
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
||||||
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
|
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
|
||||||
|
}
|
||||||
|
deptTbody += rowHtml + '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务线现金流: quarterly
|
||||||
|
var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]];
|
||||||
|
var cfAnnual = annualSums2.payment;
|
||||||
|
var expPaidQ = [0,0,0,0];
|
||||||
|
for (var ei = 0; ei < planExp.length; ei++) {
|
||||||
|
var em = (planExp[ei].expense_month || '').substring(0, 7);
|
||||||
|
var emi = parseInt(em.substring(5)) - 1;
|
||||||
|
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expPaidQ[eqi] += parseFloat(planExp[ei].incurred_amount || 0); }
|
||||||
|
}
|
||||||
|
var expPaidAnnual = 0; for (var ei = 0; ei < 4; ei++) expPaidAnnual += expPaidQ[ei];
|
||||||
|
var blCfQ = cfQ.map(function(v, i) { return v - expPaidQ[i]; });
|
||||||
|
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||||||
|
var cfRows = [
|
||||||
|
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
|
||||||
|
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
|
||||||
|
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
|
||||||
|
];
|
||||||
|
var cfTbody = '';
|
||||||
|
for (var cfri = 0; cfri < 3; cfri++) {
|
||||||
|
var cfdr = cfRows[cfri];
|
||||||
|
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
|
||||||
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
|
||||||
|
var annualCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
|
||||||
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
|
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
|
||||||
|
}
|
||||||
|
cfTbody += rowHtml + '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出 → 回款 → 项目现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
||||||
|
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
||||||
'</div>';
|
'</div>';
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
@@ -1144,11 +1272,6 @@ function renderPlanCharts(data) {
|
|||||||
if (state.planChart3) state.planChart3.destroy();
|
if (state.planChart3) state.planChart3.destroy();
|
||||||
state.planChart3 = new Chart(c3, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "回款", data: data.map(function(x) { return x.payment || 0; }), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, { label: "已付", data: data.map(function(x) { return x.cost || 0; }), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }] } }));
|
state.planChart3 = new Chart(c3, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "回款", data: data.map(function(x) { return x.payment || 0; }), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, { label: "已付", data: data.map(function(x) { return x.cost || 0; }), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }] } }));
|
||||||
}
|
}
|
||||||
var c4 = document.querySelector("#planChartProfit");
|
|
||||||
if (c4 && window.Chart) {
|
|
||||||
if (state.planChart4) state.planChart4.destroy();
|
|
||||||
state.planChart4 = new Chart(c4, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "利润", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }] } }));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function planInlineSelect(name, pfId, field, currentVal, options) {
|
function planInlineSelect(name, pfId, field, currentVal, options) {
|
||||||
@@ -1156,7 +1279,7 @@ function planInlineSelect(name, pfId, field, currentVal, options) {
|
|||||||
for (var i = 0; i < options.length; i++) {
|
for (var i = 0; i < options.length; i++) {
|
||||||
opts += '<option value="' + options[i] + '"' + (options[i] === currentVal ? ' selected' : '') + '>' + options[i] + '</option>';
|
opts += '<option value="' + options[i] + '"' + (options[i] === currentVal ? ' selected' : '') + '>' + options[i] + '</option>';
|
||||||
}
|
}
|
||||||
return '<select onchange="planInlineUpdate(' + pfId + ', \'' + field + '\'' + ', this.value)" class="text-sm bg-transparent border-0 cursor-pointer text-center" style="appearance:auto;-webkit-appearance:auto;vertical-align:middle">' + opts + '</select>';
|
return '<select onchange="planInlineUpdate(' + pfId + ', \'' + field + '\'' + ', this.value)" class="text-sm bg-transparent border-0 cursor-pointer text-center" style="appearance:none;-webkit-appearance:none;vertical-align:middle;padding-right:14px;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2210%22 height=%2210%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%23999%22 stroke-width=%222%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right center">' + opts + '</select>';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.planInlineUpdate = async (pfId, field, value) => {
|
window.planInlineUpdate = async (pfId, field, value) => {
|
||||||
|
|||||||
@@ -184,36 +184,35 @@ window.savePlanExpense = async function(event) {
|
|||||||
if (data.amount === '' || data.amount === undefined) data.amount = 0;
|
if (data.amount === '' || data.amount === undefined) data.amount = 0;
|
||||||
if (data.incurred_amount === '' || data.incurred_amount === undefined) data.incurred_amount = 0;
|
if (data.incurred_amount === '' || data.incurred_amount === undefined) data.incurred_amount = 0;
|
||||||
if (!data.status) data.status = '计入费用';
|
if (!data.status) data.status = '计入费用';
|
||||||
var isEdit = !!data.expense_id;
|
var id = data.expense_id;
|
||||||
delete data.expense_id;
|
delete data.expense_id;
|
||||||
|
data.tenant = state.tenant;
|
||||||
|
|
||||||
var id = form.querySelector('[name="expense_id"]').value;
|
try {
|
||||||
var url = isEdit ? "/api/planExpense/" + id : "/api/planExpense";
|
if (id) {
|
||||||
var method = isEdit ? "PUT" : "POST";
|
await api("/api/planExpense/" + id, { method: "PUT", body: JSON.stringify({ data }) });
|
||||||
|
} else {
|
||||||
var resp = await fetch(url, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: Object.assign({}, data, { tenant: state.tenant }) }) });
|
await api("/api/planExpense", { method: "POST", body: JSON.stringify({ data }) });
|
||||||
if (!resp.ok) { var err = await resp.text(); alert("保存失败:" + err); return; }
|
}
|
||||||
|
} catch (error) {
|
||||||
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
|
toast("保存失败:" + error.message, "error");
|
||||||
if (bResp.ok) {
|
return;
|
||||||
var bd = await bResp.json();
|
|
||||||
state.data = bd;
|
|
||||||
applyUserTenants();
|
|
||||||
renderPlanExpense();
|
|
||||||
}
|
}
|
||||||
closePlanExpModal();
|
closePlanExpModal();
|
||||||
|
await load();
|
||||||
|
renderPlanExpense();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.deletePlanExpItem = async function() {
|
window.deletePlanExpItem = async function() {
|
||||||
var id = document.querySelector("#planExpenseModal form [name='expense_id']").value;
|
var id = document.querySelector("#planExpenseModal form [name='expense_id']").value;
|
||||||
if (!id || !confirm("确定删除?")) return;
|
if (!id || !confirm("确定删除?")) return;
|
||||||
var resp = await fetch("/api/planExpense/" + id, { method: "DELETE" });
|
try {
|
||||||
if (!resp.ok) { alert("删除失败"); return; }
|
await api("/api/planExpense/" + id, { method: "DELETE" });
|
||||||
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
|
} catch (error) {
|
||||||
if (bResp.ok) {
|
toast("删除失败:" + error.message, "error");
|
||||||
var bd = await bResp.json();
|
return;
|
||||||
state.data = bd;
|
|
||||||
applyUserTenants();
|
|
||||||
renderPlanExpense();
|
|
||||||
}
|
}
|
||||||
|
closePlanExpModal();
|
||||||
|
await load();
|
||||||
|
renderPlanExpense();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ window.toggleTenantMenu = (event) => {
|
|||||||
if (menu) { menu.remove(); return; }
|
if (menu) { menu.remove(); return; }
|
||||||
const btn = event.currentTarget;
|
const btn = event.currentTarget;
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
const tenants = state.allowedTenants || [];
|
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||||
menu = document.createElement("div");
|
menu = document.createElement("div");
|
||||||
menu.id = "tenantMenu";
|
menu.id = "tenantMenu";
|
||||||
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]";
|
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]";
|
||||||
@@ -52,15 +52,31 @@ window.toggleTenantMenu = (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.switchTenantFromMenu = (tenant) => {
|
window.switchTenantFromMenu = (tenant) => {
|
||||||
document.getElementById("tenantMenu")?.remove();
|
|
||||||
switchTenant(tenant);
|
switchTenant(tenant);
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateTenantLabel() {
|
function updateTenantLabel() {
|
||||||
const label = document.querySelector("#currentTenantLabel");
|
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||||
if (label) {
|
var tenantIcons = {
|
||||||
label.textContent = state.tenant.replace("·无界", "") || "工作台";
|
'学术·无界': 'graduation-cap',
|
||||||
label.title = state.tenant;
|
'MCN·无界': 'video',
|
||||||
|
'科普·无界': 'flask-conical',
|
||||||
|
'医患·无界': 'stethoscope',
|
||||||
|
'科研·无界': 'microscope',
|
||||||
|
'总览': 'layout-dashboard'
|
||||||
|
};
|
||||||
|
const container = document.querySelector("#tenantList");
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = tenants.map(t => {
|
||||||
|
const shortName = t.replace("·无界", "");
|
||||||
|
const isActive = t === state.tenant;
|
||||||
|
var icon = tenantIcons[t] || 'circle';
|
||||||
|
return '<div class="flex flex-col items-center cursor-pointer rounded-lg py-1.5 px-1 w-14 transition-colors ' + (isActive ? 'bg-blue-600' : 'hover:bg-slate-800') + '" onclick="switchTenantFromMenu(\'' + t.replace(/'/g, "\\'") + '\')" title="' + esc(t) + '">' +
|
||||||
|
'<i data-lucide="' + icon + '" style="width:20px;height:20px;color:' + (isActive ? '#fff' : '#94a3b8') + '"></i>' +
|
||||||
|
'<span class="text-[10px] mt-1 max-w-[56px] truncate text-center ' + (isActive ? 'text-white' : 'text-slate-400') + '">' + esc(shortName) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,13 +144,13 @@ function switchTab(tab) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSidebarTabs() {
|
function updateSidebarTabs() {
|
||||||
const isOverview = state.tenant === "总工作台";
|
const isOverview = state.tenant === "总览";
|
||||||
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
|
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
|
||||||
const tab = btn.dataset.tab;
|
const tab = btn.dataset.tab;
|
||||||
if (isOverview && tab !== "home") {
|
if (isOverview) {
|
||||||
btn.style.display = "none";
|
btn.style.display = (tab === "home" || tab === "plan" || tab === "finance") ? "" : "none";
|
||||||
} else {
|
} else {
|
||||||
btn.style.display = "";
|
btn.style.display = tab === "home" ? "none" : "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -240,10 +240,9 @@ window.switchTenant = (tenant) => {
|
|||||||
state.selectedProject = null;
|
state.selectedProject = null;
|
||||||
localStorage.setItem("opc-active-tenant", tenant);
|
localStorage.setItem("opc-active-tenant", tenant);
|
||||||
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
|
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
|
||||||
const label = document.querySelector("#currentTenantLabel");
|
if (typeof updateTenantLabel === "function") updateTenantLabel();
|
||||||
if (label) { label.textContent = tenant.replace("·无界", "") || "工作台"; label.title = tenant; }
|
|
||||||
updateSidebarTabs();
|
updateSidebarTabs();
|
||||||
if (tenant === "总工作台") switchTab("home");
|
if (tenant === "总览") { state.planFilter = 'overview'; state.finFilter = 'overview'; switchTab("home"); }
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
window.doLogout = async () => {
|
window.doLogout = async () => {
|
||||||
|
|||||||
@@ -35,12 +35,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="w-8 h-px bg-slate-700 my-2"></div>
|
<div class="w-8 h-px bg-slate-700 my-2"></div>
|
||||||
<!-- 工作台切换按钮 -->
|
<!-- 工作台列表(竖向平铺) -->
|
||||||
<div class="flex flex-col items-center cursor-pointer hover:bg-slate-800 rounded-lg py-2 px-1 w-14 transition-colors" onclick="toggleTenantMenu(event)">
|
<div class="flex flex-col items-center gap-1 w-14" id="tenantList"></div>
|
||||||
<i data-lucide="layout-grid" style="width:18px;height:18px;color:#94a3b8"></i>
|
|
||||||
<span class="text-[10px] text-slate-400 mt-1 max-w-[56px] truncate text-center" id="currentTenantLabel">工作台</span>
|
|
||||||
<i data-lucide="chevron-down" style="width:12px;height:12px;color:#64748b;margin-top:2px"></i>
|
|
||||||
</div>
|
|
||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="w-8 h-px bg-slate-700 my-2"></div>
|
<div class="w-8 h-px bg-slate-700 my-2"></div>
|
||||||
<!-- 导航 Tab 图标 -->
|
<!-- 导航 Tab 图标 -->
|
||||||
@@ -80,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.28</span></p>
|
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.0.0.36</span></p>
|
||||||
<div class="flex items-center gap-4 mt-1">
|
<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