From d2ea5d0d5d7afbe0870bd20b9d2c13f568a3de46 Mon Sep 17 00:00:00 2001 From: mac Date: Wed, 8 Jul 2026 12:13:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=A1=E5=88=92=E6=A8=A1=E5=9D=97=20?= =?UTF-8?q?+=20=E5=BE=85=E7=AD=BE=E7=BA=A6=E8=BF=81=E7=A7=BB=20+=20?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E6=8E=92=E5=BA=8F=20+=20=E5=B7=B2=E7=AD=BE?= =?UTF-8?q?=E7=BA=A6=E2=86=92=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增计划模块(plan_finances/plan_expense_records, 侧边栏计划tab) - 待签约项目自动迁移到计划模块(data_fixes 幂等) - 已签约tab改名为项目(业务+计划两个模块) - 业务模块表格点击列头排序(月度/季度/总视图) - 计划与业务侧边栏位置对调 - plan模块CSS修复(使用finance-tab类) - 版本号 v1.0.0.17 --- backend/migrations/__init__.py | 3 +- backend/migrations/data_fixes.py | 28 + backend/migrations/tables.py | 2 + backend/routes.py | 8 +- static/modules/finance.js | 133 ++-- static/modules/plan.js | 1053 ++++++++++++++++++++++++++++++ static/modules/plan_expense.js | 219 +++++++ static/modules/utils.js | 31 +- templates/index.html | 9 +- 9 files changed, 1442 insertions(+), 44 deletions(-) create mode 100644 static/modules/plan.js create mode 100644 static/modules/plan_expense.js 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 = `
`; + const finFilterTabs = `
`; document.querySelector("#finance").innerHTML = `
${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}

${thead}${tbody}
`, "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 = '
' + + '
' + toolbar + '
' + + '
' + + '
费用记录' + filtered.length + '
' + + '
总费用' + money(totalAmount) + '
' + + '
已发生金额' + money(totalIncurred) + '
' + + '
' + + modal + + '
' + tableRows + '
月份' + sortArrow('expense_month') + '费用类型' + sortArrow('expense_type') + '状态' + sortArrow('status') + '金额' + sortArrow('amount') + '已发生金额' + sortArrow('incurred_amount') + '费用说明
' + + '
'; + + 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 @@ 部门
+