diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py
index 0efdece..ac433f6 100644
--- a/backend/migrations/__init__.py
+++ b/backend/migrations/__init__.py
@@ -17,7 +17,7 @@ def run_migrations():
"""
from migrations.tables import migrate_create_tables
from migrations.columns import migrate_add_columns
- from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status
+ from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status, migrate_move_pending_to_plan
from migrations.data_split import migrate_data_split
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
@@ -28,6 +28,7 @@ def run_migrations():
migrate_drop_product_fields()
migrate_fix_service_fee_standard()
migrate_fix_expense_status()
+ migrate_move_pending_to_plan()
migrate_data_split()
migrate_seed_users()
migrate_seed_demo_data()
diff --git a/backend/migrations/data_fixes.py b/backend/migrations/data_fixes.py
index 65bd756..fd74b70 100644
--- a/backend/migrations/data_fixes.py
+++ b/backend/migrations/data_fixes.py
@@ -112,3 +112,31 @@ def migrate_fix_expense_status():
print(f"[migrate] expense_records: {affected} 条记录 status 修正为 '计入费用'")
finally:
conn.close()
+
+
+def migrate_move_pending_to_plan():
+ """迁移业务待签约项目到计划模块(幂等)"""
+ from db import db
+
+ conn = db()
+ try:
+ cur = conn.cursor()
+ # 仅迁移 id 不存在于计划表的待签约项目
+ cur.execute(
+ "INSERT INTO plan_finances "
+ "SELECT * FROM project_finances WHERE status='待签约' "
+ "AND id NOT IN (SELECT id FROM plan_finances)"
+ )
+ moved = cur.rowcount
+ if moved:
+ # 删除已迁移的原记录
+ cur.execute(
+ "DELETE FROM project_finances WHERE status='待签约' "
+ "AND id IN (SELECT id FROM plan_finances)"
+ )
+ deleted = cur.rowcount
+ conn.commit()
+ print(f"[migrate] 待签约项目迁移到计划: {moved} 条 (已删除 {deleted} 条)")
+ cur.close()
+ finally:
+ conn.close()
diff --git a/backend/migrations/tables.py b/backend/migrations/tables.py
index a8a91ae..dea8752 100644
--- a/backend/migrations/tables.py
+++ b/backend/migrations/tables.py
@@ -158,6 +158,8 @@ def migrate_create_tables():
created_at VARCHAR(30) NOT NULL DEFAULT '',
updated_at VARCHAR(30) NOT NULL DEFAULT ''
)""",
+ """CREATE TABLE IF NOT EXISTS plan_finances LIKE project_finances""",
+ """CREATE TABLE IF NOT EXISTS plan_expense_records LIKE expense_records""",
]
for ddl in tables:
diff --git a/backend/routes.py b/backend/routes.py
index d9b4821..eba32ed 100644
--- a/backend/routes.py
+++ b/backend/routes.py
@@ -25,6 +25,8 @@ TABLES = {
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
"expense": ("expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
+ "planFinances": ("plan_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
+ "planExpense": ("plan_expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
}
# ---------- 鉴权装饰器 ----------
@@ -426,7 +428,7 @@ def bootstrap():
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
"risks": [],
}
- return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "expense": [], "tenant": tenant, "tenants": allowed})
+ return jsonify({"summary": summary, "sales": [], "proposals": [], "operations": [], "products": [], "finance": [], "projectFinances": [], "financeMonthly": merged_monthly, "tasks": [], "expense": [], "planFinances": [], "planExpense": [], "tenant": tenant, "tenants": allowed})
def q(sql, *args):
return rows(conn, sql, args)
@@ -438,6 +440,8 @@ def bootstrap():
tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant)
pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant)
expense = q("SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", tenant)
+ planPfs = q("SELECT * FROM plan_finances WHERE tenant=? ORDER BY id DESC", tenant)
+ planExpense = q("SELECT * FROM plan_expense_records WHERE tenant=? ORDER BY id DESC", tenant)
signed_pfs = [x for x in pfs if x["status"] == "已签约"]
def parse_budget(pf):
@@ -650,7 +654,7 @@ def bootstrap():
"recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
}
- return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "expense": expense, "tenant": tenant, "tenants": allowed})
+ return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "expense": expense, "planFinances": planPfs, "planExpense": planExpense, "tenant": tenant, "tenants": allowed})
finally:
conn.close()
diff --git a/static/modules/finance.js b/static/modules/finance.js
index c2462de..f52f685 100644
--- a/static/modules/finance.js
+++ b/static/modules/finance.js
@@ -116,7 +116,7 @@ function renderFinance() {
const finHeaderBase = `视图:${toolDateSelect}`;
const finAddBtn = ``;
- const finFilterTabs = `
${finFilterTabs}
@@ -261,38 +261,49 @@ function renderFinance() {
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
].filter(c => state.finFilter !== '待签约' || !c.hideUnsigned);
+ // 列排序
+ if (!state.finSort) state.finSort = { key: null, asc: true };
+ const sortData = (data, key) => {
+ if (!key || !state.finSort.key) return data;
+ var asc = state.finSort.asc ? 1 : -1;
+ return data.slice().sort(function(a, b) {
+ var va = a[key], vb = b[key];
+ if (typeof va === 'string') va = va || '', vb = vb || '';
+ else { va = parseFloat(va) || 0; vb = parseFloat(vb) || 0; }
+ if (va < vb) return -1 * asc;
+ if (va > vb) return 1 * asc;
+ return 0;
+ });
+ };
+ const buildThead = (cols, sortKey) => {
+ var h = '
';
+ cols.forEach(function(c) {
+ var arrow = sortKey ? (sortKey === c.key ? (state.finSort.asc ? ' ↑' : ' ↓') : '') : '';
+ h += '| ' + c.label + arrow + ' | ';
+ });
+ h += '
';
+ return h;
+ };
+ const SIGNED_COLS = [
+ { key: 'customer_name', label: '项目名称' }, { key: 'status', label: '状态' }, { key: 'sign_amount', label: '签约金额' },
+ { key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
+ { key: 'profit', label: '项目利润' }, { key: 'payment', label: '已回款' }, { key: 'paid', label: '已付' },
+ { key: 'cashflow', label: '现金流' }, { key: 'exe_rate', label: '执行率' }, { key: 'gross_rate', label: '毛利率' },
+ { key: 'pay_rate', label: '回款率' }, { key: 'paid_rate', label: '付款率' },
+ ];
+ const UNSIGNED_COLS = [
+ { key: 'customer_name', label: '项目名称' }, { key: 'status', label: '状态' }, { key: 'sign_amount', label: '签约金额' },
+ { key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
+ { key: 'profit', label: '项目利润' },
+ ];
+
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt };
const cards = METRIC_CARDS.map(c => {
const v = c.value(ctx);
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
});
- const thead = isUnsigned
- ? '
' +
- '| 项目名称 | ' +
- '状态 | ' +
- '签约金额 | ' +
- '签约月份 | ' +
- '毛利 | ' +
- '成本 | ' +
- '项目利润 | ' +
- '
'
- : '
' +
- '| 项目名称 | ' +
- '状态 | ' +
- '签约金额 | ' +
- '已确收 | ' +
- '毛利 | ' +
- '成本 | ' +
- '项目利润 | ' +
- '已回款 | ' +
- '已付 | ' +
- '现金流 | ' +
- '执行率 | ' +
- '毛利率 | ' +
- '回款率 | ' +
- '付款率 | ' +
- '
';
+ const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
const theadCols = isUnsigned ? 7 : 14;
const tbody = rows.length ? `
${rows.join("")}` : `
| ${emptyText} |
`;
@@ -336,8 +347,7 @@ function renderFinance() {
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
if (!state.finMonth) state.finMonth = defaultMonth;
const selMonth = state.finMonth;
- const rows = [];
- let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
+ var dataItems = [];
allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
@@ -349,8 +359,24 @@ function renderFinance() {
const cost = Math.round(parseFloat(e.cost || 0) || 0);
const paid = Math.round(parseFloat(e.paid || 0) || 0);
if (!rev && !payment && !cost && !paid && !gross) return;
- sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
- rows.push(isUnsigned ? tdRowUnsigned(pf, cost, gross) : tdRow(pf, rev, payment, cost, paid, gross));
+ dataItems.push({
+ pf: pf, customer_name: pf.customer_name || '', status: pf.status || '',
+ sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
+ rev: rev, gross: gross, cost: cost, profit: gross, payment: payment, paid: paid,
+ cashflow: payment - paid,
+ exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
+ gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
+ pay_rate: rev && payment ? Math.round(payment / rev * 100) : 0,
+ paid_rate: cost && paid ? Math.round(paid / cost * 100) : 0,
+ });
+ });
+ var sortK = state.finSort && state.finSort.key ? state.finSort.key.split('|')[1] : null;
+ dataItems = sortData(dataItems, sortK);
+ const rows = [];
+ let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
+ dataItems.forEach(d => {
+ sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
+ rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
const signTotal = Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约" && (x.sign_month || "").substring(0, 7) === selMonth).length;
@@ -377,8 +403,7 @@ function renderFinance() {
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(e[field] || 0); }); } catch (e) {}
return total;
};
- let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
- const rows = [];
+ var dataItems = [];
allPfs.forEach(pf => {
const rev = sumBudget(pf, "rev");
const payment = sumBudget(pf, "payment");
@@ -386,8 +411,24 @@ function renderFinance() {
const cost = sumExpense(pf, "cost");
const paid = sumExpense(pf, "paid");
if (!rev && !payment && !cost && !paid && !gross) return;
- sumRev += rev; sumPay += payment; sumCost += cost; sumPaid += paid; sumGross += gross;
- rows.push(isUnsigned ? tdRowUnsigned(pf, cost, gross) : tdRow(pf, rev, payment, cost, paid, gross));
+ dataItems.push({
+ pf: pf, customer_name: pf.customer_name || '', status: pf.status || '',
+ sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
+ rev: rev, gross: gross, cost: cost, profit: gross, payment: payment, paid: paid,
+ cashflow: payment - paid,
+ exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
+ gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
+ pay_rate: rev && payment ? Math.round(payment / rev * 100) : 0,
+ paid_rate: cost && paid ? Math.round(paid / cost * 100) : 0,
+ });
+ });
+ var sortK = state.finSort && state.finSort.key ? state.finSort.key.split('|')[1] : null;
+ dataItems = sortData(dataItems, sortK);
+ let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
+ const rows = [];
+ dataItems.forEach(d => {
+ sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
+ rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
const signTotal = Math.round(allPfs.filter(x => { const m = parseInt((x.sign_month || "0").substring(5)) || 0; return qRange.includes(m); }).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约" && qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length;
@@ -405,12 +446,26 @@ function renderFinance() {
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
};
const allPfs = pfs.filter(x => x.status === state.finFilter);
+ var dataItems = allPfs.map(pf => {
+ var t = calcTotals(pf);
+ return {
+ pf: pf, customer_name: pf.customer_name || '', status: pf.status || '',
+ sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
+ rev: t.rev, gross: t.gross, cost: t.cost, profit: t.gross,
+ payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid,
+ exe_rate: t.rev && pf.sign_amount ? Math.round(t.rev / pf.sign_amount * 100) : 0,
+ gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
+ pay_rate: t.rev && t.payment ? Math.round(t.payment / t.rev * 100) : 0,
+ paid_rate: t.cost && t.paid ? Math.round(t.paid / t.cost * 100) : 0,
+ };
+ });
+ var sortK = state.finSort && state.finSort.key ? state.finSort.key.split('|')[1] : null;
+ dataItems = sortData(dataItems, sortK);
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
const rows = [];
- allPfs.forEach(pf => {
- const t = calcTotals(pf);
- sumRev += t.rev; sumPay += t.payment; sumCost += t.cost; sumPaid += t.paid; sumGross += t.gross;
- rows.push(isUnsigned ? tdRowUnsigned(pf, t.cost, t.gross) : tdRow(pf, t.rev, t.payment, t.cost, t.paid, t.gross));
+ dataItems.forEach(d => {
+ sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
+ rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
});
const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => x.status === "已签约").length;
diff --git a/static/modules/plan.js b/static/modules/plan.js
new file mode 100644
index 0000000..d5fa86a
--- /dev/null
+++ b/static/modules/plan.js
@@ -0,0 +1,1053 @@
+// plan.js — 计划管理模块
+console.log('plan.js loaded');
+
+function renderPlan() {
+ const pfs = state.data.planFinances || [];
+ const ops = state.data.operations || [];
+ const fmTypesByTenant = {
+ "科普·无界": ["科普音频","科普视频","科普文章","科普专访","患教会","全品类科普","调研问卷"],
+ "科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"],
+ "医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"],
+ };
+ const fmTypes = fmTypesByTenant[state.tenant] || fmTypesByTenant["科普·无界"];
+ const tenantOps = (state.data.operations || []).filter(o => (o.project_name || "").includes(state.tenant.replace("·无界","")) || o.tenant === state.tenant);
+ const now = new Date();
+ const thisMonth = now.getMonth() + 1;
+ const displayMonths = [];
+ for (let i = 0; i < 4; i++) {
+ const m = thisMonth + i;
+ const mm = m > 12 ? m - 12 : m;
+ displayMonths.push({ key: "2026_" + String(mm).padStart(2, "0"), label: mm + "月" });
+ }
+ const months = displayMonths.map(d => d.key);
+ const monthLabels = displayMonths.map(d => d.label);
+
+ const signed = pfs.filter(x => x.status === "已签约");
+ const pending = pfs.filter(x => x.status === "待签约");
+ const sumSign = Math.round(signed.reduce((s,x) => s + (x.sign_amount||0), 0));
+ const sumPending = Math.round(pending.reduce((s,x) => s + (x.sign_amount||0), 0));
+
+ const monthRev = months.map(m => {
+ return signed.reduce((s, pf) => {
+ let budget = [];
+ try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
+ const row = budget.find(b => (b.month || "").replace("-", "_") === m);
+ return s + (row ? (parseFloat(row.rev) || 0) : 0);
+ }, 0);
+ });
+ const monthGross = months.map(m => {
+ return signed.reduce((s, pf) => {
+ let budget = [];
+ try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
+ const row = budget.find(b => (b.month || "").replace("-", "_") === m);
+ return s + (row ? (parseFloat(row.gross) || 0) : 0);
+ }, 0);
+ });
+
+ const thisMonthKey = displayMonths[0].key;
+ const thisMonthRev = monthRev[0];
+ const thisMonthGross = monthGross[0];
+ let monthPayment = 0, monthCost = 0;
+ for (const pf of pfs) {
+ let budget = [];
+ try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
+ for (const b of budget) {
+ const bKey = (b.month || "").replace("-", "_");
+ if (bKey === thisMonthKey) {
+ monthPayment += parseFloat(b.payment || 0);
+ monthCost += parseFloat(b.cost || 0);
+ break;
+ }
+ }
+ }
+ monthPayment = Math.round(monthPayment);
+ monthCost = Math.round(monthCost);
+ const monthCashflow = monthPayment - monthCost;
+
+ const renderPfRow = (pf) => {
+ let budgetMap = {};
+ try {
+ const budget = JSON.parse(pf.budget_data || "[]");
+ budget.forEach(b => { budgetMap[(b.month || "").replace("-", "_")] = b; });
+ } catch (e) {}
+ const isRevView = state.planView !== "cashflow" && state.planView !== "overview" && state.planView !== "monthly" && state.planView !== "quarterly";
+ const mCols = months.map(m => {
+ const b = budgetMap[m] || {};
+ if (isRevView) {
+ const rev = b.rev || 0;
+ const gross = b.gross || 0;
+ return `
${rev ? money(rev) : '—'} ${gross ? money(gross) : '—'} | `;
+ } else {
+ const payment = b.payment || 0;
+ const cost = b.cost || 0;
+ return `
${payment ? money(payment) : '—'} ${cost ? money(cost) : '—'} | `;
+ }
+ }).join("");
+ const totalCol = (() => {
+ if (isRevView) {
+ const totalRev = pf.total_rev || 0;
+ const totalGross = pf.total_gross || 0;
+ return `
${totalRev ? money(totalRev) : '—'} ${totalGross ? money(totalGross) : '—'} | `;
+ } else {
+ let totalPayment = 0, totalCost = 0;
+ try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
+ return `
${totalPayment ? money(totalPayment) : '—'} ${totalCost ? money(totalCost) : '—'} | `;
+ }
+ })();
+ const sm = pf.sign_month || "";
+ const signMonthCell = `
${sm || '—'} | `;
+ return `
| ${esc(pf.customer_name)} | ${signMonthCell}${money(pf.sign_amount)} | ${mCols}${totalCol}
`;
+ };
+
+ const now2 = new Date();
+ const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
+ if (!state.planMonth) state.planMonth = defaultMonth2;
+ if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3);
+ const monthSet2 = new Set([defaultMonth2]);
+ pfs.forEach(pf => { let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch(e) {} bd.forEach(b => { if (b.month) monthSet2.add(b.month); }); });
+ const allMonths = [...monthSet2].sort().reverse();
+ const qLabels2 = ["Q1 (1-3月)","Q2 (4-6月)","Q3 (7-9月)","Q4 (10-12月)"];
+ const toolMonthSelect = allMonths.map(m => '
').join("");
+ const toolQuarterSelect = qLabels2.map((l,i) => '
').join("");
+ const toolDateSelect = state.planView==='monthly'?'
月份:':state.planView==='quarterly'?'
季度:':'';
+
+ const finHeaderBase = `
视图:${toolDateSelect}`;
+ const finAddBtn = `
`;
+
+ const planFilterTabs = `
`;
+
+ document.querySelector("#plan").innerHTML = `
+ ${planFilterTabs}
+ ${state.planFilter !== 'expense' && state.planFilter !== 'overview' ? card(`
${finHeaderBase}
${finAddBtn}
`, "p-4") : ''}
+
+
+
+
+
+
+
+
+
+
+ ${(() => {
+ if (state.planFilter === 'overview') {
+ setTimeout(() => renderPlanOverview(), 10);
+ return `
`;
+ }
+ if (state.planFilter === 'expense') {
+ setTimeout(() => renderPlanExpense(), 10);
+ return `
`;
+ }
+ const isUnsigned = state.planFilter === '待签约';
+ const METRIC_CARDS = [
+ { label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
+ { label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
+ { label: '毛利', value: ctx => money(ctx.sumGross), color: 'text-green-700' },
+ { label: '成本', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
+ { label: '项目利润', value: ctx => { const v = ctx.sumGross; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
+ { label: '已回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
+ { label: '已付', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
+ { label: '现金流', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: money(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
+ { label: '执行率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.signTotal ? Math.round(ctx.sumRev / ctx.signTotal * 100) + '%' : '—', color: 'text-slate-500' },
+ { label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
+ { label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.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 => state.planFilter !== '待签约' || !c.hideUnsigned);
+
+ // 列排序
+ if (!state.planSort) state.planSort = { key: null, asc: true };
+ const sortData = (data, key) => {
+ if (!key || !state.planSort.key) return data;
+ var asc = state.planSort.asc ? 1 : -1;
+ return data.slice().sort(function(a, b) {
+ var va = a[key], vb = b[key];
+ if (typeof va === 'string') va = va || '', vb = vb || '';
+ else { va = parseFloat(va) || 0; vb = parseFloat(vb) || 0; }
+ if (va < vb) return -1 * asc;
+ if (va > vb) return 1 * asc;
+ return 0;
+ });
+ };
+ const buildThead = (cols, sortKey) => {
+ var h = '
';
+ cols.forEach(function(c) {
+ var arrow = sortKey ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
+ var clickable = sortKey ? ' cursor-pointer select-none' : '';
+ h += '| ' + c.label + arrow + ' | ';
+ });
+ h += '
';
+ return h;
+ };
+ const SIGNED_COLS = [
+ { key: 'customer_name', label: '项目名称' },
+ { key: 'status', label: '状态' },
+ { key: 'sign_amount', label: '签约金额' },
+ { key: 'rev', label: '已确收' },
+ { key: 'gross', label: '毛利' },
+ { key: 'cost', label: '成本' },
+ { key: 'profit', label: '项目利润' },
+ { key: 'payment', label: '已回款' },
+ { key: 'paid', label: '已付' },
+ { key: 'cashflow', label: '现金流' },
+ { key: 'exe_rate', label: '执行率' },
+ { key: 'gross_rate', label: '毛利率' },
+ { key: 'pay_rate', label: '回款率' },
+ { key: 'paid_rate', label: '付款率' },
+ ];
+ const UNSIGNED_COLS = [
+ { key: 'customer_name', label: '项目名称' },
+ { key: 'status', label: '状态' },
+ { key: 'sign_amount', label: '签约金额' },
+ { key: 'sign_month', label: '签约月份' },
+ { key: 'gross', label: '毛利' },
+ { key: 'cost', label: '成本' },
+ { key: 'profit', label: '项目利润' },
+ ];
+
+ const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
+ const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt };
+ const cards = METRIC_CARDS.map(c => {
+ const v = c.value(ctx);
+ return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
+ });
+ const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
+ const theadCols = isUnsigned ? 7 : 14;
+ const tbody = rows.length ? `
${rows.join("")}` : `
| ${emptyText} |
`;
+
+ const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
+ const subtitle = isUnsigned
+ ? '合同 → 毛利 → 成本 → 项目利润'
+ : '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
+ return card(`
${cards.map(([l, v, c]) => '
' + l + '' + v + '
').join("")}
`, "p-4") +
+ card(`
项目财务明细
${subtitle}
`, "p-4");
+ };
+
+ const fmtNoUnit = (v) => v ? `
${Number(v||0).toLocaleString('zh-CN')}` : '
—';
+ const tdRow = (pf, rev, payment, cost, paid, gross) => {
+ const cashflow = payment - paid;
+ const exe = rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) + '%' : '
—';
+ const payRVal = rev && payment ? Math.round(payment / rev * 100) : null;
+ const payRCls = payRVal === null ? '' : payRVal < 30 ? 'text-red-600' : payRVal < 80 ? 'text-amber-600' : 'text-green-600';
+ const payR = payRVal !== null ? '
' + payRVal + '%' : '
—';
+ const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '
—';
+ const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '
—';
+ const st = pf.status === '已签约' ? '
已签约' : '
待签约';
+ const cf = cashflow ? money(cashflow) : '
—';
+ const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
+ const profit = gross;
+ const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
+ const pfVal = profit ? money(profit) : '
—';
+ return `
| ${esc(pf.customer_name)} | ${st} | ${money(pf.sign_amount)} | ${fmtNoUnit(rev)} | ${fmtNoUnit(gross)} | ${fmtNoUnit(cost)} | ${pfVal} | ${fmtNoUnit(payment)} | ${fmtNoUnit(paid)} | ${cf} | ${exe} | ${grossR} | ${payR} | ${paidR} |
`;
+ };
+ // 待签约简版行
+ const tdRowUnsigned = (pf, cost, gross) => {
+ const st = pf.status === '待签约' ? '
待签约' : '
已签约';
+ const profit = gross;
+ const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
+ const pfVal = profit ? money(profit) : '
—';
+ return `
| ${esc(pf.customer_name)} | ${st} | ${money(pf.sign_amount)} | ${pf.sign_month || '—'} | ${fmtNoUnit(gross)} | ${fmtNoUnit(cost)} | ${pfVal} |
`;
+ };
+
+ if (state.planView === 'monthly') {
+ const allPfs = pfs.filter(x => x.status === state.planFilter);
+ const now = new Date();
+ const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
+ if (!state.planMonth) state.planMonth = defaultMonth;
+ const selMonth = state.planMonth;
+ var dataItems = [];
+ allPfs.forEach(pf => {
+ let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
+ let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
+ const b = bd.find(x => (x.month || "") === selMonth) || {};
+ const e = ed.find(x => (x.month || "") === selMonth) || {};
+ const rev = Math.round(parseFloat(b.rev || 0) || 0);
+ const payment = Math.round(parseFloat(b.payment || 0) || 0);
+ const gross = Math.round(parseFloat(b.gross || 0) || 0);
+ const cost = Math.round(parseFloat(e.cost || 0) || 0);
+ const paid = Math.round(parseFloat(e.paid || 0) || 0);
+ if (!rev && !payment && !cost && !paid && !gross) return;
+ dataItems.push({
+ pf: pf,
+ customer_name: pf.customer_name || '',
+ status: pf.status || '',
+ sign_amount: pf.sign_amount || 0,
+ sign_month: pf.sign_month || '',
+ rev: rev, gross: gross, cost: cost,
+ profit: gross, payment: payment, paid: paid,
+ cashflow: payment - paid,
+ exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
+ gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
+ pay_rate: rev && payment ? Math.round(payment / rev * 100) : 0,
+ paid_rate: cost && paid ? Math.round(paid / cost * 100) : 0,
+ });
+ });
+ var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
+ dataItems = sortData(dataItems, sortK);
+ const rows = [];
+ let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
+ dataItems.forEach(d => {
+ sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
+ rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
+ });
+ const signTotal = Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0));
+ const signCnt = allPfs.filter(x => x.status === "已签约" && (x.sign_month || "").substring(0, 7) === selMonth).length;
+ return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据');
+ }
+
+ if (state.planView === 'quarterly') {
+ const allPfs = pfs.filter(x => x.status === state.planFilter);
+ const qRanges = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
+ const now = new Date();
+ if (state.planQuarter === undefined) {
+ const saved = localStorage.getItem("opc-fin-quarter");
+ state.planQuarter = saved !== null ? parseInt(saved) : Math.floor(now.getMonth() / 3);
+ }
+ const selQ = state.planQuarter;
+ const qRange = qRanges[selQ];
+ const sumBudget = (pf, field) => {
+ let total = 0;
+ try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(b[field] || 0); }); } catch (e) {}
+ return total;
+ };
+ const sumExpense = (pf, field) => {
+ let total = 0;
+ try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; if (qRange.includes(m)) total += parseFloat(e[field] || 0); }); } catch (e) {}
+ return total;
+ };
+ let dataItems = [];
+ allPfs.forEach(pf => {
+ const rev = sumBudget(pf, "rev");
+ const payment = sumBudget(pf, "payment");
+ const gross = sumBudget(pf, "gross");
+ const cost = sumExpense(pf, "cost");
+ const paid = sumExpense(pf, "paid");
+ if (!rev && !payment && !cost && !paid && !gross) return;
+ dataItems.push({
+ pf: pf,
+ customer_name: pf.customer_name || '',
+ status: pf.status || '',
+ sign_amount: pf.sign_amount || 0,
+ sign_month: pf.sign_month || '',
+ rev: rev, gross: gross, cost: cost,
+ profit: gross, payment: payment, paid: paid,
+ cashflow: payment - paid,
+ exe_rate: rev && pf.sign_amount ? Math.round(rev / pf.sign_amount * 100) : 0,
+ gross_rate: rev && gross ? Math.round(gross / rev * 100) : 0,
+ pay_rate: rev && payment ? Math.round(payment / rev * 100) : 0,
+ paid_rate: cost && paid ? Math.round(paid / cost * 100) : 0,
+ });
+ });
+ var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
+ dataItems = sortData(dataItems, sortK);
+ let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
+ const rows = [];
+ dataItems.forEach(d => {
+ sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
+ rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
+ });
+ const signTotal = Math.round(allPfs.filter(x => { const m = parseInt((x.sign_month || "0").substring(5)) || 0; return qRange.includes(m); }).reduce((a, p) => a + (p.sign_amount || 0), 0));
+ const signCnt = allPfs.filter(x => x.status === "已签约" && qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length;
+ return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据');
+ }
+
+ // 总视图
+ const calcTotals = (pf) => {
+ let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
+ let expense = []; try { expense = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
+ let rev = 0, payment = 0, gross = 0;
+ budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; });
+ let cost = 0, paid = 0;
+ expense.forEach(e => { cost += parseFloat(e.cost || 0) || 0; paid += parseFloat(e.paid || 0) || 0; });
+ return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
+ };
+ const allPfs = pfs.filter(x => x.status === state.planFilter);
+ var dataItems = allPfs.map(pf => {
+ var t = calcTotals(pf);
+ var cashflow = t.payment - t.paid;
+ return {
+ pf: pf,
+ customer_name: pf.customer_name || '',
+ status: pf.status || '',
+ sign_amount: pf.sign_amount || 0,
+ rev: t.rev, gross: t.gross, cost: t.cost,
+ profit: t.gross, payment: t.payment, paid: t.paid,
+ cashflow: cashflow,
+ exe_rate: t.rev && pf.sign_amount ? Math.round(t.rev / pf.sign_amount * 100) : 0,
+ gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
+ pay_rate: t.rev && t.payment ? Math.round(t.payment / t.rev * 100) : 0,
+ paid_rate: t.cost && t.paid ? Math.round(t.paid / t.cost * 100) : 0,
+ };
+ });
+ var sortK = null;
+ if (state.planSort && state.planSort.key && state.planSort.key.startsWith('sig|')) sortK = state.planSort.key.split('|')[1];
+ dataItems = sortData(dataItems, sortK);
+ let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
+ const rows = [];
+ dataItems.forEach(d => {
+ sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
+ rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
+ });
+ const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
+ const signCnt = allPfs.filter(x => x.status === "已签约").length;
+ return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据');
+ })()}
+
`;
+ if (window.lucide) window.lucide.createIcons();
+}
+
+window.openPlanModal = () => {
+ const modal = document.querySelector("#planModal");
+ const form = modal.querySelector("form");
+ form.querySelector('[name="project_id"]').value = state.tenant;
+ const dept = form.querySelector('input[disabled]');
+ if (dept) dept.value = state.tenant;
+ const pfIdInput = form.querySelector('[name="pf_id"]');
+ if (!pfIdInput || !pfIdInput.value) {
+ initRevpayTable(null);
+ initCostTable(null);
+ initTaskTable(null);
+ document.querySelector("#planDeleteBtn").classList.add("hidden");
+ }
+ modal.classList.remove("hidden");
+};
+
+window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
+ const tbody = document.querySelector("#revpayTbody");
+ if (!tbody) return;
+ const row = document.createElement("tr");
+ row.innerHTML = `
|
+
|
+
|
+
|
+
|
+
| `;
+ tbody.appendChild(row);
+ if (window.lucide) window.lucide.createIcons();
+};
+
+window.addCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
+ const tbody = document.querySelector("#costTbody");
+ if (!tbody) return;
+ const row = document.createElement("tr");
+ row.innerHTML = `
|
+
|
+
|
+
|
+
|
+
| `;
+ tbody.appendChild(row);
+ if (window.lucide) window.lucide.createIcons();
+};
+
+window.updateRevpaySummary = () => {
+ const revEl = document.querySelector("#revpayTotalRev");
+ const grossEl = document.querySelector("#revpayTotalGross");
+ const paymentEl = document.querySelector("#revpayTotalPayment");
+ if (!revEl || !paymentEl) return;
+ const revInputs = document.querySelectorAll('[name="budget_rev[]"]');
+ const grossInputs = document.querySelectorAll('[name="budget_gross[]"]');
+ const paymentInputs = document.querySelectorAll('[name="budget_payment[]"]');
+ let totalRev = 0, totalGross = 0, totalPayment = 0;
+ revInputs.forEach(el => { totalRev += parseFloat(el.value) || 0; });
+ grossInputs.forEach(el => { totalGross += parseFloat(el.value) || 0; });
+ paymentInputs.forEach(el => { totalPayment += parseFloat(el.value) || 0; });
+ revEl.textContent = money(totalRev);
+ if (grossEl) grossEl.textContent = money(totalGross);
+ paymentEl.textContent = money(totalPayment);
+};
+
+window.updateCostSummary = () => {
+ const costEl = document.querySelector("#costTotalCost");
+ const paidEl = document.querySelector("#costTotalPaid");
+ if (!costEl || !paidEl) return;
+ const costInputs = document.querySelectorAll('[name="expense_cost[]"]');
+ const paidInputs = document.querySelectorAll('[name="expense_paid[]"]');
+ let totalCost = 0, totalPaid = 0;
+ costInputs.forEach(el => { totalCost += parseFloat(el.value) || 0; });
+ paidInputs.forEach(el => { totalPaid += parseFloat(el.value) || 0; });
+ costEl.textContent = money(totalCost);
+ paidEl.textContent = money(totalPaid);
+};
+
+window.initRevpayTable = (budgetData) => {
+ const tbody = document.querySelector("#revpayTbody");
+ if (!tbody) return;
+ tbody.innerHTML = "";
+ const rows = budgetData || [];
+ rows.forEach(r => addRevpayRow(r.month || '', r.rev || '', r.gross || '', r.payment || '', r.rev_note || ''));
+ setTimeout(() => updateRevpaySummary(), 50);
+};
+
+window.initCostTable = (expenseData) => {
+ const tbody = document.querySelector("#costTbody");
+ if (!tbody) return;
+ tbody.innerHTML = "";
+ const rows = expenseData || [];
+ rows.forEach(r => addCostRow(r.month || '', r.expense_type || '', r.cost || '', r.paid || '', r.exp_note || ''));
+ setTimeout(() => updateCostSummary(), 50);
+};
+
+// ---------- 任务管理 tab ----------
+
+function taskMonthOptions(selected) {
+ const now = new Date();
+ const opts = [];
+ for (let i = -2; i <= 12; i++) {
+ const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
+ const v = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
+ opts.push(`
`);
+ }
+ return opts.join("");
+}
+
+window.addTaskRow = (taskMonth = '', taskType = '', taskCount = '', executedCount = '', unitPrice = '') => {
+ const tbody = document.querySelector("#taskTbody");
+ if (!tbody) return;
+ const row = document.createElement("tr");
+ const defaultMonth = (() => { const n = new Date(); return n.getFullYear() + "-" + String(n.getMonth()+1).padStart(2,"0"); })();
+ const isPreset = TASK_TYPES.includes(taskType);
+ const typeCell = isPreset || !taskType
+ ? `
`
+ : `
`;
+ row.innerHTML = `
|
+
${typeCell} |
+
|
+
|
+
|
+
|
+
|
+
|
+
| `;
+ tbody.appendChild(row);
+ if (window.lucide) window.lucide.createIcons();
+ updateRowCalc(row);
+};
+
+window.updateTaskDiff = (el) => {
+ const row = el.closest('tr');
+ if (!row) return;
+ updateRowCalc(row);
+};
+
+function updateRowCalc(row) {
+ const countInput = row.querySelector('[name="task_count[]"]');
+ const execInput = row.querySelector('[name="task_executed[]"]');
+ const priceInput = row.querySelector('[name="task_unit_price[]"]');
+ const diffInput = row.querySelector('[name="task_diff[]"]');
+ const execAmtInput = row.querySelector('[name="task_exec_amount[]"]');
+ const unexecAmtInput = row.querySelector('[name="task_unexec_amount[]"]');
+ const c = parseFloat(countInput.value) || 0;
+ const e = parseFloat(execInput.value) || 0;
+ const p = parseFloat(priceInput.value) || 0;
+ const diff = c - e;
+ // 差额
+ diffInput.value = (!c && !e) ? '' : diff;
+ // 执行金额 = 单价 * 已执行
+ const execAmt = p * e;
+ execAmtInput.value = execAmt ? execAmt.toFixed(2) : '';
+ // 未执行金额 = 单价 * 差额
+ const unexecAmt = p * diff;
+ unexecAmtInput.value = unexecAmt ? unexecAmt.toFixed(2) : '';
+}
+
+window.toggleBtChip = (chip) => {
+ const cb = chip.querySelector('input');
+ cb.checked = !cb.checked;
+ chip.classList.toggle('bg-blue-50', cb.checked);
+ chip.classList.toggle('border-blue-400', cb.checked);
+ chip.classList.toggle('text-blue-600', cb.checked);
+};
+
+window.initTaskTable = (taskData) => {
+ const tbody = document.querySelector("#taskTbody");
+ if (!tbody) return;
+ tbody.innerHTML = "";
+ const rows = taskData || [];
+ rows.forEach(r => addTaskRow(r.task_month || '', r.task_type || '', r.task_count || '', r.task_executed || '', r.unit_price || ''));
+};
+
+window.onTaskTypeChange = (sel) => {
+ if (sel.value !== '__custom__') return;
+ const td = sel.parentElement;
+ const oldVal = sel.value;
+ td.innerHTML = `
`;
+ if (window.lucide) window.lucide.createIcons();
+ td.querySelector('input').focus();
+};
+
+window.revertTaskType = (btn) => {
+ const td = btn.parentElement;
+ td.innerHTML = `
`;
+ if (window.lucide) window.lucide.createIcons();
+};
+
+window.closePlanModal = () => {
+ const modal = document.querySelector("#planModal");
+ modal.classList.add("hidden");
+};
+
+window.editPfSignMonth = (event, pfId) => {
+ event.stopPropagation();
+ const pf = (state.data.planFinances || []).find(x => x.id === pfId);
+ if (!pf) return;
+ const span = event.currentTarget;
+ const td = span.parentElement;
+ const currentValue = pf.sign_month || "";
+ const select = document.createElement("select");
+ select.innerHTML = monthOptions(currentValue);
+ select.className = "form-ctrl form-ctrl-sm w-full";
+ select.value = currentValue;
+ select.addEventListener("change", async () => {
+ const newValue = select.value;
+ try {
+ await api(`/api/planFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
+ pf.sign_month = newValue;
+ td.innerHTML = `
${newValue || '—'}`;
+ } catch (e) { toast("修改失败:" + e.message, "error"); }
+ });
+ select.addEventListener("blur", () => {
+ td.innerHTML = `
${currentValue || '—'}`;
+ });
+ td.innerHTML = "";
+ td.appendChild(select);
+ select.focus();
+};
+
+window.switchPlanTab = (tab) => {
+ document.querySelectorAll(".plan-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
+ document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info");
+ document.querySelector("#financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
+ document.querySelector("#financeTabCost").classList.toggle("hidden", tab !== "cost");
+ document.querySelector("#financeTabExec").classList.toggle("hidden", tab !== "exec");
+ document.querySelector("#financeTabTasks").classList.toggle("hidden", tab !== "tasks");
+ document.querySelector("#financeTabActivity").classList.toggle("hidden", tab !== "activity");
+ document.querySelector(".plan-form-actions").classList.toggle("hidden", tab === "activity");
+ if (tab === "activity") initFinSquire();
+};
+
+// ---------- 活动与跟进 ----------
+
+async function loadFinFollowups(pfId) {
+ const list = document.querySelector("#finActivityList");
+ if (!list || !pfId) return;
+ try {
+ const fups = await api(`/api/followups/project_finance/${pfId}`);
+ list.innerHTML = fups.length
+ ? fups.map(f => `
${esc(f.follower)} · ${esc(f.follow_up_method)}${esc(f.followed_at)}
${f.next_action ? `
下一步:${text(f.next_action)}
` : ""}
`).join("")
+ : '
暂无跟进记录
';
+ if (window.lucide) window.lucide.createIcons();
+ list.querySelectorAll(".rich-content").forEach(el => {
+ const html = el.dataset.html;
+ if (html) el.innerHTML = decodeURIComponent(html);
+ });
+ } catch (e) { /* ignore */ }
+}
+
+function initFinSquire() {
+ const ed = document.querySelector("#squire_finance");
+ if (!ed || !window.Squire) return;
+ if (window.squireInstances["squire_finance"]) { window.squireInstances["squire_finance"].destroy(); }
+ const sq = new Squire(ed, { blockTag: "P" });
+ window.squireInstances["squire_finance"] = sq;
+ ed.addEventListener("focus", () => ed.classList.add("focused"));
+ ed.addEventListener("blur", () => { if (!ed.textContent.trim()) ed.classList.remove("focused"); });
+}
+
+window.submitFinComment = async () => {
+ const pfId = document.querySelector("#pf-id-input").value;
+ if (!pfId) return;
+ const sq = window.squireInstances["squire_finance"];
+ const content = sq ? sq.getHTML().trim() : "";
+ if (!content || content === "
" || content === "
") return;
+ const btn = document.querySelector("#financeTabActivity .comment-submit");
+ btn.disabled = true;
+ btn.textContent = "发送中…";
+ await api(`/api/followups/project_finance/${pfId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
+ sq.setHTML("");
+ btn.disabled = false;
+ btn.textContent = "评论";
+ await loadFinFollowups(pfId);
+};
+
+window.deleteFinFollowup = async (event, followupId) => {
+ event.stopPropagation();
+ if (!confirm("确认删除这条评论?")) return;
+ await api(`/api/followups/${followupId}`, { method: "DELETE" });
+ const pfId = document.querySelector("#pf-id-input").value;
+ if (pfId) await loadFinFollowups(pfId);
+};
+
+window.openPfEditModal = (pfId) => {
+ const pf = (state.data.planFinances || []).find(x => x.id === pfId);
+ if (!pf) return;
+ document.querySelector("#pf-id-input").value = pf.id;
+ document.querySelector("#financeModalTitle").textContent = "编辑项目财务";
+ document.querySelector("#planDeleteBtn").classList.remove("hidden");
+ const form = document.querySelector("#financeModal form");
+ form.querySelector('[name="project_id"]').value = pf.project_id || "";
+ const deptDisplay = form.querySelector('.bg-slate-50 [disabled]');
+ if (deptDisplay) deptDisplay.value = pf.project_id || "";
+ // 回填业务类型多选
+ const btValues = (pf.business_type || "").split(/[,,]/).map(s => s.trim()).filter(Boolean);
+ form.querySelectorAll('[name="business_type[]"]').forEach(cb => {
+ cb.checked = btValues.includes(cb.value);
+ const chip = cb.closest('.bt-chip');
+ if (chip) {
+ chip.classList.toggle('bg-blue-50', cb.checked);
+ chip.classList.toggle('border-blue-400', cb.checked);
+ chip.classList.toggle('text-blue-600', cb.checked);
+ }
+ });
+ form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
+ const setVal = (name, val) => { const el = form.querySelector(`[name="${name}"]`); if (el) el.value = val || ""; };
+ setVal("project_code", pf.project_code);
+ form.querySelector('[name="sign_amount"]').value = pf.sign_amount || "";
+ const signMonthValue = pf.sign_month || "";
+ const signMonthEl = form.querySelector('[name="sign_month"]');
+ if (signMonthEl && signMonthValue) {
+ signMonthEl.innerHTML = monthOptions(signMonthValue);
+ signMonthEl.value = signMonthValue;
+ }
+ form.querySelector('[name="status"]').value = pf.status || "待签约";
+ form.querySelector('[name="sales_person"]').value = pf.sales_person || "";
+ form.querySelector('[name="owner"]').value = pf.owner || "";
+ setVal("start_date", pf.start_date);
+ setVal("end_date", pf.end_date);
+ setVal("task_type", pf.task_type);
+ setVal("task_count", pf.task_count);
+ setVal("service_fee_standard", pf.service_fee_standard || 5);
+ setVal("project_manager", pf.project_manager);
+ setVal("contact_name", pf.contact_name);
+ setVal("contact_phone", pf.contact_phone);
+ setVal("other_info", pf.other_info);
+ let budgetData = [];
+ try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; }
+ initRevpayTable(budgetData.length ? budgetData : null);
+ let expenseData = [];
+ try { expenseData = JSON.parse(pf.expense_data || "[]"); } catch (e) { expenseData = []; }
+ initCostTable(expenseData.length ? expenseData : null);
+ let taskData = [];
+ try { taskData = JSON.parse(pf.task_data || "[]"); } catch (e) { taskData = []; }
+ initTaskTable(taskData.length ? taskData : null);
+ setTimeout(() => updateBudgetSummary(), 100);
+ loadFinFollowups(pf.id);
+ openPlanModal();
+};
+
+window.createPlanFinance = async (event) => {
+ event.preventDefault();
+ const form = event.currentTarget;
+ const data = Object.fromEntries(new FormData(form).entries());
+ // 业务类型多选:合并为逗号分隔
+ const btChecked = form.querySelectorAll('[name="business_type[]"]:checked');
+ data.business_type = Array.from(btChecked).map(cb => cb.value).join(",");
+ data.tenant = state.tenant;
+ // 必填校验
+ if (!data.customer_name || !data.customer_name.trim()) { toast("项目名称必填", "error"); return; }
+ if (!data.sales_person || !data.sales_person.trim()) { toast("商务负责人必填", "error"); return; }
+ if (!data.owner || !data.owner.trim()) { toast("经营负责人必填", "error"); return; }
+ if (!data.sign_month) { toast("签约月份必填", "error"); return; }
+ data.sign_amount = parseFloat(data.sign_amount) || 0;
+ if (!(data.sign_amount > 0)) { toast("签约金额必须大于 0", "error"); return; }
+ const months = form.querySelectorAll('[name="budget_month[]"]');
+ const revs = form.querySelectorAll('[name="budget_rev[]"]');
+ const grosses = form.querySelectorAll('[name="budget_gross[]"]');
+ const payments = form.querySelectorAll('[name="budget_payment[]"]');
+ const revNotes = form.querySelectorAll('[name="budget_rev_note[]"]');
+ const budgetRows = [];
+ let totalRev = 0, totalGross = 0, totalPayment = 0;
+ for (let i = 0; i < months.length; i++) {
+ const m = months[i].value.trim();
+ if (!m) continue;
+ const rev = parseFloat(revs[i].value) || 0;
+ const gross = parseFloat(grosses[i].value) || 0;
+ const payment = parseFloat(payments[i].value) || 0;
+ const rev_note = (revNotes[i] ? revNotes[i].value : '') || '';
+ budgetRows.push({ month: m, rev, gross, payment, rev_note });
+ totalRev += rev;
+ totalGross += gross;
+ totalPayment += payment;
+ }
+ data.budget_data = JSON.stringify(budgetRows);
+ data.total_rev = totalRev;
+ data.total_gross = totalGross;
+ data.total_payment = totalPayment;
+
+ // 费用明细数据
+ const expMonths = form.querySelectorAll('[name="expense_month[]"]');
+ const expTypes = form.querySelectorAll('[name="expense_type[]"]');
+ const expCosts = form.querySelectorAll('[name="expense_cost[]"]');
+ const expPaids = form.querySelectorAll('[name="expense_paid[]"]');
+ const expNotes = form.querySelectorAll('[name="expense_note[]"]');
+ const expenseRows = [];
+ let totalCost = 0, totalPaid = 0;
+ for (let i = 0; i < expMonths.length; i++) {
+ const m = expMonths[i].value.trim();
+ if (!m) continue;
+ const expense_type = (expTypes[i] ? expTypes[i].value : '') || '';
+ const cost = parseFloat(expCosts[i].value) || 0;
+ const paid = parseFloat(expPaids[i].value) || 0;
+ const exp_note = (expNotes[i] ? expNotes[i].value : '') || '';
+ expenseRows.push({ month: m, expense_type, cost, paid, exp_note });
+ totalCost += cost;
+ totalPaid += paid;
+ }
+ data.expense_data = JSON.stringify(expenseRows);
+ data.total_paid = totalPaid;
+ data.total_cost = totalCost;
+ for (const r of budgetRows) { totalPayment += r.payment; }
+ data.total_payment = totalPayment;
+ // 收集任务管理数据
+ const taskTypeInputs = form.querySelectorAll('[name="task_type[]"]');
+ const taskCountInputs = form.querySelectorAll('[name="task_count[]"]');
+ const taskExecInputs = form.querySelectorAll('[name="task_executed[]"]');
+ const taskRows = [];
+ for (let i = 0; i < taskTypeInputs.length; i++) {
+ const tt = taskTypeInputs[i].value.trim();
+ if (!tt) continue;
+ taskRows.push({
+ task_month: form.querySelectorAll('[name="task_month[]"]')[i].value || '',
+ task_type: tt,
+ task_count: parseFloat(taskCountInputs[i].value) || 0,
+ task_executed: parseFloat(taskExecInputs[i].value) || 0,
+ unit_price: parseFloat(form.querySelectorAll('[name="task_unit_price[]"]')[i].value) || 0,
+ });
+ }
+ data.task_data = JSON.stringify(taskRows);
+ // 清除数组命名的字段(FormData 会收集 task_type[] 等),避免后端写入不存在的列
+ delete data.task_type;
+ delete data.task_count;
+ for (const key of Object.keys(data)) {
+ if (key.endsWith('[]')) delete data[key];
+ }
+ const pfId = data.pf_id;
+ delete data.pf_id;
+ try {
+ if (pfId) {
+ await api("/api/planFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data }) });
+ if (data.customer_name) logActivity("finance", pfId, "更新了「" + data.customer_name + "」的财务信息");
+ } else {
+ const result = await api("/api/planFinances", { method: "POST", body: JSON.stringify({ data }) });
+ if (result.id && data.customer_name) logActivity("finance", result.id, "创建了「" + data.customer_name + "」的财务项目");
+ }
+ form.reset();
+ document.querySelector("#pf-id-input").value = "";
+ document.querySelector("#financeModalTitle").textContent = "新增项目财务";
+ closePlanModal();
+ await load();
+ } catch (error) {
+ toast("保存失败:" + error.message, "error");
+ }
+};
+
+window.deletePlanItem = async () => {
+ const pfId = document.querySelector("#pf-id-input").value;
+ if (!pfId) return;
+ const pf = (state.data.planFinances || []).find(x => x.id === parseInt(pfId));
+ const name = pf ? (pf.customer_name || "此项目") : "此项目";
+ if (!confirm(`确认删除「${name}」?此操作不可撤销。`)) return;
+ try {
+ await api(`/api/planFinances/${pfId}`, { method: "DELETE" });
+ closePlanModal();
+ await load();
+ toast("已删除", "success");
+ } catch (error) {
+ toast("删除失败:" + error.message, "error");
+ }
+}
+
+// 总览渲染
+function renderPlanOverview() {
+ const { summary, financeMonthly } = state.data;
+ if (!summary) return;
+ const m = summary.metrics;
+ const moneyIntL = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
+ const moneyWan = (v) => {
+ const n = Number(v || 0);
+ return n >= 10000 ? (n / 10000).toFixed(0) + "万" : n.toLocaleString("zh-CN");
+ };
+ const card = (body, cls) => '
' + body + '
';
+
+ const rows1 = [["年度累计", moneyIntL(m.signed_annual || m.signed_amount)], ["本季度累计", moneyIntL(m.signed_q2 || 0)], ["本月累计", moneyIntL(m.signed_month || 0)], ["上本季度累计", moneyIntL(m.signed_prev_q || 0)], ["上月累计", moneyIntL(m.signed_prev_month || 0)]];
+ const rows2 = [["年度累计", moneyIntL(m.revenue_annual || 0)], ["本季度累计", moneyIntL(m.revenue_q2 || 0)], ["本月累计", moneyIntL(m.monthly_revenue || 0)], ["上本季度累计", moneyIntL(m.revenue_prev_q || 0)], ["上月累计", moneyIntL(m.revenue_prev_month || 0)]];
+ const rows3 = [["年度累计", moneyIntL(m.gross_annual || 0)], ["本季度累计", moneyIntL(m.gross_q2 || 0)], ["本月累计", moneyIntL(m.monthly_net_profit || 0)], ["上本季度累计", moneyIntL(m.gross_prev_q || 0)], ["上月累计", moneyIntL(m.gross_prev_month || 0)]];
+ const rows4 = [["年度累计", moneyIntL(m.cost_annual || 0)], ["本季度累计", moneyIntL(m.cost_q2 || 0)], ["本月累计", moneyIntL(m.cost_month || 0)], ["上本季度累计", moneyIntL(m.cost_prev_q || 0)], ["上月累计", moneyIntL(m.cost_prev_month || 0)]];
+ const rows5 = [["年度累计", moneyIntL(m.profit_annual || 0)], ["本季度累计", moneyIntL(m.profit_q2 || 0)], ["本月累计", moneyIntL(m.profit_month || 0)], ["上本季度累计", moneyIntL(m.profit_prev_q || 0)], ["上月累计", moneyIntL(m.profit_prev_month || 0)]];
+ const rows7 = [["年度累计", moneyIntL(m.payment_annual || 0)], ["本季度累计", moneyIntL(m.payment_q2 || 0)], ["本月累计", moneyIntL(m.payment_month || 0)], ["上本季度累计", moneyIntL(m.payment_prev_q || 0)], ["上月累计", moneyIntL(m.payment_prev_month || 0)]];
+ const rows8 = [["年度累计", moneyIntL(m.paid_annual || 0)], ["本季度累计", moneyIntL(m.paid_q2 || 0)], ["本月累计", moneyIntL(m.paid_month || 0)], ["上本季度累计", moneyIntL(m.paid_prev_q || 0)], ["上月累计", moneyIntL(m.paid_prev_month || 0)]];
+ const rows9 = [["年度累计", moneyIntL(m.cashflow_annual || 0)], ["本季度累计", moneyIntL(m.cashflow_q2 || 0)], ["本月累计", moneyIntL(m.cashflow_month || 0)], ["上本季度累计", moneyIntL(m.cashflow_prev_q || 0)], ["上月累计", moneyIntL(m.cashflow_prev_month || 0)]];
+
+ const qoq = (cur, prev) => { if (!prev) return '
—'; const pct = Math.round((cur - prev) / prev * 100); const cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; const sign = pct >= 0 ? '+' : ''; return '
' + sign + pct + '%'; };
+ const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
+ const Q_FIELDS = [m.signed_q2||0, m.revenue_q2, m.gross_q2, m.cost_q2||0, m.profit_q2||0, m.payment_q2||0, m.paid_q2||0, m.cashflow_q2||0];
+ const Q_PREV = [m.signed_prev_q||0, m.revenue_prev_q||0, m.gross_prev_q||0, m.cost_prev_q||0, m.profit_prev_q||0, m.payment_prev_q||0, m.paid_prev_q||0, m.cashflow_prev_q||0];
+ const M_FIELDS = [m.signed_month||0, m.monthly_revenue, m.monthly_net_profit, m.cost_month||0, m.profit_month||0, m.payment_month||0, m.paid_month||0, m.cashflow_month||0];
+ const M_PREV = [m.signed_prev_month||0, m.revenue_prev_month||0, m.gross_prev_month||0, m.cost_prev_month||0, m.profit_prev_month||0, m.payment_prev_month||0, m.paid_prev_month||0, m.cashflow_prev_month||0];
+ const ROWS = [rows1, rows2, rows3, rows4, rows5, rows7, rows8, rows9];
+ const CF_RAW = [m.cashflow_annual||0, m.cashflow_q2||0, m.cashflow_month||0, m.cashflow_prev_q||0, m.cashflow_prev_month||0];
+ const PF_RAW = [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0];
+
+ var tdVal = function(ri, col, val) {
+ if (ri === 4) return '
' + val + ' | ';
+ if (ri === 7) return '
' + val + ' | ';
+ return '
' + val + ' | ';
+ };
+ var thead = '
| 指标 | 年度累计 | 上季度累计 | 上月累计 | 本季度累计 | 季环比 | 本月累计 | 月环比 |
|---|
';
+ var tbody = '';
+ for (var ri = 0; ri < 8; ri++) {
+ var vals = ROWS[ri];
+ tbody += '
| ' + LABELS[ri] + ' | ' +
+ tdVal(ri, 0, vals[0][1]) + tdVal(ri, 3, vals[3][1]) + tdVal(ri, 4, vals[4][1]) + tdVal(ri, 1, vals[1][1]) +
+ '' + qoq(Q_FIELDS[ri], Q_PREV[ri]) + ' | ' +
+ tdVal(ri, 2, vals[2][1]) +
+ '' + qoq(M_FIELDS[ri], M_PREV[ri]) + ' |
';
+ }
+
+ document.querySelector("#planOverview").innerHTML = '
' +
+ card('
业务(项目)财务概览
合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流
' + thead + '' + tbody + '
', 'p-4') +
+ '
' +
+ card('
月度签约趋势
2026', 'p-4') +
+ card('
月度确收与毛利
2026', 'p-4') +
+ card('
月度回款与已付
2026', 'p-4') +
+ card('
月度项目利润
2026', 'p-4') +
+ '
';
+
+ if (window.lucide) window.lucide.createIcons();
+ // 渲染图表(复用 home.js 的 moneyTick / chartOptions / monthLabels)
+ if (financeMonthly && window.Chart) {
+ renderOverviewCharts(financeMonthly);
+ }
+}
+
+function renderPlanCharts(data) {
+ if (typeof monthLabels !== 'function') return;
+ const labels = monthLabels(data);
+ const baseOpts = typeof chartOptions === 'function' ? chartOptions(moneyTick) : { responsive: true, maintainAspectRatio: false };
+ var iconf = { type: "line", options: baseOpts, data: { labels: labels } };
+
+ var c1 = document.querySelector("#finChartSign");
+ if (c1 && window.Chart) {
+ if (state.planChart1) state.planChart1.destroy();
+ state.planChart1 = new Chart(c1, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "签约金额", data: data.map(function(x) { return x.sign || 0; }), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }] } }));
+ }
+ var c2 = document.querySelector("#finChartRev");
+ if (c2 && window.Chart) {
+ if (state.planChart2) state.planChart2.destroy();
+ state.planChart2 = new Chart(c2, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "确收", data: data.map(function(x) { return x.revenue || 0; }), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, { label: "毛利", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }] } }));
+ }
+ var c3 = document.querySelector("#finChartCash");
+ if (c3 && window.Chart) {
+ if (state.planChart3) state.planChart3.destroy();
+ state.planChart3 = new Chart(c3, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "回款", data: data.map(function(x) { return x.payment || 0; }), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, { label: "已付", data: data.map(function(x) { return x.cost || 0; }), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }] } }));
+ }
+ var c4 = document.querySelector("#finChartProfit");
+ if (c4 && window.Chart) {
+ if (state.planChart4) state.planChart4.destroy();
+ state.planChart4 = new Chart(c4, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "利润", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }] } }));
+ }
+};
diff --git a/static/modules/plan_expense.js b/static/modules/plan_expense.js
new file mode 100644
index 0000000..f46a2f4
--- /dev/null
+++ b/static/modules/plan_expense.js
@@ -0,0 +1,219 @@
+// expense.js — 费用管理模块
+
+function renderPlanExpense() {
+ var records = state.data.planExpense || [];
+ 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, '"'); };
+ var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
+ var STATUS_OPTIONS = ['计入费用','暂不计入'];
+
+ // 排序状态
+ if (!state.planExpSort) state.planExpSort = { key: 'expense_month', asc: true };
+ var sortKey = state.planExpSort.key;
+ var sortAsc = state.planExpSort.asc ? 1 : -1;
+
+ // 筛选状态
+ var view = state.planExpView || 'total';
+ var typeFilter = state.planExpFilter || '全部';
+ var filtered = typeFilter === '全部' ? records : records.filter(function(r) { return r.expense_type === typeFilter; });
+
+ // 视图数据预处理
+ var now = new Date();
+ var thisYear = now.getFullYear();
+ var defaultMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
+
+ if (!state.planExpMonth) state.planExpMonth = defaultMonth;
+ if (state.planExpQuarter === '') state.planExpQuarter = String(Math.floor(now.getMonth() / 3));
+
+ // 月度筛选
+ if (view === 'monthly') {
+ filtered = filtered.filter(function(r) { return r.expense_month === state.planExpMonth; });
+ }
+
+ // 季度筛选
+ if (view === 'quarterly') {
+ var q = parseInt(state.planExpQuarter) || 0;
+ var qRanges = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
+ var qRange = qRanges[q];
+ filtered = filtered.filter(function(r) {
+ var m = parseInt((r.expense_month || '0').substring(5)) || 0;
+ return qRange.indexOf(m) >= 0;
+ });
+ }
+
+ // 排序
+ filtered.sort(function(a, b) {
+ var va = a[sortKey] || '', vb = b[sortKey] || '';
+ if (sortKey === 'amount' || sortKey === 'incurred_amount') {
+ va = parseFloat(va) || 0; vb = parseFloat(vb) || 0;
+ }
+ if (va < vb) return -1 * sortAsc;
+ if (va > vb) return 1 * sortAsc;
+ return 0;
+ });
+
+ var totalAmount = filtered.reduce(function(s, r) { return s + (r.amount || 0); }, 0);
+ var totalIncurred = filtered.reduce(function(s, r) { return s + (r.incurred_amount || 0); }, 0);
+
+ // 月份下拉选项(去年/今年/明年)
+ var monthOpts = '';
+ for (var yr = thisYear - 1; yr <= thisYear + 1; yr++)
+ for (var m = 1; m <= 12; m++) {
+ var mv = yr + '-' + String(m).padStart(2, '0');
+ monthOpts += '
';
+ }
+
+ // 季度下拉
+ var qLabels = ['Q1 (1-3月)','Q2 (4-6月)','Q3 (7-9月)','Q4 (10-12月)'];
+ var quarterOpts = '';
+ for (var qi = 0; qi < 4; qi++) {
+ quarterOpts += '
';
+ }
+
+ // 月份/季度日期选择器
+ var dateSelect = '';
+ if (view === 'monthly') {
+ dateSelect = '
月份:';
+ } else if (view === 'quarterly') {
+ dateSelect = '
季度:';
+ }
+
+ // Toolbar
+ var toolbar = '
视图:类型:' + dateSelect;
+
+ // 排序箭头
+ var sortArrow = function(key) {
+ var arrow = key === sortKey ? (sortAsc === 1 ? ' ↑' : ' ↓') : '';
+ return '
' + arrow + '';
+ };
+
+ // 表格行
+ var tableRows = '';
+ if (filtered.length) {
+ filtered.forEach(function(r) {
+ var statusCls = r.status === '暂不计入' ? 'text-amber-600' : 'text-green-600';
+ tableRows += '
| ' + (r.expense_month || '—') + ' | ' + esc(r.expense_type) + ' | ' + esc(r.status || '计入费用') + ' | ' + money(r.amount) + ' | ' + money(r.incurred_amount) + ' | ' + (esc(r.notes) || '—') + ' |
';
+ });
+ } else {
+ var emptyMsg = view === 'monthly' ? '该月份暂无费用记录' : view === 'quarterly' ? '该季度暂无费用记录' : '暂无费用记录';
+ tableRows = '
| ' + emptyMsg + ' |
';
+ }
+
+ // 模态框 monthOpts for form (with current value preselected)
+ var modalMonthOpts = '
';
+ for (var yr2 = thisYear - 1; yr2 <= thisYear + 1; yr2++)
+ for (var m2 = 1; m2 <= 12; m2++) {
+ var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
+ modalMonthOpts += '
';
+ }
+ var statusOpts = STATUS_OPTIONS.map(function(s) { return '
'; }).join('');
+
+ var modal = '
';
+
+ var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
+ if (!target) return;
+ target.innerHTML = '
' +
+ '
' +
+ '
' +
+ '
费用记录' + filtered.length + '
' +
+ '
总费用' + money(totalAmount) + '
' +
+ '
已发生金额' + money(totalIncurred) + '
' +
+ '
' +
+ modal +
+ '
| 月份' + sortArrow('expense_month') + ' | 费用类型' + sortArrow('expense_type') + ' | 状态' + sortArrow('status') + ' | 金额' + sortArrow('amount') + ' | 已发生金额' + sortArrow('incurred_amount') + ' | 费用说明 |
' + tableRows + '
' +
+ '
';
+
+ if (window.lucide) window.lucide.createIcons();
+}
+
+window.togglePlanExpSort = function(key) {
+ var cur = state.planExpSort || {};
+ if (cur.key === key) { cur.asc = !cur.asc; }
+ else { cur.key = key; cur.asc = true; }
+ state.planExpSort = cur;
+ renderPlanExpense();
+};
+
+window.setPlanExpView = function(v) { state.planExpView = v; renderPlanExpense(); };
+window.setPlanExpFilter = function(v) { state.planExpFilter = v; renderPlanExpense(); };
+window.setPlanExpMonth = function(v) { state.planExpMonth = v; renderPlanExpense(); };
+window.setPlanExpQuarter = function(v) { state.planExpQuarter = v; renderPlanExpense(); };
+
+window.openPlanExpModal = function() {
+ var form = document.querySelector("#planExpenseModal form");
+ form.reset();
+ form.querySelector('[name="expense_id"]').value = "";
+ form.querySelector('[name="status"]').value = "计入费用";
+ document.querySelector("#planExpenseModalTitle").textContent = "新增费用";
+ document.querySelector("#planExpDeleteBtn").classList.add("hidden");
+ document.querySelector("#planExpenseModal").classList.remove("hidden");
+};
+
+window.closePlanExpModal = function() {
+ document.querySelector("#planExpenseModal").classList.add("hidden");
+};
+
+window.openPlanExpEdit = function(id) {
+ var r = (state.data.planExpense || []).find(function(x) { return x.id === id; });
+ if (!r) return;
+ var form = document.querySelector("#planExpenseModal form");
+ form.reset();
+ var setVal = function(name, val) { var el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val || ""; };
+ setVal("expense_id", r.id);
+ setVal("expense_type", r.expense_type);
+ setVal("expense_month", r.expense_month);
+ setVal("amount", r.amount);
+ setVal("incurred_amount", r.incurred_amount);
+ setVal("notes", r.notes);
+ setVal("status", r.status || "计入费用");
+ document.querySelector("#planExpenseModalTitle").textContent = "编辑费用";
+ document.querySelector("#planExpDeleteBtn").classList.remove("hidden");
+ document.querySelector("#planExpenseModal").classList.remove("hidden");
+};
+
+window.savePlanExpense = async function(event) {
+ event.preventDefault();
+ var form = event.target;
+ var data = Object.fromEntries(new FormData(form).entries());
+ if (data.amount === '' || data.amount === undefined) data.amount = 0;
+ if (data.incurred_amount === '' || data.incurred_amount === undefined) data.incurred_amount = 0;
+ if (!data.status) data.status = '计入费用';
+ var isEdit = !!data.expense_id;
+ delete data.expense_id;
+
+ var id = form.querySelector('[name="expense_id"]').value;
+ var url = isEdit ? "/api/planExpense/" + id : "/api/planExpense";
+ var method = isEdit ? "PUT" : "POST";
+
+ 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; }
+
+ var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
+ if (bResp.ok) {
+ var bd = await bResp.json();
+ state.data = bd;
+ applyUserTenants();
+ renderPlanExpense();
+ }
+ closePlanExpModal();
+};
+
+window.deletePlanExpItem = async function() {
+ var id = document.querySelector("#planExpenseModal form [name='expense_id']").value;
+ if (!id || !confirm("确定删除?")) return;
+ var resp = await fetch("/api/planExpense/" + id, { method: "DELETE" });
+ if (!resp.ok) { alert("删除失败"); return; }
+ var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
+ if (bResp.ok) {
+ var bd = await bResp.json();
+ state.data = bd;
+ applyUserTenants();
+ renderPlanExpense();
+ }
+};
diff --git a/static/modules/utils.js b/static/modules/utils.js
index c6d511a..4f9d9d7 100644
--- a/static/modules/utils.js
+++ b/static/modules/utils.js
@@ -10,6 +10,10 @@ const state = {
taskQuery: "",
taskView: localStorage.getItem("opc-task-view") || "detail",
finView: "overview",
+ finSort: null, // { key: 'col', asc: true }
+ planFilter: "已签约",
+ planView: "overview",
+ planSort: null,
proposalTab: "standard",
chart: null,
chart2: null,
@@ -132,7 +136,7 @@ function switchTab(tab) {
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
// 更新顶部标题
- const titles = { home: 'OPC 工作台', finance: '业务管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核' };
+ const titles = { home: 'OPC 工作台', finance: '业务管理', plan: '计划管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核' };
const titleEl = document.querySelector('#workspaceTitle');
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
@@ -159,6 +163,7 @@ function render() {
renderProducts();
renderPerformance();
renderFinance();
+ renderPlan();
if (window.lucide) window.lucide.createIcons();
}
@@ -171,6 +176,7 @@ function renderActive() {
else if (tab === "products") renderProducts();
else if (tab === "performance") renderPerformance();
else if (tab === "finance") renderFinance();
+ else if (tab === "plan") renderPlan();
if (window.lucide) window.lucide.createIcons();
}
@@ -200,8 +206,31 @@ window.setFinView = (view) => {
};
window.switchFinFilter = (filter) => {
state.finFilter = filter;
+ state.finSort = null;
renderFinance();
};
+
+window.setFinSort = (key) => {
+ if (!state.finSort) state.finSort = { key: null, asc: true };
+ if (state.finSort.key === key) { state.finSort.asc = !state.finSort.asc; }
+ else { state.finSort.key = key; state.finSort.asc = true; }
+ renderFinance();
+};
+window.setPlanView = (view) => {
+ state.planView = view;
+ renderPlan();
+};
+window.switchPlanFilter = (filter) => {
+ state.planFilter = filter;
+ state.planSort = null;
+ renderPlan();
+};
+window.setPlanSort = (key) => {
+ if (!state.planSort) state.planSort = { key: null, asc: true };
+ if (state.planSort.key === key) { state.planSort.asc = !state.planSort.asc; }
+ else { state.planSort.key = key; state.planSort.asc = true; }
+ renderPlan();
+};
window.switchTenant = (tenant) => {
state.tenant = tenant;
state.selectedProject = null;
diff --git a/templates/index.html b/templates/index.html
index a1889f6..00770d4 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -49,6 +49,10 @@
部门
+