Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bdde246bc | ||
|
|
ea1e477c31 | ||
|
|
dcc59a9bc6 | ||
|
|
ead6f2ac46 | ||
|
|
9471ee3e85 | ||
|
|
d4fa828f8c | ||
|
|
ceb54e29ec |
@@ -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, migrate_rename_xuehui_to_xueshu, migrate_rename_zong_to_overview
|
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, migrate_add_test_tenant
|
||||||
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
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ def run_migrations():
|
|||||||
migrate_fix_expense_status()
|
migrate_fix_expense_status()
|
||||||
migrate_rename_xuehui_to_xueshu()
|
migrate_rename_xuehui_to_xueshu()
|
||||||
migrate_rename_zong_to_overview()
|
migrate_rename_zong_to_overview()
|
||||||
|
migrate_add_test_tenant()
|
||||||
migrate_data_split()
|
migrate_data_split()
|
||||||
migrate_seed_users()
|
migrate_seed_users()
|
||||||
migrate_seed_demo_data()
|
migrate_seed_demo_data()
|
||||||
|
|||||||
@@ -157,3 +157,26 @@ def migrate_rename_zong_to_overview():
|
|||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_add_test_tenant():
|
||||||
|
"""为所有 opc_owner 用户添加 测试·无界 工作台权限"""
|
||||||
|
from db import db, mysql
|
||||||
|
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute("SELECT id, username FROM users WHERE role = 'opc_owner'")
|
||||||
|
users = cur.fetchall()
|
||||||
|
added = 0
|
||||||
|
for u in users:
|
||||||
|
cur.execute("SELECT id FROM user_tenants WHERE user_id = %s AND tenant = %s", (u["id"], "测试·无界"))
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.execute("INSERT INTO user_tenants (user_id, tenant) VALUES (%s, %s)", (u["id"], "测试·无界"))
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
conn.commit()
|
||||||
|
print(f"[migrate] 为 {added} 个 opc_owner 用户添加了 '测试·无界' 工作台权限")
|
||||||
|
cur.close()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -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"]),
|
||||||
@@ -75,7 +75,7 @@ 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]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ function renderExpense() {
|
|||||||
var records = state.data.expense || [];
|
var records = state.data.expense || [];
|
||||||
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
|
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
|
||||||
var esc = function(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
var esc = function(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
||||||
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
|
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
|
||||||
var STATUS_OPTIONS = ['计入费用','暂不计入'];
|
var STATUS_OPTIONS = ['计入费用','暂不计入'];
|
||||||
|
|
||||||
// 排序状态
|
// 排序状态
|
||||||
@@ -78,12 +78,16 @@ function renderExpense() {
|
|||||||
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.expenseQuarter + '"', 'value="' + state.expenseQuarter + '" selected') + '</select>';
|
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.expenseQuarter + '"', 'value="' + state.expenseQuarter + '" selected') + '</select>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toolbar
|
// 类型 Tab 按钮
|
||||||
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-sm text-slate-500 ml-3">类型:</span><select onchange="setExpenseFilter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
|
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setExpenseFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
|
||||||
EXPENSE_TYPES.forEach(function(t) {
|
EXPENSE_TYPES.forEach(function(t) {
|
||||||
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
|
var cnt = records.filter(function(r){return r.expense_type===t}).length;
|
||||||
|
typeTabs += '<button onclick="setExpenseFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
|
||||||
});
|
});
|
||||||
toolbar += '</select>' + dateSelect;
|
typeTabs += '</div>';
|
||||||
|
|
||||||
|
// Toolbar(视图+日期选择器)
|
||||||
|
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select>' + dateSelect;
|
||||||
|
|
||||||
// 排序箭头
|
// 排序箭头
|
||||||
var sortArrow = function(key) {
|
var sortArrow = function(key) {
|
||||||
@@ -119,12 +123,12 @@ function renderExpense() {
|
|||||||
var target = document.querySelector("#financeExpense") || document.querySelector("#financeTabExpense") || document.querySelector("#expense");
|
var target = document.querySelector("#financeExpense") || document.querySelector("#financeTabExpense") || document.querySelector("#expense");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
target.innerHTML = '<div class="grid gap-4">' +
|
target.innerHTML = '<div class="grid gap-4">' +
|
||||||
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
|
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
|
||||||
'<div class="grid grid-cols-3 gap-3">' +
|
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
|
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">总费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
|
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">已发生金额</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
|
||||||
modal +
|
modal +
|
||||||
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'incurred_amount\')">已发生金额' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
|
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'incurred_amount\')">已发生金额' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -194,13 +198,10 @@ window.saveExpense = async function(event) {
|
|||||||
var resp = await fetch(url, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: Object.assign({}, data, { tenant: state.tenant }) }) });
|
var resp = await fetch(url, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: Object.assign({}, data, { tenant: state.tenant }) }) });
|
||||||
if (!resp.ok) { var err = await resp.text(); alert("保存失败:" + err); return; }
|
if (!resp.ok) { var err = await resp.text(); alert("保存失败:" + err); return; }
|
||||||
|
|
||||||
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
|
try {
|
||||||
if (bResp.ok) {
|
state.data.expense = await fetch("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)).then(r => r.json());
|
||||||
var bd = await bResp.json();
|
} catch(e) {}
|
||||||
state.data = bd;
|
renderExpense();
|
||||||
applyUserTenants();
|
|
||||||
renderExpense();
|
|
||||||
}
|
|
||||||
closeExpenseModal();
|
closeExpenseModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,11 +210,8 @@ window.deleteExpenseItem = async function() {
|
|||||||
if (!id || !confirm("确定删除?")) return;
|
if (!id || !confirm("确定删除?")) return;
|
||||||
var resp = await fetch("/api/expense/" + id, { method: "DELETE" });
|
var resp = await fetch("/api/expense/" + id, { method: "DELETE" });
|
||||||
if (!resp.ok) { alert("删除失败"); return; }
|
if (!resp.ok) { alert("删除失败"); return; }
|
||||||
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
|
try {
|
||||||
if (bResp.ok) {
|
state.data.expense = await fetch("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)).then(r => r.json());
|
||||||
var bd = await bResp.json();
|
} catch(e) {}
|
||||||
state.data = bd;
|
renderExpense();
|
||||||
applyUserTenants();
|
|
||||||
renderExpense();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// finance.js — 经营管理(财务)模块
|
// finance.js — 经营管理(财务)模块
|
||||||
|
|
||||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
|
||||||
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(1)} 万`;
|
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(1)} 万`;
|
||||||
|
|
||||||
function renderFinance() {
|
function renderFinance() {
|
||||||
@@ -98,7 +98,7 @@ function renderFinance() {
|
|||||||
})();
|
})();
|
||||||
const sm = pf.sign_month || "";
|
const sm = pf.sign_month || "";
|
||||||
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>`;
|
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
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 truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${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();
|
||||||
@@ -118,12 +118,12 @@ function renderFinance() {
|
|||||||
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">经营月报</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
|
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">经营月报</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
|
||||||
|
|
||||||
document.querySelector("#finance").innerHTML = `<div class="grid gap-2">
|
document.querySelector("#finance").innerHTML = `<div class="grid gap-2">
|
||||||
${finFilterTabs}
|
|
||||||
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#fef9c3,#fde047);border-color:#eab308">
|
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#fef9c3,#fde047);border-color:#eab308">
|
||||||
<i data-lucide="lightbulb" class="text-yellow-700 flex-shrink-0" style="width:18px;height:18px"></i>
|
<i data-lucide="lightbulb" class="text-yellow-700 flex-shrink-0" style="width:18px;height:18px"></i>
|
||||||
<span class="text-sm text-yellow-900"><strong>已发生模块:</strong>只记录,已经发生的确收,毛利,回款,成本,支出</span>
|
<span class="text-sm text-yellow-900"><strong>已发生模块:</strong>只记录,已经发生的确收,毛利,回款,成本,支出</span>
|
||||||
</div>`}
|
</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") : ''}
|
${finFilterTabs}
|
||||||
|
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? '' : ''}
|
||||||
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
<div 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>
|
||||||
@@ -185,7 +185,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" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
|
<table class="w-full text-sm" style="line-height:1.37 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>
|
||||||
@@ -202,14 +202,14 @@ function renderFinance() {
|
|||||||
<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" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="costTable">
|
<table class="w-full text-sm" style="line-height:1.37 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" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
|
<table class="w-full text-sm" style="line-height:1.37 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>
|
||||||
@@ -256,6 +256,7 @@ 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: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: v < 0 ? '超额回款 ' + moneyWan(Math.abs(v)) : moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
|
||||||
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
|
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
|
||||||
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
||||||
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
||||||
@@ -263,13 +264,16 @@ function renderFinance() {
|
|||||||
{ 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: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
||||||
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.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' },
|
|
||||||
].filter(c => true);
|
].filter(c => true);
|
||||||
|
|
||||||
// 列排序
|
// 列排序(默认按确收金额从高到低)
|
||||||
if (!state.finSort) state.finSort = { key: null, asc: true };
|
if (!state.finSort) state.finSort = { key: 'sig|rev', asc: false };
|
||||||
const sortData = (data, key) => {
|
const sortData = (data, key) => {
|
||||||
if (!key || !state.finSort.key) return data;
|
if (!key) return data;
|
||||||
|
if (!state.finSort.key) {
|
||||||
|
// 默认按确收金额从高到低
|
||||||
|
return data.slice().sort(function(a, b) { return (parseFloat(b.rev) || 0) - (parseFloat(a.rev) || 0); });
|
||||||
|
}
|
||||||
var asc = state.finSort.asc ? 1 : -1;
|
var asc = state.finSort.asc ? 1 : -1;
|
||||||
return data.slice().sort(function(a, b) {
|
return data.slice().sort(function(a, b) {
|
||||||
var va = a[key], vb = b[key];
|
var va = a[key], vb = b[key];
|
||||||
@@ -284,7 +288,7 @@ function renderFinance() {
|
|||||||
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.finSort.asc ? ' ↑' : ' ↓') : '') : '';
|
var arrow = sortKey ? (sortKey === c.key ? (state.finSort.asc ? ' ↑' : ' ↓') : '') : '';
|
||||||
h += '<th class="p-2 text-center font-semibold align-middle cursor-pointer select-none" onclick="setFinSort(\'' + sortKey + '|' + c.key + '\')">' + c.label + arrow + '</th>';
|
h += '<th class="px-2 py-px text-center font-semibold align-middle cursor-pointer select-none" onclick="setFinSort(\'' + sortKey + '|' + c.key + '\')">' + c.label + arrow + '</th>';
|
||||||
});
|
});
|
||||||
h += '</tr></thead>';
|
h += '</tr></thead>';
|
||||||
return h;
|
return h;
|
||||||
@@ -296,6 +300,7 @@ function renderFinance() {
|
|||||||
{ key: 'payment', label: '回款' }, { key: 'pay_rate', label: '回款率' },
|
{ key: 'payment', label: '回款' }, { key: 'pay_rate', label: '回款率' },
|
||||||
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'paid_rate', label: '付款率' },
|
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'paid_rate', label: '付款率' },
|
||||||
{ key: 'cashflow', label: '现金流' },
|
{ key: 'cashflow', label: '现金流' },
|
||||||
|
{ key: 'pending', 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: '签约金额' },
|
||||||
@@ -311,13 +316,14 @@ function renderFinance() {
|
|||||||
});
|
});
|
||||||
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 ? 6 : 12;
|
const theadCols = isUnsigned ? 6 : 12;
|
||||||
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 tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
||||||
|
|
||||||
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
|
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
|
||||||
const subtitle = isUnsigned
|
const subtitle = isUnsigned
|
||||||
? '合同 → 毛利 → 成本 → 项目利润'
|
? '合同 → 毛利 → 成本 → 项目利润'
|
||||||
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
|
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
|
||||||
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>` +
|
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
|
||||||
|
`${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
|
||||||
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
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");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -332,24 +338,32 @@ 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 pending = rev - payment;
|
||||||
|
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-400';
|
||||||
|
const pendingVal = pending ? money(pending) : '<span class="text-slate-300">—</span>';
|
||||||
var budgetArr = [];
|
var budgetArr = [];
|
||||||
var expenseArr = [];
|
var expenseArr = [];
|
||||||
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
||||||
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
|
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
|
||||||
var detailRows = budgetArr.filter(function(b) {
|
// 合并 budget 和 expense 的所有月份
|
||||||
var exp = expenseArr.find(function(e) { return e.month === (b.month || ''); }) || {};
|
var allMonths = {};
|
||||||
|
budgetArr.forEach(function(b) { allMonths[b.month || ''] = true; });
|
||||||
|
expenseArr.forEach(function(e) { allMonths[e.month || ''] = true; });
|
||||||
|
var detailRows = Object.keys(allMonths).filter(function(m) {
|
||||||
|
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
|
||||||
|
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
|
||||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||||
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
return bRev || bGross || bPay || eCost || ePaid;
|
return bRev || bGross || bPay || eCost || ePaid;
|
||||||
}).map(function(b) {
|
}).sort().map(function(m) {
|
||||||
var m = b.month || '';
|
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
|
||||||
var exp = expenseArr.find(function(e) { return e.month === m; }) || {};
|
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
|
||||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||||
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
var bCf = bPay - ePaid;
|
var bCf = bPay - ePaid;
|
||||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td></tr>';
|
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="fin_expand_${pf.id}"><td colspan="13"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="fin_expand_${pf.id}"><td colspan="14"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||||
};
|
};
|
||||||
// 待签约简版行
|
// 待签约简版行
|
||||||
const tdRowUnsigned = (pf, cost, gross) => {
|
const tdRowUnsigned = (pf, cost, gross) => {
|
||||||
@@ -707,7 +721,7 @@ window.editPfSignMonth = (event, pfId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.switchFinanceTab = (tab) => {
|
window.switchFinanceTab = (tab) => {
|
||||||
document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
document.querySelectorAll("#financeModal .finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
||||||
document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info");
|
document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info");
|
||||||
document.querySelector("#financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
|
document.querySelector("#financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
|
||||||
document.querySelector("#financeTabCost").classList.toggle("hidden", tab !== "cost");
|
document.querySelector("#financeTabCost").classList.toggle("hidden", tab !== "cost");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
function renderHome() {
|
function renderHome() {
|
||||||
const { summary, financeMonthly } = state.data;
|
const { summary, financeMonthly } = state.data;
|
||||||
const m = summary.metrics;
|
const m = summary.metrics;
|
||||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
|
||||||
// 回款提醒
|
// 回款提醒
|
||||||
const yrRev = m.revenue_annual || 0, yrPay = m.payment_annual || 0, yrUnpaid = yrRev - yrPay;
|
const yrRev = m.revenue_annual || 0, yrPay = m.payment_annual || 0, yrUnpaid = yrRev - yrPay;
|
||||||
const yrPayRate = yrRev > 0 ? Math.round(yrPay / yrRev * 100) : 0;
|
const yrPayRate = yrRev > 0 ? Math.round(yrPay / yrRev * 100) : 0;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ function renderPlan() {
|
|||||||
})();
|
})();
|
||||||
const sm = pf.sign_month || "";
|
const sm = pf.sign_month || "";
|
||||||
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>`;
|
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
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 truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${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();
|
||||||
@@ -113,15 +113,16 @@ function renderPlan() {
|
|||||||
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
||||||
|
|
||||||
const isOverviewMode = false;
|
const isOverviewMode = false;
|
||||||
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">经营月报</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用计划</button>`}</div>`;
|
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">经营月报</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
|
||||||
|
|
||||||
document.querySelector("#plan").innerHTML = `<div class="grid gap-2">
|
document.querySelector("#plan").innerHTML = `<div class="grid gap-2">
|
||||||
${planFilterTabs}
|
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#fef9c3,#fde047);border-color:#eab308">
|
||||||
${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-yellow-700 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-yellow-900"><strong>预算:</strong>OPC 对每一个项目确收,毛利,回款,成本,支付,现金流,按月做出规划,回头对比已发生,看预算的准确率</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>`, "py-2 px-4") : ''}
|
${planFilterTabs}
|
||||||
|
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? '' : (state.planFilter === 'expense' ? '' : '')}
|
||||||
|
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? '' : ''}
|
||||||
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
|
<div 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>
|
||||||
@@ -183,7 +184,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" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_revpayTable">
|
<table class="w-full text-sm" style="line-height:1.37 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>
|
||||||
@@ -200,14 +201,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" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_costTable">
|
<table class="w-full text-sm" style="line-height:1.37 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" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_taskTable">
|
<table class="w-full text-sm" style="line-height:1.37 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>
|
||||||
@@ -254,6 +255,7 @@ 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: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: v < 0 ? '超额回款 ' + moneyWan(Math.abs(v)) : moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
|
||||||
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
|
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
|
||||||
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
||||||
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
||||||
@@ -261,13 +263,16 @@ function renderPlan() {
|
|||||||
{ 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: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
||||||
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.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' },
|
|
||||||
].filter(c => true);
|
].filter(c => true);
|
||||||
|
|
||||||
// 列排序
|
// 列排序(默认按确收金额从高到低)
|
||||||
if (!state.planSort) state.planSort = { key: null, asc: true };
|
if (!state.planSort) state.planSort = { key: 'sig|rev', asc: false };
|
||||||
const sortData = (data, key) => {
|
const sortData = (data, key) => {
|
||||||
if (!key || !state.planSort.key) return data;
|
if (!key) return data;
|
||||||
|
if (!state.planSort.key) {
|
||||||
|
// 默认按确收金额从高到低
|
||||||
|
return data.slice().sort(function(a, b) { return (parseFloat(b.rev) || 0) - (parseFloat(a.rev) || 0); });
|
||||||
|
}
|
||||||
var asc = state.planSort.asc ? 1 : -1;
|
var asc = state.planSort.asc ? 1 : -1;
|
||||||
return data.slice().sort(function(a, b) {
|
return data.slice().sort(function(a, b) {
|
||||||
var va = a[key], vb = b[key];
|
var va = a[key], vb = b[key];
|
||||||
@@ -285,7 +290,7 @@ function renderPlan() {
|
|||||||
var canSort = sortKey && noSortKeys.indexOf(c.key) < 0;
|
var canSort = sortKey && noSortKeys.indexOf(c.key) < 0;
|
||||||
var arrow = canSort ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
|
var arrow = canSort ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
|
||||||
var clickable = canSort ? ' cursor-pointer select-none' : '';
|
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 += '<th class="px-2 py-px 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;
|
||||||
@@ -306,6 +311,7 @@ function renderPlan() {
|
|||||||
{ key: 'paid', label: '付款' },
|
{ key: 'paid', label: '付款' },
|
||||||
{ key: 'paid_rate', label: '付款率' },
|
{ key: 'paid_rate', label: '付款率' },
|
||||||
{ key: 'cashflow', label: '现金流' },
|
{ key: 'cashflow', label: '现金流' },
|
||||||
|
{ key: 'pending', label: '待回款' },
|
||||||
];
|
];
|
||||||
const UNSIGNED_COLS = [
|
const UNSIGNED_COLS = [
|
||||||
{ key: 'client_name', label: '客户' },
|
{ key: 'client_name', label: '客户' },
|
||||||
@@ -327,13 +333,14 @@ 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 style="line-height:0.95">${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:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
||||||
|
|
||||||
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
|
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
|
||||||
const subtitle = isUnsigned
|
const subtitle = isUnsigned
|
||||||
? '合同 → 毛利 → 成本 → 项目利润'
|
? '合同 → 毛利 → 成本 → 项目利润'
|
||||||
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流';
|
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流';
|
||||||
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>` +
|
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
|
||||||
|
`${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
|
||||||
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
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");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -347,31 +354,39 @@ function renderPlan() {
|
|||||||
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||||
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
||||||
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
|
const pending = rev - payment;
|
||||||
|
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-400';
|
||||||
|
const pendingVal = pending ? money(pending) : '<span class="text-slate-300">—</span>';
|
||||||
var budgetArr = [];
|
var budgetArr = [];
|
||||||
var expenseArr = [];
|
var expenseArr = [];
|
||||||
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
||||||
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
|
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
|
||||||
var detailRows = budgetArr.filter(function(b) {
|
// 合并 budget 和 expense 的所有月份
|
||||||
var exp = expenseArr.find(function(e) { return e.month === (b.month || ''); }) || {};
|
var allMonths = {};
|
||||||
|
budgetArr.forEach(function(b) { allMonths[b.month || ''] = true; });
|
||||||
|
expenseArr.forEach(function(e) { allMonths[e.month || ''] = true; });
|
||||||
|
var detailRows = Object.keys(allMonths).filter(function(m) {
|
||||||
|
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
|
||||||
|
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
|
||||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||||
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
return bRev || bGross || bPay || eCost || ePaid;
|
return bRev || bGross || bPay || eCost || ePaid;
|
||||||
}).map(function(b) {
|
}).sort().map(function(m) {
|
||||||
var m = b.month || '';
|
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
|
||||||
var exp = expenseArr.find(function(e) { return e.month === m; }) || {};
|
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
|
||||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||||
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
var bCf = bPay - ePaid;
|
var bCf = bPay - ePaid;
|
||||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td></tr>';
|
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="16"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||||
};
|
};
|
||||||
// 待签约简版行
|
// 待签约简版行
|
||||||
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="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>`;
|
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 truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center 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="px-2 py-px text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.planView === 'monthly') {
|
if (state.planView === 'monthly') {
|
||||||
@@ -1313,7 +1328,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: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>';
|
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;height:20px;line-height:1;padding:0 14px 0 0;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) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ function renderPlanExpense() {
|
|||||||
var records = state.data.planExpense || [];
|
var records = state.data.planExpense || [];
|
||||||
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
|
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
|
||||||
var esc = function(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
var esc = function(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
||||||
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
|
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
|
||||||
var STATUS_OPTIONS = ['计入费用','暂不计入'];
|
var STATUS_OPTIONS = ['计入费用','暂不计入'];
|
||||||
|
|
||||||
// 排序状态
|
// 排序状态
|
||||||
@@ -78,12 +78,16 @@ function renderPlanExpense() {
|
|||||||
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setPlanExpQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.planExpQuarter + '"', 'value="' + state.planExpQuarter + '" selected') + '</select>';
|
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setPlanExpQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.planExpQuarter + '"', 'value="' + state.planExpQuarter + '" selected') + '</select>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toolbar
|
// 类型 Tab 按钮
|
||||||
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-sm text-slate-500 ml-3">类型:</span><select onchange="setPlanExpFilter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
|
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setPlanExpFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
|
||||||
EXPENSE_TYPES.forEach(function(t) {
|
EXPENSE_TYPES.forEach(function(t) {
|
||||||
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
|
var cnt = records.filter(function(r){return r.expense_type===t}).length;
|
||||||
|
typeTabs += '<button onclick="setPlanExpFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
|
||||||
});
|
});
|
||||||
toolbar += '</select>' + dateSelect;
|
typeTabs += '</div>';
|
||||||
|
|
||||||
|
// Toolbar(视图+日期选择器)
|
||||||
|
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select>' + dateSelect;
|
||||||
|
|
||||||
// 排序箭头
|
// 排序箭头
|
||||||
var sortArrow = function(key) {
|
var sortArrow = function(key) {
|
||||||
@@ -119,12 +123,12 @@ function renderPlanExpense() {
|
|||||||
var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
|
var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
target.innerHTML = '<div class="grid gap-4">' +
|
target.innerHTML = '<div class="grid gap-4">' +
|
||||||
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
|
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
|
||||||
'<div class="grid grid-cols-3 gap-3">' +
|
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
|
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">总费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
|
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">现金支出</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
|
||||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">现金支出</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
|
||||||
modal +
|
modal +
|
||||||
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">现金支出' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
|
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">现金支出' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|||||||
@@ -32,7 +32,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 || []).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; });
|
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]";
|
||||||
@@ -63,13 +63,14 @@ window.switchTenantFromMenu = (tenant) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function updateTenantLabel() {
|
function updateTenantLabel() {
|
||||||
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; });
|
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||||
var tenantIcons = {
|
var tenantIcons = {
|
||||||
'学术·无界': 'graduation-cap',
|
'学术·无界': 'graduation-cap',
|
||||||
'MCN·无界': 'video',
|
'MCN·无界': 'video',
|
||||||
'科普·无界': 'flask-conical',
|
'科普·无界': 'flask-conical',
|
||||||
'医患·无界': 'stethoscope',
|
'医患·无界': 'stethoscope',
|
||||||
'科研·无界': 'microscope',
|
'科研·无界': 'microscope',
|
||||||
|
'测试·无界': 'flask-round',
|
||||||
'总览': 'layout-dashboard'
|
'总览': 'layout-dashboard'
|
||||||
};
|
};
|
||||||
const container = document.querySelector("#tenantList");
|
const container = document.querySelector("#tenantList");
|
||||||
@@ -91,13 +92,14 @@ 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 || []).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; });
|
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||||
var tenantIcons = {
|
var tenantIcons = {
|
||||||
'学术·无界': 'graduation-cap',
|
'学术·无界': 'graduation-cap',
|
||||||
'MCN·无界': 'video',
|
'MCN·无界': 'video',
|
||||||
'科普·无界': 'flask-conical',
|
'科普·无界': 'flask-conical',
|
||||||
'医患·无界': 'stethoscope',
|
'医患·无界': 'stethoscope',
|
||||||
'科研·无界': 'microscope',
|
'科研·无界': 'microscope',
|
||||||
|
'测试·无界': 'flask-round',
|
||||||
'总览': 'layout-dashboard'
|
'总览': 'layout-dashboard'
|
||||||
};
|
};
|
||||||
menu = document.createElement("div");
|
menu = document.createElement("div");
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const state = {
|
|||||||
expenseQuarter: "",
|
expenseQuarter: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")} 元`;
|
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
|
||||||
const text = (value) => value === undefined || value === null || value === "" ? "—" : esc(value);
|
const text = (value) => value === undefined || value === null || value === "" ? "—" : esc(value);
|
||||||
|
|
||||||
function escapeHtml(str) { return String(str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
function escapeHtml(str) { return String(str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
|
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
|
||||||
<div class="flex items-center gap-3 w-full">
|
<div class="flex items-center gap-3 w-full">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.9</span></p>
|
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.16</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