Compare commits

...

42 Commits

Author SHA1 Message Date
mac
774cdec9e3 feat: 预算模块成本→当期成本,付款→当期流出,去除付款率
- 成本列/卡片改名为当期成本
- 付款列/卡片改名为当期流出
- 去除付款率列和卡片
- colspan和theadCols同步调整
- 展开明细表头同步改名
- 版本号 v1.2.0.20
2026-07-10 23:47:54 +08:00
mac
19fa00e220 feat: 季度/月度标签再次点击取消选中
- setPlanQuarter/setPlanMonth增加toggle逻辑
- setFinQuarter/setFinMonth增加toggle逻辑
- 再次点击已选中的季度/月份回到overview总视图
- 版本号 v1.2.0.19
2026-07-10 23:22:12 +08:00
mac
06ba9b2bff feat: 刷新恢复工作台+状态标签调整
- state.tenant从localStorage恢复(只写不读bug修复)
- 状态标签移到总视图后面,增加全部按钮
- 去除总视图按钮
- 版本号 v1.2.0.18
2026-07-10 23:02:48 +08:00
mac
f4b69449b2 feat: 预算筛选区增加状态标签(待签约/已签约/只付款)
- 月份后增加待签约|已签约|只付款3个状态筛选按钮
- 待签约: project_type为待签约
- 已签约: project_type为已签约
- 只付款: 只有成本/付款,确收/毛利/回款为空
- 三种视图(月度/季度/总视图)都支持过滤
- 版本号 v1.2.0.17
2026-07-10 22:32:27 +08:00
mac
7bdde246bc feat: 平台费用Tab切换+汇总筛选对换+卡片样式统一
- 平台费用类型改为Tab按钮切换(人力成本|研发费用|奖金|平台采购|通用费用|其他)
- 平台费用计划改名为平台费用
- 项目管理和平台费用: 汇总区在上筛选区在下
- 平台费用卡片样式对齐项目管理(紧凑px-3 py-1.5)
- 视图下拉框放在Tab按钮左边
- 版本号 v1.2.0.16
2026-07-10 21:43:19 +08:00
mac
ea1e477c31 fix: 已发生模块平台费用保存/删除后列表变空
- 根因: saveExpense/deleteExpenseItem调用bootstrap覆盖state.data
- 轻量bootstrap不返回expense数据导致列表为空
- 改为只调/api/expense/list刷新费用列表
- 版本号 v1.2.0.15
2026-07-10 21:19:53 +08:00
mac
dcc59a9bc6 feat: 待回款卡片负数显示超额回款
- 待回款<0时显示'超额回款 xx万'
- 版本号 v1.2.0.14
2026-07-10 20:14:53 +08:00
mac
ead6f2ac46 feat: 增加待回款列和卡片+取消付款率
- 项目财务明细表格增加待回款列(确收-回款, 现金流后)
- 汇总卡片增加待回款(毛利前), 取消付款率
- 展开明细增加待回款列
- 版本号 v1.2.0.13
2026-07-10 20:06:03 +08:00
mac
9471ee3e85 feat: 新增测试工作台+tip样式调整+tip/tab位置对调
- 新增测试·无界工作台(所有人可见)
- 为所有opc_owner用户添加测试工作台权限
- 预算模块tip改淡黄色+新文案
- 预算/已发生模块tip和tab位置对调(tip在上)
- 版本号 v1.2.0.12
2026-07-10 19:52:00 +08:00
mac
d4fa828f8c feat: 行高调整+去元单位+客户名截断+select高度修复
- 项目财务明细行高增至1.37(原0.95的1.2x1.2倍)
- 去掉所有表格内容中的元单位
- 客户名称固定100px+省略号+hover显示完整
- planInlineSelect的select高度固定20px对齐文本
- switchFinanceTab选择器限定#financeModal
- 版本号 v1.2.0.11
2026-07-10 19:32:00 +08:00
mac
ceb54e29ec fix: 展开明细合并月份+tab选中态+默认排序+已发生改名
- 展开明细合并budget_data和expense_data月份(修复只有费用没预算时看不到)
- switchFinanceTab选择器限定#financeModal(修复弹窗tab误伤顶部筛选tab)
- 项目管理表格默认按确收金额从高到低排序
- 执行→已发生, tip改淡黄色
- 登录后默认选权限范围内第一个工作台
- 版本号 v1.2.0.10
2026-07-10 19:11:32 +08:00
mac
9ca5b11e20 feat: 执行→已发生+默认工作台+tip改色
- 执行模块改名为已发生
- tip改为淡黄色+新文案(只记录已发生的确收/毛利/回款/成本/支出)
- 登录后默认选择权限范围内第一个工作台
- applyUserTenants返回Promise,app.js先await再load
- 去除总览工作台残留
- 版本号 v1.2.0.9
2026-07-10 18:22:54 +08:00
mac
07fa3e6e10 feat: 去除总览工作台
- 后端: ALL_TENANTS去掉总览, bootstrap删除聚合逻辑
- 前端: 删除renderHome加载步骤, 隐藏部门tab
- switchTenant删除总览特殊处理, 默认进预算模块
- 租户排序和图标映射去掉总览
- isOverviewMode强制false
- 版本号 v1.2.0.8
2026-07-10 17:33:05 +08:00
mac
7bf30a7cc2 feat: 执行模块项目管理增加行展开明细
- 项目财务明细表格最左侧增加展开箭头
- 点击展开月度确收回款明细(同预算模块)
- 自动过滤全0月份
- 版本号 v1.2.0.7
2026-07-10 13:38:11 +08:00
mac
44f313050e fix: 切换工作台后数据不刷新
- 根因: ensureStandardProposals每次触发return跳过ensureTabData
- 修复: ensureTabData移到ensureStandardProposals之前
- 增加_proposalsInitialized一次性flag避免重复检查
- switchTenant改为async+await load()
- 版本号 v1.2.0.6
2026-07-10 13:27:22 +08:00
mac
fad6a0e2fb fix: 切换工作台后数据不刷新
- load()渲染前增加ensureTabData(state.active)加载当前tab数据
- 版本号 v1.2.0.5
2026-07-10 13:12:42 +08:00
mac
19d1aceb9f feat: 按需加载+年份筛选+行展开明细+UI优化
后端:
- bootstrap精简为轻量版(只返回summary+financeMonthly) 0.028s
- 按需加载: 切tab时通过/api/<resource>/list获取数据
- 修复risk_projects语法错误

前端:
- 项目管理tab改名(项目计划/项目→项目管理)
- 筛选区增加年份下拉框(2024-2027)
- 季度筛选增加年份过滤
- 项目行增加展开箭头,显示月度确收回款明细
- 自动过滤全0月份记录
- 平台费用: 金额→费用计提, 已发生金额→现金支出
- 计划→预算, 实际→执行
- 经营月报/季报卡片顺序调整
- 非总览工作台跳过renderHome
- 版本号 v1.2.0.4
2026-07-10 13:10:04 +08:00
mac
153835b184 fix: 非总览工作台强制跳过renderHome
- skip条件从state.active判断改为仅判断state.tenant!==总览
- 修复state.active默认为home导致renderHome仍被调用
- 版本号 v1.2.0.3
2026-07-09 18:18:03 +08:00
mac
2f759b42cd fix: 预算模块tab选中态+平台费用字段改名
- switchPlanTab选择器修复(.plan-tab→#planModal .finance-tab)
- 平台费用计划: 金额→费用计提, 已发生金额→现金支出
- 版本号 v1.2.0.2
2026-07-09 17:41:01 +08:00
mac
b076cabb83 feat: renderHome跳过+卡片排序+模块改名
- 非总览工作台跳过renderHome(省Chart.js渲染)
- 计划→预算, 实际→执行
- 经营月报+季报卡片顺序统一: 业务线经营→业务线现金流→财务概览
- 版本号 v1.0.0.40
2026-07-09 16:52:35 +08:00
mac
c6c282537c feat: 加载浮层+工作台改回下拉菜单
- load()增加模态加载浮层: 7步进度展示+进度条+逐步渲染
- 工作台切换从竖向平铺改回下拉菜单(带图标)
- 版本号 v1.0.0.39
2026-07-09 16:33:49 +08:00
mac
c26cbb3cf4 perf: 全项目批量优化-16处load()改为轻量刷新
- plan.js: createPlanFinance/deletePlanItem/planInlineUpdate
- finance.js: createFinance/deleteFinanceItem
- drawer.js: deleteDrawerItem/submitComment/deleteActivity
- products.js: createResource/submitProductDrawer
- projects.js: duplicateProject/submitTaskForm
- proposals.js: ensureStandardProposals/submitStandardComment/submitProposal
- plan_expense.js: savePlanExpense/deletePlanExpItem(上版本已修)
- 所有保存/删除/编辑后改为/api/<resource>/list+单模块render
- 仅保留app.js初始加载和switchTenant切换工作台的全量load()
- 版本号 v1.0.0.38
2026-07-09 16:20:21 +08:00
mac
296917456c perf: 计划费用保存/删除性能优化
- 后端新增/api/<resource>/list轻量级列表接口
- plan_expense.js保存/删除后改为轻量刷新,不再调load()
- 避免全量bootstrap+7模块渲染,只刷新费用列表
- 版本号 v1.0.0.37
2026-07-09 16:03:30 +08:00
mac
236d43052e feat: 总览增加计划/实际模块+工作台改版+季报完善
- 总览工作台: 显示部门/计划/实际3个tab
- 计划/实际模块在总览下: 只显示经营月报+经营季报tab, 内容留空
- 季度月报改名为经营季报
- 工作台改版: 学会→学术, 总工作台→总览, 竖向平铺+独立图标
- 5个工作台去除部门模块
- 工作台顺序: 总览→学术→MCN→科普→医患→科研
- session旧名称清理(总工作台→总览, 学会→学术)
- 版本号 v1.0.0.36
2026-07-08 20:48:52 +08:00
mac
c0fb382d78 feat: 大版本更新-工作台改版+总报完善+UI优化
工作台改版:
- 学会→学术, 总工作台→总览(数据库迁移+session清理)
- 侧边栏工作台从下拉菜单改为竖向平铺+独立图标
- 5个工作台去除部门模块, 只有总览显示
- 工作台顺序: 总览→学术→MCN→科普→医患→科研

经营月报+季度月报:
- 月度/季度总览改名(月度总览→经营月报, 季度总览→季度月报)
- 财务概览增加回款/项目现金流行(7行)
- 新增业务线经营情况卡片(毛利/平台费用/利润)
- 新增业务线现金流卡片(项目现金流/平台费用已付/业务线现金流)
- 季度月报完善为三卡片结构, 与经营月报一致
- 计划模块总览独立数据源(computePlanMonthly)

UI优化:
- 汇总卡片单行显示+5x2网格+去外层卡片
- 项目表格行高压缩(py-px+line-height:0.95)
- 表头不压缩, 只压缩tbody
- 加权/项目类型内联下拉去原生箭头+自定义箭头
- 加权/项目类型表头去除排序箭头
- 实际模块已付→支出, 已回款→回款, 成本→成本(不含平台费用)
- 现金流注释(回款-支出)
- 卡片间距/筛选区/tip高度压缩
- 实际模块增加执行提示tip

修复:
- routes.py planFinances client_name列去重
- plan_expense.js savePlanExpense改用api()+load()
- plan.js createPlanFinance拆分try-catch
- 版本号 v1.0.0.35
2026-07-08 20:39:44 +08:00
mac
e1b549b3f9 feat: 汇总卡片精简+表格列调整+总览改名+颜色规则
- 计划+实际模块: 汇总卡片移除项目利润和执行率
- 计划+实际模块: 表格现金流移到最后一列
- 计划+实际模块: 月度总览改名为经营月报, 季度总览改名为季度月报
- 实际模块: 已付全部改名为支出
- 月度总览: 毛利统一绿色, 利润/现金流条件着色
- 版本号 v1.0.0.34
2026-07-08 18:55:27 +08:00
mac
1e82ca73d9 fix: 计划费用保存卡顿+月度总览增加回款现金流行
- plan_expense.js: savePlanExpense/deletePlanExpItem改用api()+load()
- 去掉多余的applyUserTenants和手动fetch bootstrap
- plan.js月度总览增加回款和项目现金流两行
- 版本号 v1.0.0.33
2026-07-08 17:45:48 +08:00
mac
cfd5137edc fix: 修复计划模块保存失败(client_name列重复)
- routes.py: planFinances列列表去掉重复的client_name
- plan.js: 拆分try-catch区分保存错误和刷新错误
- 版本号 v1.0.0.32
2026-07-08 17:04:47 +08:00
mac
0a0caa959c feat: 月度总览增加业务线经营+现金流卡片+表头调整
- 计划+实际模块月度总览: 新增业务线经营情况卡片(毛利/平台费用/利润)
- 计划+实际模块月度总览: 新增业务线现金流卡片(项目现金流/平台费用已付/业务线现金流)
- 实际模块月度总览: 修复deptCard/cfCard未输出到innerHTML
- 计划模块项目表头: 列顺序调整(14列), 已确收→确收, 已付→付款
- 实际模块项目表头: 列顺序调整(12列)
- 版本号 v1.0.0.31
2026-07-08 16:49:11 +08:00
mac
b313c1b6a2 feat: 总览精简+实际模块表头调整+删除利润图表
- 月度/季度总览只显示5行: 合同金额/确收/毛利/成本/已付
- 实际模块项目表头调整为12列新顺序
- 删除月度项目利润图表, 剩余3图改3列布局
- 版本号 v1.0.0.30
2026-07-08 16:29:30 +08:00
mac
13e63b98e3 fix: 实际模块删除项目利润列
- SIGNED_COLS/UNSIGNED_COLS移除profit列
- tdRow/tdRowUnsigned移除pfCls/pfVal变量和单元格
- theadCols计数修正(13/6)
- 版本号 v1.0.0.29
2026-07-08 16:14:51 +08:00
mac
8952829fe5 fix: 计划总览独立数据源+实际模块表头修复
- 计划月度/季度总览: 改用planFinances计算,不再引用实际模块数据
- 新增computePlanMonthly函数从planFinances计算月度数据
- 实际模块SIGNED_COLS/UNSIGNED_COLS补全client_name列
- theadCols计数修正(14/7)
- 版本号 v1.0.0.28
2026-07-08 16:05:45 +08:00
mac
41f4ac1b1b feat: 计划+实际模块增加月度总览和季度总览
- 计划模块: 总览改名为月度总览, 新增季度总览tab(含环比)
- 实际模块: 总览改名为月度总览, 新增季度总览tab(含环比)
- 月度总览: 年度累计+1-12月14列
- 季度总览: 年度累计+Q1-Q4+Q2/Q3/Q4环比
- 修复finance.js函数嵌套导致季度总览不渲染
- 版本号 v1.0.0.27
2026-07-08 15:52:45 +08:00
mac
4edd0b0473 feat: 实际模块加客户名称+计划总览改月度表格
- project_finances新增client_name列(实际模块)
- 实际模块表单/表格增加客户名称列(项目名称前)
- 计划总览财务概览: 改为年度累计+1-12月14列
- 表格内容去掉元后缀
- 版本号 v1.0.0.26
2026-07-08 15:11:26 +08:00
mac
a31d10f23f feat: 计划模块新增客户/项目类型/加权字段+内联编辑
- plan_finances新增client_name/project_type/weight三列
- 表格新增客户/加权(内联下拉)/项目类型(内联下拉)三列
- 删除毛利率/付款率列
- 表头列定义修复(清理status残留空行)
- planInlineUpdate支持表格内直接修改
- 版本号 v1.0.0.25
2026-07-08 14:50:14 +08:00
mac
347c8aa818 feat: 筛选栏改标签式+计划总览图表修复+tip位置调整
- 计划/实际模块筛选栏: 下拉框改为标签按钮(总视图|按季度|按月度)
- utils.js新增setFinQuarter/setFinMonth/setPlanQuarter/setPlanMonth
- 计划总览图表: 修复canvas ID和渲染函数调用
- 计划模块tab和tip位置对换(tab在上,tip在下)
- 版本号 v1.0.0.24
2026-07-08 14:16:31 +08:00
mac
e21d41b218 refactor: 彻底删除实际模块status字段
- 数据库: DROP project_finances.status 列(幂等迁移)
- 后端: routes.py移除status过滤(12处), helpers.py修复SQL
- 后端: 删除migrate_move_pending_to_plan, seed_data移除status
- 前端: finance.js/performance.js/utils.js全部清除status引用
- finFilter默认值改为projects
- 版本号 v1.0.0.23
2026-07-08 13:46:10 +08:00
mac
a57cba8043 refactor: 彻底删除计划模块status字段+新增蓝色tip
- 数据库: DROP plan_finances.status 列(幂等迁移)
- 后端: routes.py TABLES移除status, migrate用显式列名
- 前端: plan.js移除表单/表格/过滤/汇总所有status引用
- planFilter值从待签约改为projects
- 计划页面顶部新增蓝色tip提醒条
- 版本号 v1.0.0.22
2026-07-08 13:21:54 +08:00
mac
fdb16d053a fix: 移除closePlanModal的render()调用 2026-07-08 13:04:51 +08:00
mac
61ba9c4c5d fix: 计划模块弹窗完整修复
- 弹窗HTML从finance.js干净复制, 结构完全对齐
- 25个modal ID加plan_前缀, 与实际模块零冲突
- 16个window函数+4个全局函数加plan前缀, 避免覆盖
- closePlanModal关闭后调用render()清理DOM
- CSS 5条规则加上#planModal选择器
- isUnsigned=false, 表格14列+卡片14个
- 版本号 v1.0.0.20
2026-07-08 13:00:16 +08:00
mac
eba4945fba refactor: 业务→实际 2026-07-08 12:23:32 +08:00
mac
2445dea4dd refactor: 业务去待签约, 计划tab调整
- 业务模块移除待签约tab, 状态只保留已签约
- 计划模块移除项目tab, 待签约→项目计划, 平台费用→平台费用计划
- 计划默认选中项目计划tab
- 版本号 v1.0.0.18
2026-07-08 12:22:02 +08:00
21 changed files with 1429 additions and 763 deletions

View File

@@ -65,7 +65,7 @@ def monthly_finance(conn, tenant="科普·无界"):
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
pfs = rows(conn,
"SELECT sign_amount, sign_month, status, budget_data, expense_data FROM project_finances WHERE tenant=? AND status='已签约'",
"SELECT sign_amount, sign_month, budget_data, expense_data FROM project_finances WHERE tenant=?",
[tenant])
parsed_budgets = []
@@ -104,7 +104,7 @@ def monthly_finance(conn, tenant="科普·无界"):
key = month.replace("-", "_")
revenue = gross = payment = cost = paid = sign = 0
for pf, budget_map in parsed_budgets:
if pf["status"] == "已签约" and (pf.get("sign_month") or "") == month:
if (pf.get("sign_month") or "") == month:
sign += float(pf["sign_amount"] or 0)
b = budget_map.get(key)
if b:

View File

@@ -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, migrate_move_pending_to_plan
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status, migrate_rename_xuehui_to_xueshu, migrate_rename_zong_to_overview, migrate_add_test_tenant
from migrations.data_split import migrate_data_split
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
@@ -28,7 +28,9 @@ def run_migrations():
migrate_drop_product_fields()
migrate_fix_service_fee_standard()
migrate_fix_expense_status()
migrate_move_pending_to_plan()
migrate_rename_xuehui_to_xueshu()
migrate_rename_zong_to_overview()
migrate_add_test_tenant()
migrate_data_split()
migrate_seed_users()
migrate_seed_demo_data()

View File

@@ -114,5 +114,30 @@ def migrate_add_columns():
conn.commit()
print("[migrate] 加列迁移完成")
# plan_finances 新增字段
_add_column_if_missing(conn, "plan_finances", "client_name",
"ALTER TABLE plan_finances ADD COLUMN client_name VARCHAR(200) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "plan_finances", "project_type",
"ALTER TABLE plan_finances ADD COLUMN project_type VARCHAR(50) NOT NULL DEFAULT '待签约'")
_add_column_if_missing(conn, "plan_finances", "weight",
"ALTER TABLE plan_finances ADD COLUMN weight VARCHAR(10) NOT NULL DEFAULT '20%'")
# 删除 plan_finances.status 列(计划模块不再需要状态字段)
cur = conn.cursor()
cur.execute("SHOW COLUMNS FROM plan_finances LIKE 'status'")
if cur.fetchone():
cur.execute("ALTER TABLE plan_finances DROP COLUMN status")
conn.commit()
print("[migrate] plan_finances.status 列已删除")
# project_finances 新增客户名称字段
_add_column_if_missing(conn, "project_finances", "client_name",
"ALTER TABLE project_finances ADD COLUMN client_name VARCHAR(200) NOT NULL DEFAULT ''")
# 删除 project_finances.status 列(实际模块不再需要状态字段)
cur.execute("SHOW COLUMNS FROM project_finances LIKE 'status'")
if cur.fetchone():
cur.execute("ALTER TABLE project_finances DROP COLUMN status")
conn.commit()
print("[migrate] project_finances.status 列已删除")
cur.close()
finally:
conn.close()

View File

@@ -114,29 +114,69 @@ def migrate_fix_expense_status():
conn.close()
def migrate_move_pending_to_plan():
"""迁移业务待签约项目到计划模块(幂等)"""
from db import db
def migrate_rename_xuehui_to_xueshu():
"""工作台重命名:学会·无界 → 学术·无界"""
from db import db, _exec, mysql
conn = db()
try:
tables = ["project_finances", "plan_finances", "expense_records", "plan_expense_records",
"sales_leads", "business_proposals", "product_versions", "operation_projects",
"follow_up_records", "project_tasks", "finance_records", "users", "user_tenants"]
for table in tables:
try:
cur = _exec(conn, f"UPDATE {table} SET tenant='学术·无界' WHERE tenant='学会·无界'")
affected = cur.rowcount
cur.close()
if affected:
conn.commit()
print(f"[migrate] {table}: {affected} 条记录 tenant 改为 '学术·无界'")
except mysql.connector.Error:
pass
finally:
conn.close()
def migrate_rename_zong_to_overview():
"""工作台重命名:总工作台 → 总览"""
from db import db, _exec, mysql
conn = db()
try:
tables = ["project_finances", "plan_finances", "expense_records", "plan_expense_records",
"sales_leads", "business_proposals", "product_versions", "operation_projects",
"follow_up_records", "project_tasks", "finance_records", "users", "user_tenants"]
for table in tables:
try:
cur = _exec(conn, f"UPDATE {table} SET tenant='总览' WHERE tenant='总工作台'")
affected = cur.rowcount
cur.close()
if affected:
conn.commit()
print(f"[migrate] {table}: {affected} 条记录 tenant 改为 '总览'")
except mysql.connector.Error:
pass
finally:
conn.close()
def migrate_add_test_tenant():
"""为所有 opc_owner 用户添加 测试·无界 工作台权限"""
from db import db, mysql
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
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, username FROM users WHERE role = 'opc_owner'")
users = cur.fetchall()
added = 0
for u in users:
cur.execute("SELECT id FROM user_tenants WHERE user_id = %s AND tenant = %s", (u["id"], "测试·无界"))
if not cur.fetchone():
cur.execute("INSERT INTO user_tenants (user_id, tenant) VALUES (%s, %s)", (u["id"], "测试·无界"))
added += 1
if added:
conn.commit()
print(f"[migrate] 待签约项目迁移到计划: {moved} 条 (已删除 {deleted} 条)")
print(f"[migrate] {added} 个 opc_owner 用户添加了 '测试·无界' 工作台权限")
cur.close()
finally:
conn.close()

View File

@@ -28,7 +28,7 @@ def migrate_seed_users():
# 绑定工作台
tenant_map = [
("kepu", "科普·无界"), ("keyan", "科研·无界"), ("yihuan", "医患·无界"),
("mcn", "MCN·无界"), ("wuji", "·无界"),
("mcn", "MCN·无界"), ("wuji", "·无界"),
]
for uname, tenant in tenant_map:
u = one(conn, "SELECT id FROM users WHERE username=?", (uname,))

View File

@@ -8,7 +8,7 @@ random.seed(42)
from datetime import date
from db import db, _exec, one
TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
# 每个工作台的差异化客户和项目模板
TENANT_CUSTOMERS = {
@@ -40,7 +40,7 @@ TENANT_CUSTOMERS = {
("B站知识区", ["深度科普纪录片", "医学动画制作"]),
("微信视频号", ["医生科普直播运营", "企业微信科普推送"]),
],
"·无界": [
"·无界": [
("中华医学会", ["学术年会数字化平台", "继续教育学分系统", "基层医生培训"]),
("中国医师协会", ["医师定考管理系统", "专科医师培训平台"]),
("中国抗癌协会", ["肿瘤科普万里行", "CACA指南推广"]),
@@ -72,7 +72,7 @@ def make_budget_data(sign_amount, signed_month, status):
"""生成12个月的预算数据"""
months = []
start_m = int(signed_month.split("-")[1]) if signed_month else 1
rev_total = sign_amount if status == "已签约" else int(sign_amount * random.uniform(0.3, 0.7))
rev_total = sign_amount
gross_rate = random.uniform(0.25, 0.55)
payment_rate = random.uniform(0.7, 0.95)
@@ -135,17 +135,17 @@ def seed_db():
cust_idx += i % 3
# 70% 已签约, 30% 待签约
status = "已签约" if random.random() < 0.7 else "待签约"
sign_month = f"2026-{random.randint(1, 6):02d}" if status == "已签约" else ""
status = "已签约"
sign_month = f"2026-{random.randint(1, 6):02d}"
project_code = f"{tenant[:2]}-2026-{i+1:03d}"
business_type = random.choice(["学术推广", "医生教育", "患者管理", "科研合作", "科普内容"])
# 如果签约,总额 = 签约额否则为0
total_rev = int(sign_amount * random.uniform(0.8, 1.0)) if status == "已签约" else 0
total_gross = int(total_rev * random.uniform(0.25, 0.5)) if status == "已签约" else 0
total_payment = int(total_rev * random.uniform(0.7, 0.9)) if status == "已签约" else 0
total_cost = int(total_rev * random.uniform(0.3, 0.5)) if status == "已签约" else 0
total_paid = int(total_cost * random.uniform(0.7, 0.95)) if status == "已签约" else 0
total_rev = int(sign_amount * random.uniform(0.8, 1.0))
total_gross = int(total_rev * random.uniform(0.25, 0.5))
total_payment = int(total_rev * random.uniform(0.7, 0.9))
total_cost = int(total_rev * random.uniform(0.3, 0.5))
total_paid = int(total_cost * random.uniform(0.7, 0.95))
budget_data = make_budget_data(sign_amount, sign_month, status)
expense_data = make_expense_data() if status == "已签约" else "[]"
@@ -157,16 +157,16 @@ def seed_db():
cur = _exec(conn, """
INSERT INTO project_finances
(tenant, project_id, business_type, customer_name, sign_amount, sign_month, status,
(tenant, project_id, business_type, customer_name, sign_amount, sign_month,
sales_person, total_rev, total_gross, total_payment, total_cost, total_paid,
budget_data, expense_data, task_data,
project_code, start_date, end_date, task_type, task_count,
service_fee_standard, project_manager, contact_name, contact_phone, other_info,
created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
tenant, f"PF-{tenant[:2]}-{i+1:04d}", business_type, cust_name,
sign_amount, sign_month, status,
sign_amount, sign_month,
f"销售{random.choice(['A','B','C','D'])}", total_rev, total_gross,
total_payment, total_cost, total_paid,
budget_data, expense_data, task_data,
@@ -178,7 +178,7 @@ def seed_db():
today, today,
))
# Store for later use
pf_ids.setdefault(tenant, []).append((cur.lastrowid, proj_name, cust_name, status))
pf_ids.setdefault(tenant, []).append((cur.lastrowid, proj_name, cust_name))
# operation_projects: 每个工作台 3-5 个
op_count = random.randint(3, 5)

View File

@@ -14,7 +14,7 @@ from helpers import add_file_index, attach_common, monthly_finance
bp = Blueprint("api", __name__)
ALL_TENANTS = ["总工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
ALL_TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "术·无界", "测试·无界"]
TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
@@ -23,9 +23,9 @@ TABLES = {
"products": ("product_versions", ["product_name", "version", "version_goal", "priority", "start_date", "plan_date", "dev_done_date", "test_date", "launch_date", "status", "notes", "tenant"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "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"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info"]),
"expense": ("expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
"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"]),
"planFinances": ("plan_finances", ["project_id", "tenant", "business_type", "customer_name", "client_name", "sign_amount", "sign_month", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "expense_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code", "contact_name", "contact_phone", "other_info", "project_type", "weight"]),
"planExpense": ("plan_expense_records", ["expense_type", "expense_month", "amount", "incurred_amount", "notes", "status", "tenant"]),
}
@@ -75,10 +75,13 @@ def auth_login():
session["display_name"] = user["display_name"]
session["role"] = user["role"]
if user["role"] == "admin":
session["tenants"] = ["总工作台", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "·无界"]
session["tenants"] = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "术·无界", "测试·无界"]
else:
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
session["tenants"] = [x["tenant"] for x in ut]
# 清理旧名称
session["tenants"] = [t if t != "总工作台" else "总览" for t in session["tenants"]]
session["tenants"] = [t if t != "学会·无界" else "学术·无界" for t in session["tenants"]]
return jsonify({
"ok": True,
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"]},
@@ -123,8 +126,9 @@ def auth_me():
if "user_id" not in session:
return jsonify({"logged_in": False})
tenants = session.get("tenants", [])
if "总工作台" not in tenants:
tenants = ["总工作台"] + tenants
tenants = [t if t != "总工作台" else "科普·无界" for t in tenants]
tenants = [t if t != "学会·无界" else "学术·无界" for t in tenants]
tenants = [t for t in tenants if t != "总览" and t != "总工作台"]
return jsonify({
"logged_in": True,
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
@@ -252,197 +256,27 @@ def index():
@bp.route("/api/bootstrap")
def bootstrap():
"""轻量级 bootstrap只返回 summary + financeMonthly首页用其他模块按需加载"""
if "user_id" not in session:
return jsonify({"error": "未登录"}), 401
_year = date.today().year
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
allowed = session.get("tenants", [])
if "总工作台" not in allowed:
allowed = ["总工作台"] + allowed
allowed = [t if t != "总工作台" else "科普·无界" for t in allowed]
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
allowed = [t for t in allowed if t != "总览" and t != "总工作台"]
if tenant not in allowed:
tenant = allowed[0]
tenant = allowed[0] if allowed else ""
conn = db()
try:
# 工作台:聚合所有工作台的首页数据
if tenant == "总工作台":
real_tenants = [t for t in allowed if t != "总工作台"]
all_metrics = []
all_monthly = []
all_recent = []
for t in real_tenants:
t_pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [t])
t_ops = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [t]))
t_sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", [t]))
t_products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [t]))
t_expense = rows(conn, "SELECT * FROM expense_records WHERE tenant=?", [t])
t_proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", [t]))
t_signed_pfs = [x for x in t_pfs if x["status"] == "已签约"]
def t_parse_budget(pf):
try:
budget = json.loads(pf.get("budget_data") or "[]")
except (json.JSONDecodeError, TypeError):
budget = []
return {(b.get("month") or "").replace("-", "_"): b for b in budget}
t_bm = {pf["id"]: t_parse_budget(pf) for pf in t_pfs}
def t_sum_budget(field, months_range):
total = 0
for pf in t_pfs:
bm = t_bm.get(pf["id"], {})
for m in months_range:
b = bm.get(f"{_year}_{m:02d}")
if b:
total += float(b.get(field) or 0)
return total
def t_parse_expense(pf):
try:
ed = json.loads(pf.get("expense_data") or "[]")
except (json.JSONDecodeError, TypeError):
ed = []
return {(e.get("month") or "").replace("-", "_"): e for e in ed}
t_em = {pf["id"]: t_parse_expense(pf) for pf in t_pfs}
def t_sum_expense(field, months_range):
total = 0
for pf in t_pfs:
em = t_em.get(pf["id"], {})
for m in months_range:
e = em.get(f"{_year}_{m:02d}")
if e:
total += float(e.get(field) or 0)
return total
_now_month = date.today().month
_q_start = ((_now_month - 1) // 3) * 3 + 1
_q_range = range(_q_start, _q_start + 3)
_q_months = [f"{_year}-{m:02d}" for m in _q_range]
_prev_q_start = ((_now_month - 4) // 3) * 3 + 1
_prev_q_range = range(max(_prev_q_start, 1), _prev_q_start + 3)
_prev_q_months = [f"{_year}-{m:02d}" for m in _prev_q_range]
_prev_month = _now_month - 1 if _now_month > 1 else None
all_metrics.append({
"total_projects": len(t_signed_pfs),
"total_proposals": len(t_ops),
"total_products": len(t_proposals),
"upcoming_products": len(t_products),
"signed_amount": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约"),
"signed_annual": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约"),
"signed_q2": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months),
"signed_month": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"{_year}-{_now_month:02d}"),
"signed_prev_q": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _prev_q_months),
"signed_prev_month": sum(x["sign_amount"] or 0 for x in t_pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"{_year}-{_prev_month:02d}") if _prev_month else 0,
"revenue_annual": t_sum_budget("rev", range(1, 13)),
"revenue_q2": t_sum_budget("rev", _q_range),
"monthly_revenue": t_sum_budget("rev", [_now_month]),
"revenue_prev_q": t_sum_budget("rev", _prev_q_range),
"revenue_prev_month": t_sum_budget("rev", [_prev_month]) if _prev_month else 0,
"gross_annual": t_sum_budget("gross", range(1, 13)),
"gross_q2": t_sum_budget("gross", _q_range),
"monthly_net_profit": t_sum_budget("gross", [_now_month]),
"gross_prev_q": t_sum_budget("gross", _prev_q_range),
"gross_prev_month": t_sum_budget("gross", [_prev_month]) if _prev_month else 0,
"payment_annual": t_sum_budget("payment", range(1, 13)),
"payment_q2": t_sum_budget("payment", _q_range),
"payment_month": t_sum_budget("payment", [_now_month]),
"payment_prev_q": t_sum_budget("payment", _prev_q_range),
"payment_prev_month": t_sum_budget("payment", [_prev_month]) if _prev_month else 0,
"cost_annual": t_sum_expense("cost", range(1, 13)),
"cost_q2": t_sum_expense("cost", _q_range),
"cost_month": t_sum_expense("cost", [_now_month]),
"cost_prev_q": t_sum_expense("cost", _prev_q_range),
"cost_prev_month": t_sum_expense("cost", [_prev_month]) if _prev_month else 0,
})
# 费用汇总(从 expense_records 表计算)
def t_expense_sum(m_range):
total = 0
for r in t_expense:
if (r.get("status") or "") == "暂不计入": continue
m = (r.get("expense_month") or "").strip()
if not m or "-" not in m: continue
try:
if int(m.split("-")[1]) in m_range:
total += float(r.get("amount") or 0)
except: pass
return total
all_metrics[-1]["expense_annual"] = t_expense_sum(range(1, 13))
all_metrics[-1]["expense_q2"] = t_expense_sum(_q_range)
all_metrics[-1]["expense_month"] = t_expense_sum([_now_month])
all_metrics[-1]["expense_prev_q"] = t_expense_sum(_prev_q_range)
all_metrics[-1]["expense_prev_month"] = t_expense_sum([_prev_month]) if _prev_month else 0
all_metrics[-1]["paid_annual"] = t_sum_expense("paid", range(1, 13))
all_metrics[-1]["paid_q2"] = t_sum_expense("paid", _q_range)
all_metrics[-1]["paid_month"] = t_sum_expense("paid", [_now_month])
all_metrics[-1]["paid_prev_q"] = t_sum_expense("paid", _prev_q_range)
all_metrics[-1]["paid_prev_month"] = t_sum_expense("paid", [_prev_month]) if _prev_month else 0
all_metrics[-1]["proj_expense_annual"] = t_sum_budget("expense", range(1, 13))
all_metrics[-1]["proj_expense_q2"] = t_sum_budget("expense", _q_range)
all_metrics[-1]["proj_expense_month"] = t_sum_budget("expense", [_now_month])
all_metrics[-1]["proj_expense_prev_q"] = t_sum_budget("expense", _prev_q_range)
all_metrics[-1]["proj_expense_prev_month"] = t_sum_budget("expense", [_prev_month]) if _prev_month else 0
all_metrics[-1]["cashflow_annual"] = all_metrics[-1]["payment_annual"] - all_metrics[-1]["paid_annual"]
all_metrics[-1]["cashflow_q2"] = all_metrics[-1]["payment_q2"] - all_metrics[-1]["paid_q2"]
all_metrics[-1]["cashflow_month"] = all_metrics[-1]["payment_month"] - all_metrics[-1]["paid_month"]
all_metrics[-1]["cashflow_prev_q"] = all_metrics[-1]["payment_prev_q"] - all_metrics[-1]["paid_prev_q"]
all_metrics[-1]["cashflow_prev_month"] = all_metrics[-1]["payment_prev_month"] - all_metrics[-1]["paid_prev_month"]
def t_expense_paid_sum(m_range):
total = 0
for r in t_expense:
if (r.get("status") or "") == "暂不计入": continue
m = (r.get("expense_month") or "").strip()
if not m or "-" not in m: continue
try:
if int(m.split("-")[1]) in m_range:
total += float(r.get("incurred_amount") or 0)
except: pass
return total
all_metrics[-1]["expense_paid_annual"] = t_expense_paid_sum(range(1, 13))
all_metrics[-1]["expense_paid_q2"] = t_expense_paid_sum(_q_range)
all_metrics[-1]["expense_paid_month"] = t_expense_paid_sum([_now_month])
all_metrics[-1]["expense_paid_prev_q"] = t_expense_paid_sum(_prev_q_range)
all_metrics[-1]["expense_paid_prev_month"] = t_expense_paid_sum([_prev_month]) if _prev_month else 0
all_metrics[-1]["dept_cf_annual"] = all_metrics[-1]["cashflow_annual"] - all_metrics[-1]["expense_paid_annual"]
all_metrics[-1]["dept_cf_q2"] = all_metrics[-1]["cashflow_q2"] - all_metrics[-1]["expense_paid_q2"]
all_metrics[-1]["dept_cf_month"] = all_metrics[-1]["cashflow_month"] - all_metrics[-1]["expense_paid_month"]
all_metrics[-1]["dept_cf_prev_q"] = all_metrics[-1]["cashflow_prev_q"] - all_metrics[-1]["expense_paid_prev_q"]
all_metrics[-1]["dept_cf_prev_month"] = all_metrics[-1]["cashflow_prev_month"] - all_metrics[-1]["expense_paid_prev_month"]
all_metrics[-1]["profit_annual"] = all_metrics[-1]["gross_annual"] - all_metrics[-1]["proj_expense_annual"]
all_metrics[-1]["profit_q2"] = all_metrics[-1]["gross_q2"] - all_metrics[-1]["proj_expense_q2"]
all_metrics[-1]["profit_month"] = all_metrics[-1]["monthly_net_profit"] - all_metrics[-1]["proj_expense_month"]
all_metrics[-1]["profit_prev_q"] = all_metrics[-1]["gross_prev_q"] - all_metrics[-1]["proj_expense_prev_q"]
all_metrics[-1]["profit_prev_month"] = all_metrics[-1]["gross_prev_month"] - all_metrics[-1]["proj_expense_prev_month"]
all_monthly.append(monthly_finance(conn, t))
all_recent.extend(rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 4", [t]))
agg = {}
for key in ["total_projects","total_proposals","total_products","upcoming_products","signed_amount","signed_annual","signed_q2","signed_month","signed_prev_q","signed_prev_month","revenue_annual","revenue_q2","monthly_revenue","revenue_prev_q","revenue_prev_month","gross_annual","gross_q2","monthly_net_profit","gross_prev_q","gross_prev_month","payment_annual","payment_q2","payment_month","payment_prev_q","payment_prev_month","cost_annual","cost_q2","cost_month","cost_prev_q","cost_prev_month","expense_annual","expense_q2","expense_month","expense_prev_q","expense_prev_month","paid_annual","paid_q2","paid_month","paid_prev_q","paid_prev_month","cashflow_annual","cashflow_q2","cashflow_month","cashflow_prev_q","cashflow_prev_month","expense_paid_annual","expense_paid_q2","expense_paid_month","expense_paid_prev_q","expense_paid_prev_month","dept_cf_annual","dept_cf_q2","dept_cf_month","dept_cf_prev_q","dept_cf_prev_month","profit_annual","profit_q2","profit_month","profit_prev_q","profit_prev_month","proj_expense_annual","proj_expense_q2","proj_expense_month","proj_expense_prev_q","proj_expense_prev_month"]:
agg[key] = sum(m.get(key, 0) for m in all_metrics)
merged_monthly = []
for i in range(12):
m = {"month": all_monthly[0][i]["month"] if all_monthly and len(all_monthly[0]) > i else f"{_year}-{i+1:02d}"}
for field in ["revenue","gross","payment","cost","paid","sign"]:
m[field] = sum(tl[i][field] if i < len(tl) else 0 for tl in all_monthly)
merged_monthly.append(m)
summary = {
"project_name": "总工作台",
"metrics": agg,
"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": [], "planFinances": [], "planExpense": [], "tenant": tenant, "tenants": allowed})
def q(sql, *args):
return rows(conn, sql, args)
sales = attach_common(conn, "sales", q("SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", tenant))
proposals = attach_common(conn, "proposals", q("SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", tenant))
operations = attach_common(conn, "operations", q("SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", tenant))
products = attach_common(conn, "products", q("SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", tenant))
finance = q("SELECT * FROM finance_records WHERE tenant=? ORDER BY month DESC, id DESC", tenant)
tasks = q("SELECT * FROM project_tasks WHERE tenant=? ORDER BY phase, sort_order, id", tenant)
pfs = q("SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", tenant)
expense = q("SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", tenant)
planPfs = q("SELECT * FROM plan_finances WHERE tenant=? ORDER BY id DESC", tenant)
planExpense = q("SELECT * FROM plan_expense_records WHERE tenant=? ORDER BY id DESC", tenant)
signed_pfs = [x for x in pfs if x["status"] == "已签约"]
# 普通工作台:只查 summary 所需的数据project_finances + operations + sales + products + proposals + expense
pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [tenant])
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [tenant]))
sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", [tenant]))
products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [tenant]))
proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", [tenant]))
expense = rows(conn, "SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", [tenant])
signed_pfs = pfs
def parse_budget(pf):
try:
@@ -563,20 +397,18 @@ def bootstrap():
profit_month = gross_month - proj_expense_month
profit_prev_q = gross_prev_q - proj_expense_prev_q
profit_prev_month = gross_prev_month - proj_expense_prev_month
def pf_status_sum(status):
return sum(x["sign_amount"] or 0 for x in pfs if x["status"] == status)
signed_amount = pf_status_sum("已签约")
signed_annual = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约")
signed_amount = sum(x["sign_amount"] or 0 for x in pfs)
signed_annual = sum(x["sign_amount"] or 0 for x in pfs)
_q_months = [f"{_year}-{m:02d}" for m in _q_range]
signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _q_months)
signed_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"{_year}-{_now_month:02d}")
signed_q2 = sum(x["sign_amount"] or 0 for x in pfs if (x.get("sign_month") or "")[:7] in _q_months)
signed_month = sum(x["sign_amount"] or 0 for x in pfs if (x.get("sign_month") or "")[:7] == f"{_year}-{_now_month:02d}")
_prev_q_months = [f"{_year}-{m:02d}" for m in _prev_q_range]
signed_prev_q = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] in _prev_q_months)
signed_prev_month = sum(x["sign_amount"] or 0 for x in pfs if x["status"] == "已签约" and (x.get("sign_month") or "")[:7] == f"{_year}-{_prev_month:02d}") if _prev_month else 0
signed_prev_q = sum(x["sign_amount"] or 0 for x in pfs if (x.get("sign_month") or "")[:7] in _prev_q_months)
signed_prev_month = sum(x["sign_amount"] or 0 for x in pfs if (x.get("sign_month") or "")[:7] == f"{_year}-{_prev_month:02d}") if _prev_month else 0
pipeline_amount = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_status"] not in ["已签约","已丢单","已归档","已完成"])
signed_not_executed = sum(x["expected_contract_amount"] or 0 for x in operations if x["project_type"] == "execution" and x["execution_progress"] < 100)
summary = {
"project_name": "科普(慰心斋)",
"project_name": tenant,
"metrics": {
"p0_customers": len([x for x in sales if x["priority"] == "P0"]),
"active_sales": len([x for x in sales if x["status"] in ["待跟进", "跟进中", "方案中", "商务谈判"]]),
@@ -651,16 +483,36 @@ def bootstrap():
"proj_expense_prev_month": proj_expense_prev_month,
"signed_not_executed": signed_not_executed,
},
"recent": q("SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", tenant),
"recent": rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 8", [tenant]),
"risks": [{"title": "执行提醒", "content": x["next_action"]} for x in operations if x["next_action"]][:5],
}
return jsonify({"summary": summary, "sales": sales, "proposals": proposals, "operations": operations, "products": products, "finance": finance, "projectFinances": pfs, "financeMonthly": monthly_finance(conn, tenant), "tasks": tasks, "expense": expense, "planFinances": planPfs, "planExpense": planExpense, "tenant": tenant, "tenants": allowed})
return jsonify({"summary": summary, "financeMonthly": monthly_finance(conn, tenant), "tenant": tenant, "tenants": allowed})
finally:
conn.close()
# ---------- 通用 CRUD ----------
@bp.route("/api/<resource>/list", methods=["GET"])
@login_required
def list_resource(resource):
"""轻量级列表接口,只返回指定 tenant 的记录"""
if resource not in TABLES:
return jsonify({"error": "unknown resource"}), 404
table, _ = TABLES[resource]
tenant = request.args.get("tenant", session.get("tenants", [""])[0])
allowed = session.get("tenants", [])
allowed = [t if t != "总工作台" else "总览" for t in allowed]
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
if tenant not in allowed:
tenant = allowed[0] if allowed else ""
conn = db()
try:
result = rows(conn, f"SELECT * FROM {table} WHERE tenant=? ORDER BY id DESC", [tenant])
return jsonify(result)
finally:
conn.close()
@bp.route("/api/<resource>", methods=["POST"])
@login_required
def create_resource(resource):

View File

@@ -1,23 +1,12 @@
// app.js — 入口文件(加载模块 + 初始化)
// 恢复上次的工作台和标签页
const savedTenant = localStorage.getItem("opc-active-tenant");
if (savedTenant) {
state.tenant = savedTenant;
const label = savedTenant.replace("·无界", "");
document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台";
const tLabel = document.querySelector("#currentTenantLabel");
if (tLabel) { tLabel.textContent = label || "工作台"; tLabel.title = savedTenant; }
}
const savedTab = localStorage.getItem("opc-active-tab");
// 初始化
applyUserTenants();
updateSidebarTabs();
load().then(() => {
if (state.tenant === "总工作台") {
switchTab("home");
} else if (savedTab && savedTab !== "home") {
applyUserTenants().then(() => {
updateSidebarTabs();
return load();
}).then(() => {
const savedTab = localStorage.getItem("opc-active-tab");
if (savedTab && savedTab !== "home") {
switchTab(savedTab);
}
}).catch((error) => {

View File

@@ -175,7 +175,9 @@ window.deleteDrawerItem = async (resource, id) => {
logActivity(resType, id, "删除了「" + name + "」");
}
closeDrawer();
await load();
var rk = resource === 'projectFinances' ? 'projectFinances' : resource === 'planFinances' ? 'planFinances' : null;
if (rk) { try { state.data[rk] = await api("/api/" + rk + "/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} }
if (resource === 'projectFinances') renderFinance(); else if (resource === 'planFinances') renderPlan();
} catch (error) {
toast("删除失败:" + error.message, "error");
}
@@ -216,21 +218,26 @@ window.submitComment = async (event, targetType, targetId, resource) => {
button.disabled = true;
button.textContent = "发送中…";
await api(`/api/followups/${targetType}/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
await load();
var ck = resource === 'projectFinances' ? 'projectFinances' : resource === 'planFinances' ? 'planFinances' : null;
if (ck) { try { state.data[ck] = await api("/api/" + ck + "/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} }
openDrawer(resource, targetId);
};
window.deleteActivity = async (id) => {
if (!confirm("确认删除这条动态?")) return;
await api(`/api/followups/${id}`, { method: "DELETE" });
await load();
var dk = resource === 'projectFinances' ? 'projectFinances' : resource === 'planFinances' ? 'planFinances' : null;
if (dk) { try { state.data[dk] = await api("/api/" + dk + "/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} }
if (resource === 'projectFinances') renderFinance(); else if (resource === 'planFinances') renderPlan();
};
window.deleteFollowup = async (event, followupId, resource, targetId) => {
event.stopPropagation();
if (!confirm("确认删除这条评论?")) return;
await api(`/api/followups/${followupId}`, { method: "DELETE" });
await load();
var fkey2 = resource === 'projectFinances' ? 'projectFinances' : resource === 'planFinances' ? 'planFinances' : null;
if (fkey2) { try { state.data[fkey2] = await api("/api/" + fkey2 + "/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} }
if (resource === 'projectFinances') renderFinance(); else if (resource === 'planFinances') renderPlan();
openDrawer(resource, targetId);
};

View File

@@ -4,7 +4,7 @@ function renderExpense() {
var records = state.data.expense || [];
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
var esc = function(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
var STATUS_OPTIONS = ['计入费用','暂不计入'];
// 排序状态
@@ -78,12 +78,16 @@ function renderExpense() {
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.expenseQuarter + '"', 'value="' + state.expenseQuarter + '" selected') + '</select>';
}
// Toolbar
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-sm text-slate-500 ml-3">类型:</span><select onchange="setExpenseFilter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
// 类型 Tab 按钮
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setExpenseFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
EXPENSE_TYPES.forEach(function(t) {
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
var cnt = records.filter(function(r){return r.expense_type===t}).length;
typeTabs += '<button onclick="setExpenseFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
});
toolbar += '</select>' + dateSelect;
typeTabs += '</div>';
// Toolbar视图+日期选择器)
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select>' + dateSelect;
// 排序箭头
var sortArrow = function(key) {
@@ -119,12 +123,12 @@ function renderExpense() {
var target = document.querySelector("#financeExpense") || document.querySelector("#financeTabExpense") || document.querySelector("#expense");
if (!target) return;
target.innerHTML = '<div class="grid gap-4">' +
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
'<div class="grid grid-cols-3 gap-3">' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">已发生金额</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
'</div>' +
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
modal +
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'incurred_amount\')">已发生金额' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'</div>';
@@ -194,13 +198,10 @@ window.saveExpense = async function(event) {
var resp = await fetch(url, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: Object.assign({}, data, { tenant: state.tenant }) }) });
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();
renderExpense();
}
try {
state.data.expense = await fetch("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)).then(r => r.json());
} catch(e) {}
renderExpense();
closeExpenseModal();
};
@@ -209,11 +210,8 @@ window.deleteExpenseItem = async function() {
if (!id || !confirm("确定删除?")) return;
var resp = await fetch("/api/expense/" + 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();
renderExpense();
}
try {
state.data.expense = await fetch("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)).then(r => r.json());
} catch(e) {}
renderExpense();
};

View File

@@ -1,6 +1,6 @@
// finance.js — 经营管理(财务)模块
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(1)}`;
function renderFinance() {
@@ -24,8 +24,8 @@ function renderFinance() {
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 signed = pfs;
const pending = pfs;
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));
@@ -78,49 +78,52 @@ function renderFinance() {
if (isRevView) {
const rev = b.rev || 0;
const gross = b.gross || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
} else {
const payment = b.payment || 0;
const cost = b.cost || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${payment ? 'text-amber-700 font-medium' : 'text-slate-300'}">${payment ? money(payment) : '—'}</span><br><span class="text-xs ${cost ? 'text-rose-600' : 'text-slate-300'}">${cost ? money(cost) : '—'}</span></td>`;
}
}).join("");
const totalCol = (() => {
if (isRevView) {
const totalRev = pf.total_rev || 0;
const totalGross = pf.total_gross || 0;
return `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
} else {
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 `<td class="p-2 text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalPayment ? 'text-amber-700' : 'text-slate-300'}">${totalPayment ? money(totalPayment) : '—'}</span><br><span class="text-xs ${totalCost ? 'text-rose-600' : 'text-slate-300'}">${totalCost ? money(totalCost) : '—'}</span></td>`;
}
})();
const sm = pf.sign_month || "";
const signMonthCell = `<td class="p-2 text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="p-2 text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
};
const now2 = new Date();
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
if (!state.finMonth) state.finMonth = defaultMonth2;
if (state.finQuarter === undefined) state.finQuarter = 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 => '<option value="'+m+'" '+(m===state.finMonth?'selected':'')+'>'+m+'</option>').join("");
const toolQuarterSelect = qLabels2.map((l,i) => '<option value="'+i+'" '+(i===state.finQuarter?'selected':'')+'>'+l+'</option>').join("");
const toolDateSelect = state.finView==='monthly'?'<span class="text-sm text-slate-500 ml-2">份:</span><select onchange="state.finMonth=this.value;renderFinance()" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">'+toolMonthSelect+'</select>':state.finView==='quarterly'?'<span class="text-sm text-slate-500 ml-2">度:</span><select onchange="state.finQuarter=parseInt(this.value);localStorage.setItem(\'opc-fin-quarter\',this.value);renderFinance()" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">'+toolQuarterSelect+'</select>':'';
const finHeaderBase = `<span class="text-sm text-slate-500">视图:</span><select onchange="setFinView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="overview" ${state.finView==='overview'?'selected':''}>总视图</option><option value="quarterly" ${state.finView==='quarterly'?'selected':''}>季度视图</option><option value="monthly" ${state.finView==='monthly'?'selected':''}>月度视图</option></select>${toolDateSelect}`;
const qLabels = ['Q1','Q2','Q3','Q4'];
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
const nowD = new Date();
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
const activeMonths = qMonths[activeQ];
const yearOpts = [2024,2025,2026,2027];
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setFinView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 rounded text-sm font-medium border border-slate-200 bg-white">${yearOpts.map(y => `<option value="${y}" ${state.finYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">度:</span>${activeMonths.map(m => { const ms = `${state.finYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">总览</button><button onclick="switchFinFilter('已签约')" class="finance-tab${state.finFilter==='已签约'?' active':''}" style="font-size:14px;font-weight:600">项目 (${pfs.filter(x=>x.status==='已签约').length})</button><button onclick="switchFinFilter('待签约')" class="finance-tab${state.finFilter==='待签约'?' active':''}" style="font-size:14px;font-weight:600">待签约 (${pfs.filter(x=>x.status==='待签约').length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button></div>`;
const isOverviewMode = false;
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">经营月报</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
document.querySelector("#finance").innerHTML = `<div class="grid gap-2">
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#fef9c3,#fde047);border-color:#eab308">
<i data-lucide="lightbulb" class="text-yellow-700 flex-shrink-0" style="width:18px;height:18px"></i>
<span class="text-sm text-yellow-900"><strong>已发生模块:</strong>只记录,已经发生的确收,毛利,回款,成本,支出</span>
</div>`}
${finFilterTabs}
${state.finFilter !== 'expense' && state.finFilter !== 'overview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "p-4") : ''}
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? '' : ''}
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
@@ -133,16 +136,16 @@ function renderFinance() {
<form onsubmit="createFinance(event)" class="finance-form" novalidate><input type="hidden" name="pf_id" id="pf-id-input" value="">
<div class="finance-tab-body">
<div id="financeTabInfo">
<div class="grid gap-4">
<div class="grid gap-2">
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如KP-2026-001"></label>
<label class="block"><span class="fin-label">项目名称 <span class="text-red-500">*</span></span><input name="customer_name" required class="form-ctrl" placeholder="请输入项目名称"></label>
<label class="block"><span class="fin-label">项目名称 <span class="text-red-500">*</span></span><input name="customer_name" required class="form-ctrl" placeholder="请输入项目名称"></label><label class="block"><span class="fin-label">客户名称</span><input name="client_name" class="form-ctrl" placeholder="客户名称"></label>
</div>
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
<label class="block"><span class="fin-label">项目状态</span><select name="status" class="form-ctrl bg-white"><option>已签约</option><option>待签约</option></select></label>
</div>
<div class="grid grid-cols-3 gap-3">
<label class="block"><span class="fin-label">商务负责人 <span class="text-red-500">*</span></span><input name="sales_person" required class="form-ctrl" placeholder="请输入商务负责人"></label>
@@ -182,7 +185,7 @@ function renderFinance() {
<p class="text-lg font-bold text-amber-700" id="revpayTotalPayment">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="revpayTbody"></tbody>
</table>
@@ -195,18 +198,18 @@ function renderFinance() {
<p class="text-lg font-bold text-rose-700" id="costTotalCost">¥0</p>
</div>
<div class="bg-purple-50 rounded-lg p-3 text-center border border-purple-100">
<p class="text-xs text-purple-600 font-medium">已付</p>
<p class="text-xs text-purple-600 font-medium">支出</p>
<p class="text-lg font-bold text-purple-700" id="costTotalPaid">¥0</p>
</div>
</div>
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="costTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="costTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-left font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="costTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addCostRow()"><i data-lucide="plus"></i></button>
</div>
<div id="financeTabTasks" class="hidden">
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="taskTbody"></tbody>
</table>
@@ -241,30 +244,36 @@ function renderFinance() {
setTimeout(() => renderFinanceOverview(), 10);
return `<div id="financeOverview"></div>`;
}
if (state.finFilter === 'quarterlyOverview') {
setTimeout(() => renderFinanceQuarterlyOverview(), 10);
return `<div id="financeQuarterlyOverview"></div>`;
}
if (state.finFilter === 'expense') {
setTimeout(() => renderExpense(), 10);
return `<div id="financeExpense"></div>`;
}
const isUnsigned = state.finFilter === '待签约';
const isUnsigned = false;
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 => { const v = ctx.sumRev - ctx.sumPay; return { val: v < 0 ? '超额回款 ' + moneyWan(Math.abs(v)) : moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
{ label: '支出', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.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.finFilter !== '待签约' || !c.hideUnsigned);
].filter(c => true);
// 列排序
if (!state.finSort) state.finSort = { key: null, asc: true };
// 列排序(默认按确收金额从高到低)
if (!state.finSort) state.finSort = { key: 'sig|rev', asc: false };
const sortData = (data, key) => {
if (!key || !state.finSort.key) return data;
if (!key) return data;
if (!state.finSort.key) {
// 默认按确收金额从高到低
return data.slice().sort(function(a, b) { return (parseFloat(b.rev) || 0) - (parseFloat(a.rev) || 0); });
}
var asc = state.finSort.asc ? 1 : -1;
return data.slice().sort(function(a, b) {
var va = a[key], vb = b[key];
@@ -279,22 +288,24 @@ function renderFinance() {
var h = '<thead><tr class="bg-slate-50 border-b border-slate-200">';
cols.forEach(function(c) {
var arrow = sortKey ? (sortKey === c.key ? (state.finSort.asc ? ' ↑' : ' ↓') : '') : '';
h += '<th class="p-2 text-center font-semibold align-middle cursor-pointer select-none" onclick="setFinSort(\'' + sortKey + '|' + c.key + '\')">' + c.label + arrow + '</th>';
h += '<th class="px-2 py-px text-center font-semibold align-middle cursor-pointer select-none" onclick="setFinSort(\'' + sortKey + '|' + c.key + '\')">' + c.label + arrow + '</th>';
});
h += '</tr></thead>';
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: '付款率' },
{ key: '_expand', label: '' },
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
{ key: 'payment', label: '回款' }, { key: 'pay_rate', label: '回款率' },
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'paid_rate', label: '付款率' },
{ key: 'cashflow', label: '现金流' },
{ key: 'pending', label: '待回款' },
];
const UNSIGNED_COLS = [
{ key: 'customer_name', label: '项目名称' }, { key: 'status', label: '状态' }, { key: 'sign_amount', label: '签约金额' },
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
{ key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
{ key: 'profit', label: '项目利润' },
,
];
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
@@ -304,14 +315,15 @@ function renderFinance() {
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 ? `<tbody>${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const theadCols = isUnsigned ? 6 : 12;
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-6';
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
const subtitle = isUnsigned
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
return card(`<div class="grid ${cardGrid} gap-3 mb-3">${cards.map(([l, v, c]) => '<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">' + l + '</span><strong class="mt-2 block text-xl ' + c + '">' + v + '</strong></div>').join("")}</div>`, "p-4") +
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
`${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
};
@@ -324,25 +336,42 @@ function renderFinance() {
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
const st = pf.status === '已签约' ? '<span class="text-green-600">已签约</span>' : '<span class="text-amber-600">待签约</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300"></span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${st}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td><td class="p-2 text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="p-2 text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="p-2 text-center align-middle text-sm">${exe}</td><td class="p-2 text-center align-middle text-sm">${grossR}</td><td class="p-2 text-center align-middle text-sm">${payR}</td><td class="p-2 text-center align-middle text-sm">${paidR}</td></tr>`;
const pending = rev - payment;
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-400';
const pendingVal = pending ? money(pending) : '<span class="text-slate-300">—</span>';
var budgetArr = [];
var expenseArr = [];
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
// 合并 budget 和 expense 的所有月份
var allMonths = {};
budgetArr.forEach(function(b) { allMonths[b.month || ''] = true; });
expenseArr.forEach(function(e) { allMonths[e.month || ''] = true; });
var detailRows = Object.keys(allMonths).filter(function(m) {
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
return bRev || bGross || bPay || eCost || ePaid;
}).sort().map(function(m) {
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
var bCf = bPay - ePaid;
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
}).join('');
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="fin_expand_${pf.id}"><td colspan="14"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
const st = pf.status === '待签约' ? '<span class="text-amber-600">待签约</span>' : '<span class="text-green-600">已签约</span>';
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="p-2 text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="p-2 text-center align-middle text-sm">${st}</td><td class="p-2 text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="p-2 text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="p-2 text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="p-2 text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
};
if (state.finView === 'monthly') {
const allPfs = pfs.filter(x => x.status === state.finFilter);
const allPfs = pfs;
const now = new Date();
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
if (!state.finMonth) state.finMonth = defaultMonth;
@@ -360,9 +389,8 @@ function renderFinance() {
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,
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
rev: rev, gross: gross, cost: cost, 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,
@@ -379,12 +407,12 @@ function renderFinance() {
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;
const signCnt = allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据');
}
if (state.finView === 'quarterly') {
const allPfs = pfs.filter(x => x.status === state.finFilter);
const allPfs = pfs;
const qRanges = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]];
const now = new Date();
if (state.finQuarter === undefined) {
@@ -395,12 +423,12 @@ function renderFinance() {
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) {}
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.finYear) total += parseFloat(b[field] || 0); }); } catch (e) {}
return total;
};
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) {}
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.finYear) total += parseFloat(e[field] || 0); }); } catch (e) {}
return total;
};
var dataItems = [];
@@ -412,9 +440,8 @@ function renderFinance() {
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,
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
rev: rev, gross: gross, cost: cost, 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,
@@ -431,7 +458,7 @@ function renderFinance() {
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;
const signCnt = allPfs.filter(x => qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据');
}
@@ -445,13 +472,12 @@ function renderFinance() {
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.finFilter);
const allPfs = pfs;
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,
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
rev: t.rev, gross: t.gross, cost: t.cost,
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,
@@ -468,7 +494,7 @@ function renderFinance() {
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;
const signCnt = allPfs.length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据');
})()}
</div>`;
@@ -695,7 +721,7 @@ window.editPfSignMonth = (event, pfId) => {
};
window.switchFinanceTab = (tab) => {
document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
document.querySelectorAll("#financeModal .finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info");
document.querySelector("#financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
document.querySelector("#financeTabCost").classList.toggle("hidden", tab !== "cost");
@@ -758,6 +784,19 @@ window.deleteFinFollowup = async (event, followupId) => {
if (pfId) await loadFinFollowups(pfId);
};
window.finToggleExpand = (pfId) => {
var row = document.getElementById('fin_expand_' + pfId);
var icon = document.getElementById('fin_expand_icon_' + pfId);
if (!row) return;
if (row.classList.contains('hidden')) {
row.classList.remove('hidden');
if (icon) { icon.setAttribute('data-lucide', 'chevron-down'); if (window.lucide) window.lucide.createIcons(); }
} else {
row.classList.add('hidden');
if (icon) { icon.setAttribute('data-lucide', 'chevron-right'); if (window.lucide) window.lucide.createIcons(); }
}
};
window.openPfEditModal = (pfId) => {
const pf = (state.data.projectFinances || []).find(x => x.id === pfId);
if (!pf) return;
@@ -780,6 +819,7 @@ window.openPfEditModal = (pfId) => {
}
});
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
const clientInput = form.querySelector('[name="client_name"]'); if (clientInput) clientInput.value = pf.client_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 || "";
@@ -789,7 +829,6 @@ window.openPfEditModal = (pfId) => {
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);
@@ -915,7 +954,8 @@ window.createFinance = async (event) => {
document.querySelector("#pf-id-input").value = "";
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
closeFinanceModal();
await load();
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
renderFinance();
} catch (error) {
toast("保存失败:" + error.message, "error");
}
@@ -930,76 +970,256 @@ window.deleteFinanceItem = async () => {
try {
await api(`/api/projectFinances/${pfId}`, { method: "DELETE" });
closeFinanceModal();
await load();
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
renderFinance();
toast("已删除", "success");
} catch (error) {
toast("删除失败:" + error.message, "error");
}
}
// 总览渲染
// 经营月报渲染
function renderFinanceOverview() {
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 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) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
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 '<span class="text-slate-300">—</span>'; const pct = Math.round((cur - prev) / prev * 100); const cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; const sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
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 '<td class="py-2 text-right font-semibold ' + (PF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
if (ri === 7) return '<td class="py-2 text-right font-semibold ' + (CF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
return '<td class="py-2 text-right font-semibold text-slate-800">' + val + '</td>';
};
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500">指标</th><th class="py-2 text-right font-semibold text-slate-700">年度累计</th><th class="py-2 text-right font-semibold text-slate-700">上季度累计</th><th class="py-2 text-right font-semibold text-slate-700">上月累计</th><th class="py-2 text-right font-semibold text-slate-700">本季度累计</th><th class="py-2 text-right font-semibold text-slate-400">季环比</th><th class="py-2 text-right font-semibold text-slate-700">本月累计</th><th class="py-2 text-right font-semibold text-slate-400">月环比</th></tr>';
const fm = financeMonthly || [];
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)];
const fmFields = ['sign','revenue','gross','cost','paid','payment',null];
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
thead += '</tr>';
var tbody = '';
for (var ri = 0; ri < 8; ri++) {
var vals = ROWS[ri];
tbody += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + LABELS[ri] + '</td>' +
tdVal(ri, 0, vals[0][1]) + tdVal(ri, 3, vals[3][1]) + tdVal(ri, 4, vals[4][1]) + tdVal(ri, 1, vals[1][1]) +
'<td class="py-2 text-right">' + qoq(Q_FIELDS[ri], Q_PREV[ri]) + '</td>' +
tdVal(ri, 2, vals[2][1]) +
'<td class="py-2 text-right">' + qoq(M_FIELDS[ri], M_PREV[ri]) + '</td></tr>';
for (var ri = 0; ri < 7; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
for (var mi = 0; mi < 12; mi++) {
var val = 0;
if (fm[mi]) {
if (ri === 6) { val = (fm[mi].payment || 0) - (fm[mi].paid || 0); }
else { var fk = fmFields[ri]; if (fk) val = fm[mi][fk] || 0; }
}
var cls = 'text-slate-700';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
}
tbody += rowHtml + '</tr>';
}
document.querySelector("#financeOverview").innerHTML = '<div class="grid gap-5">' +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'<div class="grid grid-cols-4 gap-5">' +
// 业务线经营情况卡片
var finExp = state.data.expense || [];
var expMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
for (var ei = 0; ei < finExp.length; ei++) {
if ((finExp[ei].status || '') === '暂不计入') continue;
var em = (finExp[ei].expense_month || '').substring(0, 7);
var mi2 = parseInt(em.substring(5)) - 1;
if (mi2 >= 0 && mi2 < 12) expMonthly[mi2] += parseFloat(finExp[ei].amount || 0);
}
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
var grossAnnual = 0; for (var ei = 0; ei < 12; ei++) grossAnnual += (fm[ei] ? fm[ei].gross : 0);
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[dmi] + '</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
{ label: '平台费用', vals: expMonthly, annual: expAnnual },
{ label: '利润', vals: fm.map(function(x, i) { return (x ? x.gross : 0) - expMonthly[i]; }), annual: grossAnnual - expAnnual }
];
var deptTbody = '';
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri];
var rowCls = dri === 2 ? 'font-semibold' : '';
var valCls = dri === 2 ? 'text-green-600' : 'text-slate-700';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + valCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
for (var dmi = 0; dmi < 12; dmi++) {
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + valCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
var deptCard = card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
// 业务线现金流卡片
var cfMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
for (var cfi = 0; cfi < 12; cfi++) { cfMonthly[cfi] = (fm[cfi] ? (fm[cfi].payment - fm[cfi].paid) : 0); }
var cfAnnual = 0; for (var cfi = 0; cfi < 12; cfi++) cfAnnual += cfMonthly[cfi];
var expPaidMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
for (var epi = 0; epi < finExp.length; epi++) {
if ((finExp[epi].status || '') === '暂不计入') continue;
var epm = (finExp[epi].expense_month || '').substring(0, 7);
var epmi = parseInt(epm.substring(5)) - 1;
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(finExp[epi].incurred_amount || 0);
}
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
var blCfMonthly = cfMonthly.map(function(v, i) { return v - expPaidMonthly[i]; });
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
{ label: '平台费用(支出)', vals: expPaidMonthly, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
var cfdr = cfRows[cfri];
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
var cfValCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
var cfRowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
cfRowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cfValCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
for (var cfdmi = 0; cfdmi < 12; cfdmi++) {
var cellCls = cfValCls;
if (cfri === 2) cellCls = blCfMonthly[cfdmi] >= 0 ? 'text-green-600' : 'text-red-600';
cfRowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[cfdmi]) + '</td>';
}
cfTbody += cfRowHtml + '</tr>';
}
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(支出)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
document.querySelector("#financeOverview").innerHTML = '<div class="grid gap-2.5">' +
deptCard +
cfCard +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务月度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
'<div class="grid grid-cols-3 gap-2.5">' +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartRev"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度项目利润</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartProfit"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与支出</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
'</div></div>';
if (window.lucide) window.lucide.createIcons();
// 渲染图表(复用 home.js 的 moneyTick / chartOptions / monthLabels
if (financeMonthly && window.Chart) {
renderOverviewCharts(financeMonthly);
}
}
// 经营季报渲染
function renderFinanceQuarterlyOverview() {
const { summary, financeMonthly } = state.data;
if (!summary) return;
const m = summary.metrics;
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
const fm = financeMonthly || [];
var qRanges = [[0,1,2],[3,4,5],[6,7,8],[9,10,11]];
// 财务概览: 7 rows
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
var annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)];
var fmFields = ['sign','revenue','gross','cost','paid','payment',null];
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
for (var qi = 0; qi < 4; qi++) {
for (var ri = 0; ri < 7; ri++) {
var sum = 0;
for (var mi = 0; mi < 3; mi++) {
var idx = qRanges[qi][mi];
if (fm[idx]) {
if (ri === 6) { sum += (fm[idx].payment||0) - (fm[idx].paid||0); }
else { var fk = fmFields[ri]; if (fk) sum += fm[idx][fk] || 0; }
}
}
qData[ri][qi] = sum;
}
}
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '</tr>';
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
var tbody = '';
for (var ri = 0; ri < 7; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(annualVals[ri]) + '</td>';
for (var qi = 0; qi < 4; qi++) {
var val = qData[ri][qi];
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
if (qi > 0) { rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(val, qData[ri][qi - 1]) + '</td>'; }
}
tbody += rowHtml + '</tr>';
}
// 业务线经营情况: quarterly
var finExp = state.data.expense || [];
var expQ = [0,0,0,0];
for (var ei = 0; ei < finExp.length; ei++) {
if ((finExp[ei].status || '') === '暂不计入') continue;
var em = (finExp[ei].expense_month || '').substring(0, 7);
var emi = parseInt(em.substring(5)) - 1;
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expQ[eqi] += parseFloat(finExp[ei].amount || 0); }
}
var expAnnual = 0; for (var ei = 0; ei < 4; ei++) expAnnual += expQ[ei];
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
var grossAnnual = m.gross_annual || 0;
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; }
deptThead += '</tr>';
var deptRows = [
{ label: '毛利', vals: grossQ, annual: grossAnnual },
{ label: '平台费用', vals: expQ, annual: expAnnual },
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
];
var deptTbody = '';
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri];
var rowCls = dri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
for (var qi = 0; qi < 4; qi++) {
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
}
deptTbody += rowHtml + '</tr>';
}
// 业务线现金流: quarterly
var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]];
var cfAnnual = m.payment_annual || 0;
var expPaidQ = [0,0,0,0];
for (var ei = 0; ei < finExp.length; ei++) {
if ((finExp[ei].status || '') === '暂不计入') continue;
var em = (finExp[ei].expense_month || '').substring(0, 7);
var emi = parseInt(em.substring(5)) - 1;
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expPaidQ[eqi] += parseFloat(finExp[ei].incurred_amount || 0); }
}
var expPaidAnnual = 0; for (var ei = 0; ei < 4; ei++) expPaidAnnual += expPaidQ[ei];
var blCfQ = cfQ.map(function(v, i) { return v - expPaidQ[i]; });
var blCfAnnual = cfAnnual - expPaidAnnual;
var cfRows = [
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
];
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
var cfdr = cfRows[cfri];
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfdr.label + '</td>';
var annualCls = cfri === 2 ? (cfdr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(cfdr.annual) + '</td>';
for (var qi = 0; qi < 4; qi++) {
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
}
cfTbody += rowHtml + '</tr>';
}
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出 → 回款 → 项目现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
'</div>';
if (window.lucide) window.lucide.createIcons();
}
function renderOverviewCharts(data) {
if (typeof monthLabels !== 'function') return;
const labels = monthLabels(data);
@@ -1019,7 +1239,7 @@ function renderOverviewCharts(data) {
var c3 = document.querySelector("#finChartCash");
if (c3 && window.Chart) {
if (state.finChart3) state.finChart3.destroy();
state.finChart3 = new Chart(c3, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "回款", data: data.map(function(x) { return x.payment || 0; }), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, { label: "已付", data: data.map(function(x) { return x.cost || 0; }), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }] } }));
state.finChart3 = new Chart(c3, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "回款", data: data.map(function(x) { return x.payment || 0; }), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 }, { label: "支出", data: data.map(function(x) { return x.cost || 0; }), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 }] } }));
}
var c4 = document.querySelector("#finChartProfit");
if (c4 && window.Chart) {

View File

@@ -3,7 +3,7 @@
function renderHome() {
const { summary, financeMonthly } = state.data;
const m = summary.metrics;
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
// 回款提醒
const yrRev = m.revenue_annual || 0, yrPay = m.payment_annual || 0, yrUnpaid = yrRev - yrPay;
const yrPayRate = yrRev > 0 ? Math.round(yrPay / yrRev * 100) : 0;
@@ -117,7 +117,7 @@ function renderHome() {
}
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
${state.tenant === "总工作台" ? "" : `<div class="grid grid-cols-4 gap-3">
${`<div class="grid grid-cols-4 gap-3">
${[
["项目管理", m.total_projects, "finance"],
["重点工作与台账", m.total_proposals, "projects"],

View File

@@ -10,7 +10,7 @@ window.renderPerformance = () => {
const qRange = qRanges[q];
const storageKey = 'opc-performance-Q' + (q + 1);
const pfs = (data.projectFinances || []).filter(p => p.status === '已签约');
const pfs = (data.projectFinances || []);
const sumQ = (field) => {
let total = 0;

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); };
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
var STATUS_OPTIONS = ['计入费用','暂不计入'];
// 排序状态
@@ -78,12 +78,16 @@ function renderPlanExpense() {
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setPlanExpQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.planExpQuarter + '"', 'value="' + state.planExpQuarter + '" selected') + '</select>';
}
// Toolbar
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-sm text-slate-500 ml-3">类型:</span><select onchange="setPlanExpFilter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
// 类型 Tab 按钮
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setPlanExpFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
EXPENSE_TYPES.forEach(function(t) {
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
var cnt = records.filter(function(r){return r.expense_type===t}).length;
typeTabs += '<button onclick="setPlanExpFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
});
toolbar += '</select>' + dateSelect;
typeTabs += '</div>';
// Toolbar视图+日期选择器)
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url(&apos;data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E&apos;) no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select>' + dateSelect;
// 排序箭头
var sortArrow = function(key) {
@@ -114,19 +118,19 @@ function renderPlanExpense() {
var modal = '<div id="planExpenseModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanExpModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between"><h3 class="text-lg font-bold text-slate-800" id="planExpenseModalTitle">新增费用</h3><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planExpDeleteBtn" onclick="deletePlanExpItem()">删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanExpModal()"><i data-lucide="x"></i></button></div></div><form onsubmit="savePlanExpense(event)" class="p-6 grid gap-4" novalidate><input type="hidden" name="expense_id" value=""><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用类型 <span class="text-red-500">*</span></span><select name="expense_type" required class="form-ctrl bg-white"><option value="">请选择</option>';
EXPENSE_TYPES.forEach(function(t) { modal += '<option>' + t + '</option>'; });
modal += '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">月份 <span class="text-red-500">*</span></span><select name="expense_month" required class="form-ctrl bg-white">' + modalMonthOpts + '</select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">金额(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">已发生金额(元)</span><input name="incurred_amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><label class="block"><span class="text-sm text-slate-500 mb-1 block">状态</span><select name="status" class="form-ctrl bg-white">' + statusOpts + '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用说明</span><textarea name="notes" class="form-ctrl" rows="3" placeholder="备注信息"></textarea></label><div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closePlanExpModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>';
modal += '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">月份 <span class="text-red-500">*</span></span><select name="expense_month" required class="form-ctrl bg-white">' + modalMonthOpts + '</select></label></div><div class="grid grid-cols-2 gap-3"><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用计提(元)</span><input name="amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">已发生费用计提(元)</span><input name="incurred_amount" type="number" step="0.01" class="form-ctrl" placeholder="0.00"></label></div><label class="block"><span class="text-sm text-slate-500 mb-1 block">状态</span><select name="status" class="form-ctrl bg-white">' + statusOpts + '</select></label><label class="block"><span class="text-sm text-slate-500 mb-1 block">费用说明</span><textarea name="notes" class="form-ctrl" rows="3" placeholder="备注信息"></textarea></label><div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closePlanExpModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>';
var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
if (!target) return;
target.innerHTML = '<div class="grid gap-4">' +
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
'<div class="grid grid-cols-3 gap-3">' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">现金支出</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
'</div>' +
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
modal +
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">已发生金额' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">现金支出' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
'</div>';
if (window.lucide) window.lucide.createIcons();
@@ -184,36 +188,39 @@ window.savePlanExpense = async function(event) {
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;
var id = data.expense_id;
delete data.expense_id;
data.tenant = state.tenant;
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();
try {
if (id) {
await api("/api/planExpense/" + id, { method: "PUT", body: JSON.stringify({ data }) });
} else {
await api("/api/planExpense", { method: "POST", body: JSON.stringify({ data }) });
}
} catch (error) {
toast("保存失败:" + error.message, "error");
return;
}
closePlanExpModal();
try {
state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant));
renderPlanExpense();
} catch (e) { /* non-critical */ }
};
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();
try {
await api("/api/planExpense/" + id, { method: "DELETE" });
} catch (error) {
toast("删除失败:" + error.message, "error");
return;
}
closePlanExpModal();
try {
state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant));
renderPlanExpense();
} catch (e) { /* non-critical */ }
};

View File

@@ -19,7 +19,7 @@ async function createResource(event, resource) {
const name = data.project_name || data.target_customer || data.customer_or_project_name || data.product_name || "";
if (result.id && name) logActivity(resType, result.id, "创建了" + name);
form.reset();
await load();
try { state.data.products = await api("/api/products/list?tenant=" + encodeURIComponent(state.tenant)); renderProducts(); } catch(e) {}
} catch (error) {
toast("创建失败:" + error.message, "error");
}
@@ -141,7 +141,7 @@ window.submitProductDrawer = async (event) => {
form.reset();
closeProductDrawer();
if (result.id) logActivity("product", result.id, "创建了产品版本「" + data.product_name + " " + data.version + "」");
await load();
try { state.data.products = await api("/api/products/list?tenant=" + encodeURIComponent(state.tenant)); renderProducts(); } catch(e) {}
} catch (error) {
toast("创建失败:" + error.message, "error");
}

View File

@@ -1,7 +1,7 @@
// projects.js — 重点工作与台账(项目管理 + 任务管理)
function applyUserTenants() {
fetch("/api/auth/me").then(r => r.json()).then(data => {
return fetch("/api/auth/me").then(r => r.json()).then(data => {
if (!data.logged_in) { window.location.href = "/login"; return; }
const user = data.user;
const avatar = document.querySelector("#userAvatar");
@@ -15,6 +15,13 @@ function applyUserTenants() {
});
// 缓存可用工作台列表,供下拉菜单使用
state.allowedTenants = data.tenants || [];
// 如果当前工作台不在权限范围内,选择第一个
if (state.allowedTenants.length > 0 && state.allowedTenants.indexOf(state.tenant) < 0) {
state.tenant = state.allowedTenants[0];
localStorage.setItem("opc-active-tenant", state.tenant);
var label0 = state.tenant.replace("·无界", "");
document.querySelector("#workspaceTitle").textContent = label0 + " OPC 工作台";
}
updateTenantLabel();
});
}
@@ -25,7 +32,7 @@ window.toggleTenantMenu = (event) => {
if (menu) { menu.remove(); return; }
const btn = event.currentTarget;
const rect = btn.getBoundingClientRect();
const tenants = state.allowedTenants || [];
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
menu = document.createElement("div");
menu.id = "tenantMenu";
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]";
@@ -52,18 +59,75 @@ window.toggleTenantMenu = (event) => {
};
window.switchTenantFromMenu = (tenant) => {
document.getElementById("tenantMenu")?.remove();
switchTenant(tenant);
};
function updateTenantLabel() {
const label = document.querySelector("#currentTenantLabel");
if (label) {
label.textContent = state.tenant.replace("·无界", "") || "工作台";
label.title = state.tenant;
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
var tenantIcons = {
'学术·无界': 'graduation-cap',
'MCN·无界': 'video',
'科普·无界': 'flask-conical',
'医患·无界': 'stethoscope',
'科研·无界': 'microscope',
'测试·无界': 'flask-round',
'总览': 'layout-dashboard'
};
const container = document.querySelector("#tenantList");
if (container) {
var currentShort = (state.tenant || '').replace("·无界", "");
var currentIcon = tenantIcons[state.tenant] || 'circle';
container.innerHTML = '<div class="flex flex-col items-center cursor-pointer hover:bg-slate-800 rounded-lg py-2 px-1 w-14 transition-colors" onclick="toggleTenantMenu(event)" title="切换工作台">' +
'<i data-lucide="' + currentIcon + '" style="width:20px;height:20px;color:#60a5fa"></i>' +
'<span class="text-[10px] mt-1 max-w-[56px] truncate text-center text-slate-300">' + esc(currentShort) + '</span>' +
'<i data-lucide="chevron-down" style="width:12px;height:12px;color:#64748b;margin-top:2px"></i>' +
'</div>';
if (window.lucide) window.lucide.createIcons();
}
}
window.toggleTenantMenu = (event) => {
event.stopPropagation();
let menu = document.getElementById("tenantMenu");
if (menu) { menu.remove(); return; }
const btn = event.currentTarget;
const rect = btn.getBoundingClientRect();
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
var tenantIcons = {
'学术·无界': 'graduation-cap',
'MCN·无界': 'video',
'科普·无界': 'flask-conical',
'医患·无界': 'stethoscope',
'科研·无界': 'microscope',
'测试·无界': 'flask-round',
'总览': 'layout-dashboard'
};
menu = document.createElement("div");
menu.id = "tenantMenu";
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[180px] z-[9999]";
menu.style.left = Math.min(rect.left - 8, window.innerWidth - 200) + "px";
menu.style.top = rect.bottom + 6 + "px";
menu.innerHTML = '<div class="px-4 py-2 border-b border-slate-100"><p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">切换工作台</p></div>' +
tenants.map(t => {
const shortName = t.replace("·无界", "");
const isActive = t === state.tenant;
var icon = tenantIcons[t] || 'circle';
return '<button class="w-full text-left px-4 py-2.5 text-sm hover:bg-slate-50 transition-colors flex items-center gap-3 ' + (isActive ? 'text-blue-600 font-medium bg-blue-50' : 'text-slate-700') + '" onclick="switchTenantFromMenu(\'' + t.replace(/'/g, "\\'") + '\')">' +
'<i data-lucide="' + icon + '" style="width:16px;height:16px;color:' + (isActive ? '#2563eb' : '#94a3b8') + '"></i>' +
'<span class="flex-1">' + esc(shortName) + '</span>' +
(isActive ? '<i data-lucide="check" style="width:14px;height:14px;color:#2563eb"></i>' : '') +
'</button>';
}).join('');
document.body.appendChild(menu);
if (window.lucide) window.lucide.createIcons();
setTimeout(() => {
document.addEventListener("click", function closeMenu() {
menu.remove();
document.removeEventListener("click", closeMenu);
}, { once: true });
}, 10);
};
window.toggleUserMenu = (user) => {
let menu = document.getElementById("userMenu");
if (menu) { menu.remove(); return; }
@@ -210,7 +274,7 @@ window.duplicateProject = async () => {
});
}
toast("已创建副本", "success");
await load();
try { state.data.operations = await api("/api/operations/list?tenant=" + encodeURIComponent(state.tenant)); renderProjects(); } catch(e) {}
} catch (error) {
toast("创建副本失败:" + error.message, "error");
}
@@ -447,7 +511,7 @@ window.submitTaskForm = async (event, projectId) => {
const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」");
closeTaskDrawer(projectId);
await load();
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
}
} catch (error) {
toast("保存失败:" + error.message, "error");

View File

@@ -32,7 +32,8 @@ async function ensureStandardProposals() {
});
} catch (e) { /* ignore */ }
}
await load();
state.data.proposals = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant));
renderProposals();
}
window.switchProposalTab = (tab) => {
@@ -183,7 +184,7 @@ window.submitStandardComment = async (event, targetId) => {
button.disabled = true;
button.textContent = "发送中…";
await api(`/api/followups/proposal/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
await load();
try { state.data.proposals = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant)); renderProposals(); } catch(e) {}
openStandardProposalDrawer(targetId);
};
@@ -207,7 +208,7 @@ window.submitProposal = async (event) => {
if (result.id && data.customer_or_project_name) logActivity("proposal", result.id, "创建了方案「" + data.customer_or_project_name + "」");
form.reset();
closeProposalModal();
await load();
try { state.data.proposals = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant)); renderProposals(); } catch(e) {}
} catch (error) {
toast("保存失败:" + error.message, "error");
}

View File

@@ -3,15 +3,18 @@
const state = {
active: "home",
data: null,
tenant: "科普·无界",
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
opFilter: "all",
finFilter: "已签约",
finFilter: "projects",
selectedProject: null,
taskQuery: "",
taskView: localStorage.getItem("opc-task-view") || "detail",
finView: "overview",
finSort: null, // { key: 'col', asc: true }
planFilter: "已签约",
planFilter: "projects",
planYear: new Date().getFullYear(),
finYear: new Date().getFullYear(),
planStatusFilter: "all",
planView: "overview",
planSort: null,
proposalTab: "standard",
@@ -27,7 +30,7 @@ const state = {
expenseQuarter: "",
};
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
const text = (value) => value === undefined || value === null || value === "" ? "—" : esc(value);
function escapeHtml(str) { return String(str || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
@@ -115,18 +118,141 @@ function renderTable(headers, rows, rowClicks) {
`);
}
async function load() {
state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
// 首次加载时确保标准资料库 7 项已初始化
if (typeof ensureStandardProposals === "function") {
const existing = (state.data.proposals || []).filter(p => p.proposal_type === "标准资料");
const missingCount = 7 - existing.length;
if (missingCount > 0) {
await ensureStandardProposals();
return; // ensureStandardProposals 内部会再次 render
}
var _loadingSteps = [
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return state.tenant !== '总览'; } },
{ fn: 'renderProjects', label: '正在加载业务机会列表' },
{ fn: 'renderProposals', label: '正在加载业务方案列表' },
{ fn: 'renderProducts', label: '正在加载产品迭代列表' },
{ fn: 'renderPerformance', label: '正在加载绩效管理' },
{ fn: 'renderFinance', label: '正在加载已发生模块(表格+总览+图表)' },
{ fn: 'renderPlan', label: '正在加载预算模块' },
];
function showLoadingOverlay(steps) {
var existing = document.getElementById('_loadingOverlay');
if (existing) existing.remove();
var stepsHtml = steps.map(function(s, i) {
return '<div id="_loadingStep_' + i + '" class="flex items-center gap-3 py-1.5 text-sm ' + (i === 0 ? 'text-blue-600 font-medium' : 'text-slate-400') + '">' +
'<span class="w-4 h-4 flex items-center justify-center">' + (i === 0 ? '<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>' : '<span class="w-2 h-2 rounded-full bg-slate-300"></span>') + '</span>' +
'<span>' + s.label + '</span>' +
'</div>';
}).join('');
var overlay = document.createElement('div');
overlay.id = '_loadingOverlay';
overlay.className = 'fixed inset-0 z-[99999] flex items-center justify-center bg-black/50';
overlay.innerHTML = '<div class="bg-white rounded-2xl shadow-2xl px-8 py-6 w-full max-w-md">' +
'<h3 class="text-base font-bold text-slate-800 mb-1">正在加载工作台</h3>' +
'<p class="text-xs text-slate-400 mb-4">共 ' + steps.length + ' 个模块,请稍候…</p>' +
'<div id="_loadingSteps">' + stepsHtml + '</div>' +
'<div class="mt-4 h-1 bg-slate-100 rounded-full overflow-hidden"><div id="_loadingProgress" class="h-full bg-blue-600 transition-all duration-300" style="width:0%"></div></div>' +
'</div>';
document.body.appendChild(overlay);
}
function updateLoadingStep(idx, total) {
var stepEl = document.getElementById('_loadingStep_' + idx);
if (stepEl) {
stepEl.className = 'flex items-center gap-3 py-1.5 text-sm text-green-600 font-medium';
stepEl.querySelector('span:first-child').innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
}
render();
var nextEl = document.getElementById('_loadingStep_' + (idx + 1));
if (nextEl) {
nextEl.className = 'flex items-center gap-3 py-1.5 text-sm text-blue-600 font-medium';
nextEl.querySelector('span:first-child').innerHTML = '<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>';
}
var progress = document.getElementById('_loadingProgress');
if (progress) progress.style.width = Math.round((idx + 1) / total * 100) + '%';
}
function hideLoadingOverlay() {
var overlay = document.getElementById('_loadingOverlay');
if (overlay) overlay.remove();
}
async function load() {
var allSteps = [{ label: '正在加载工作台数据查询10+张表+计算财务汇总)' }].concat(_loadingSteps);
showLoadingOverlay(allSteps);
state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
// 初始化空数据,按需加载
if (!state.data.sales) state.data.sales = [];
if (!state.data.proposals) state.data.proposals = [];
if (!state.data.operations) state.data.operations = [];
if (!state.data.products) state.data.products = [];
if (!state.data.finance) state.data.finance = [];
if (!state.data.tasks) state.data.tasks = [];
if (!state.data.projectFinances) state.data.projectFinances = [];
if (!state.data.expense) state.data.expense = [];
if (!state.data.planFinances) state.data.planFinances = [];
if (!state.data.planExpense) state.data.planExpense = [];
clearTabCache();
updateLoadingStep(0, allSteps.length);
// 按需加载当前 tab 的数据(必须在 ensureStandardProposals 之前,否则会被 return 跳过)
await ensureTabData(state.active);
// 首次加载时确保标准资料库 7 项已初始化(仅首次,避免每次切换工作台都触发)
if (!_proposalsInitialized && typeof ensureStandardProposals === "function") {
_proposalsInitialized = true;
try {
const proposalsData = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant));
state.data.proposals = proposalsData;
const existing = (proposalsData || []).filter(p => p.proposal_type === "标准资料");
const missingCount = 7 - existing.length;
if (missingCount > 0) {
hideLoadingOverlay();
await ensureStandardProposals();
return;
}
} catch(e) { /* non-critical */ }
}
// 逐步渲染
for (var i = 0; i < _loadingSteps.length; i++) {
var step = _loadingSteps[i];
if (step.skip && step.skip()) {
updateLoadingStep(i + 1, allSteps.length);
continue;
}
if (typeof window[step.fn] === 'function') {
window[step.fn]();
}
updateLoadingStep(i + 1, allSteps.length);
await new Promise(function(r) { setTimeout(r, 50); });
}
if (window.lucide) window.lucide.createIcons();
setTimeout(hideLoadingOverlay, 300);
}
var _tabResourceMap = {
projects: 'operations',
proposals: 'proposals',
products: 'products',
finance: 'projectFinances',
plan: 'planFinances',
};
var _tabLoaded = {};
var _proposalsInitialized = false;
async function ensureTabData(tab) {
if (tab === 'home' || tab === 'performance') return;
var resource = _tabResourceMap[tab];
if (!resource) return;
var dataKey = resource;
if (_tabLoaded[dataKey + '_' + state.tenant]) return;
_tabLoaded[dataKey + '_' + state.tenant] = true;
try {
state.data[dataKey] = await api("/api/" + resource + "/list?tenant=" + encodeURIComponent(state.tenant));
} catch(e) { console.error('ensureTabData failed:', resource, e.message); }
// Load related expense data for finance/plan tabs
if (tab === 'finance') {
try { state.data.expense = await api("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
try { state.data.finance = await api("/api/finance/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
} else if (tab === 'plan') {
try { state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
}
}
function clearTabCache() {
_tabLoaded = {};
}
function switchTab(tab) {
@@ -136,28 +262,32 @@ function switchTab(tab) {
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
// 更新顶部标题
const titles = { home: 'OPC 工作台', finance: '业务管理', plan: '计划管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核' };
const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核' };
const titleEl = document.querySelector('#workspaceTitle');
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
render();
// 按需加载 tab 数据
ensureTabData(tab).then(function() {
renderActive();
if (window.lucide) window.lucide.createIcons();
});
}
function updateSidebarTabs() {
const isOverview = state.tenant === "总工作台";
const isOverview = state.tenant === "总";
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
const tab = btn.dataset.tab;
if (isOverview && tab !== "home") {
btn.style.display = "none";
if (isOverview) {
btn.style.display = (tab === "home" || tab === "plan" || tab === "finance") ? "" : "none";
} else {
btn.style.display = "";
btn.style.display = tab === "home" ? "none" : "";
}
});
}
function render() {
if (!state.data) return;
renderHome();
if (state.tenant === '总览') renderHome();
renderProjects();
renderProposals();
renderProducts();
@@ -204,6 +334,17 @@ window.setFinView = (view) => {
state.finView = view;
renderFinance();
};
window.setFinQuarter = (q) => {
if (state.finView === 'quarterly' && state.finQuarter === q) { state.finView = 'overview'; }
else { state.finQuarter = q; state.finView = 'quarterly'; }
renderFinance();
};
window.setFinMonth = (month) => {
if (state.finView === 'monthly' && state.finMonth === month) { state.finView = 'overview'; }
else { state.finMonth = month; state.finView = 'monthly'; }
renderFinance();
};
window.setFinYear = (year) => { state.finYear = parseInt(year); renderFinance(); };
window.switchFinFilter = (filter) => {
state.finFilter = filter;
state.finSort = null;
@@ -220,6 +361,18 @@ window.setPlanView = (view) => {
state.planView = view;
renderPlan();
};
window.setPlanQuarter = (q) => {
if (state.planView === 'quarterly' && state.planQuarter === q) { state.planView = 'overview'; }
else { state.planQuarter = q; state.planView = 'quarterly'; }
renderPlan();
};
window.setPlanMonth = (month) => {
if (state.planView === 'monthly' && state.planMonth === month) { state.planView = 'overview'; }
else { state.planMonth = month; state.planView = 'monthly'; }
renderPlan();
};
window.setPlanYear = (year) => { state.planYear = parseInt(year); renderPlan(); };
window.setPlanStatusFilter = (filter) => { state.planStatusFilter = filter; renderPlan(); };
window.switchPlanFilter = (filter) => {
state.planFilter = filter;
state.planSort = null;
@@ -231,17 +384,26 @@ window.setPlanSort = (key) => {
else { state.planSort.key = key; state.planSort.asc = true; }
renderPlan();
};
window.switchTenant = (tenant) => {
window.switchTenant = async (tenant) => {
state.tenant = tenant;
state.selectedProject = null;
localStorage.setItem("opc-active-tenant", tenant);
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
const label = document.querySelector("#currentTenantLabel");
if (label) { label.textContent = tenant.replace("·无界", "") || "工作台"; label.title = tenant; }
if (typeof updateTenantLabel === "function") updateTenantLabel();
updateSidebarTabs();
if (tenant === "总工作台") switchTab("home");
load();
if (tenant === "总") { state.planFilter = 'overview'; state.finFilter = 'overview'; switchTab("home"); }
else if (state.active === 'home') { state.active = 'plan'; localStorage.setItem("opc-active-tab", "plan"); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan")); }
await load();
};
// 轻量刷新辅助函数
async function refreshResource(resource, renderFn) {
try {
var dataKey = resource;
state.data[dataKey] = await api("/api/" + resource + "/list?tenant=" + encodeURIComponent(state.tenant));
if (renderFn && typeof window[renderFn] === 'function') window[renderFn]();
} catch(e) { /* non-critical */ }
}
window.doLogout = async () => {
await api("/api/auth/logout", { method: "POST" });
location.href = "/login";

View File

@@ -96,26 +96,26 @@ body {
}
/* 详情弹窗固定高度tab 切换不抖动 */
#financeModal > div {
#financeModal > div, #planModal > div {
height: 650px;
display: flex;
flex-direction: column;
}
#financeModal .finance-tabs { flex-shrink: 0; }
#financeModal form {
#financeModal .finance-tabs, #planModal .finance-tabs { flex-shrink: 0; }
#financeModal form, #planModal form {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
#financeModal .finance-tab-body {
#financeModal .finance-tab-body, #planModal .finance-tab-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 32px;
}
#financeModal .finance-form-actions {
#financeModal .finance-form-actions, #planModal .finance-form-actions {
flex-shrink: 0;
background: #fff;
padding: 12px 32px 8px;

View File

@@ -35,12 +35,8 @@
</div>
<!-- 分隔线 -->
<div class="w-8 h-px bg-slate-700 my-2"></div>
<!-- 工作台切换按钮 -->
<div class="flex flex-col items-center cursor-pointer hover:bg-slate-800 rounded-lg py-2 px-1 w-14 transition-colors" onclick="toggleTenantMenu(event)">
<i data-lucide="layout-grid" style="width:18px;height:18px;color:#94a3b8"></i>
<span class="text-[10px] text-slate-400 mt-1 max-w-[56px] truncate text-center" id="currentTenantLabel">工作台</span>
<i data-lucide="chevron-down" style="width:12px;height:12px;color:#64748b;margin-top:2px"></i>
</div>
<!-- 工作台列表(竖向平铺) -->
<div class="flex flex-col items-center gap-1 w-14" id="tenantList"></div>
<!-- 分隔线 -->
<div class="w-8 h-px bg-slate-700 my-2"></div>
<!-- 导航 Tab 图标 -->
@@ -49,13 +45,13 @@
<i data-lucide="trending-up" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">部门</span>
</div>
<div class="sidebar-tab" data-tab="plan" onclick="switchTab('plan')" title="计划">
<div class="sidebar-tab" data-tab="plan" onclick="switchTab('plan')" title="预算">
<i data-lucide="calendar-range" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">计划</span>
<span class="text-[10px] mt-1">预算</span>
</div>
<div class="sidebar-tab" data-tab="finance" onclick="switchTab('finance')" title="业务">
<div class="sidebar-tab" data-tab="finance" onclick="switchTab('finance')" title="已发生">
<i data-lucide="folder-open-dot" style="width:20px;height:20px"></i>
<span class="text-[10px] mt-1">业务</span>
<span class="text-[10px] mt-1">已发生</span>
</div>
<div class="sidebar-tab" data-tab="projects" onclick="switchTab('projects')" title="Focus">
<i data-lucide="file-text" style="width:20px;height:20px"></i>
@@ -80,7 +76,7 @@
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
<div class="flex items-center gap-3 w-full">
<div class="flex-1">
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.0.0.17</span></p>
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.20</span></p>
<div class="flex items-center gap-4 mt-1">
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100 rounded-full px-4 py-1.5">