Compare commits

...

115 Commits
v3.3.2 ... main

Author SHA1 Message Date
mac
21b5c40626 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-15 14:49:18 +08:00
mac
ec457e9d40 feat: 删除部门模块 + 任务系统重构 + 布局修复
- 删除部门(Home)侧边栏入口、面板及相关代码
- 删除 home.js,chartOptions 等图表工具迁移到 utils.js
- 清理 render()、togglePhase、filterPhaseTasks 等死代码
- 任务列表改为平铺(取消阶段分组),支持拖拽排序
- 任务表单加开始时间字段,重组字段布局为三列
- 任务展开面板改为竖排:时间、任务进展、卡点与备注
- 视图按钮和新增任务按钮移入任务卡片顶部
- 修复 index.html div 嵌套不平衡导致布局塌陷
- 修复 savedTab 校验和 switchTenant 侧边栏 active 同步
- 总览工作台只显示预算/发生总览入口
- 性能面板 id 去重
2026-07-15 14:49:12 +08:00
mac
3930451ce8 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-15 10:03:12 +08:00
mac
39513c0a7b feat: 版本号自动同步 + 五色标记 + 财务指标颜色优化
- 左上角版本号从 git tag 自动读取(flask_app.py → Jinja2 注入)
- 项目管理右键菜单新增标黄/蓝/绿/紫四种颜色标记 + 取消标记
- 应收/应付列正数绿负数红(finance+plan 双模块)
- 经营月报利润行负数标红
- 预算模块移除状态筛选标签,添加年份选择器
- 年份下拉框 pr-6 修复箭头重叠
2026-07-15 10:03:12 +08:00
mac
e2a421cde7 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-14 15:25:38 +08:00
mac
d2d1b0f99e feat: 经营报表年份支持 + 项目管理年份过滤修复
- 经营月报/季报增加年份选择入口,数据按选中年份过滤
- 年份下拉框扩展至 2020-2027,增加「所有年份」选项
- 项目管理视图全年/季度/月度均按年份过滤项目列表
- 修复月度视图在所有年份模式下数据查询(substring→slice)
- 修复 finView/finFilter 初始化,确保全年标签默认选中
- 修复 yearly API 缺失 tenant 参数导致跨工作台数据错误
- 费用数据(expense)遍历加年份过滤,季报指标改为从 fm 累加
2026-07-14 15:25:35 +08:00
mac
aa3ec75179 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-13 16:34:57 +08:00
mac
4ad19d438f Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-13 14:57:49 +08:00
mac
30b1a50490 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-13 13:47:56 +08:00
mac
01718e9acc Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-13 13:41:48 +08:00
mac
87f0abbbd8 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-13 09:15:18 +08:00
mac
c2ed20caeb Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-12 21:48:12 +08:00
mac
a3f269046d Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-12 17:23:28 +08:00
mac
ec062fb2ec Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 14s
2026-07-12 17:13:06 +08:00
mac
c4af452115 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-12 17:05:03 +08:00
mac
8c318fb3ad Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 15s
2026-07-12 16:47:25 +08:00
mac
9c2beee51e Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-11 15:43:43 +08:00
mac
6a582c65b8 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-11 15:39:35 +08:00
mac
3fff80425b Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-11 15:27:12 +08:00
mac
02abe41793 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-11 15:23:16 +08:00
mac
f30d40fe70 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-11 15:10:16 +08:00
mac
eb300731a3 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-11 15:06:23 +08:00
mac
67bc00314c Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-11 14:54:28 +08:00
mac
d34e52eff4 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 17s
2026-07-11 14:36:00 +08:00
mac
8e38f50c99 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-11 12:31:50 +08:00
mac
3a40e78c3e Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-11 12:26:29 +08:00
mac
a1a4f41d79 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-11 11:57:42 +08:00
mac
d4889d56ec Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 17s
2026-07-11 11:50:18 +08:00
mac
35706f123d Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-11 11:44:05 +08:00
mac
e4d1cd59f3 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-10 23:47:54 +08:00
mac
1673603b02 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-10 23:22:12 +08:00
mac
db93eb8d31 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-10 23:02:48 +08:00
mac
94ad7aed55 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 16s
2026-07-10 22:32:27 +08:00
mac
043938670a Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-10 21:43:19 +08:00
mac
9862d9fe56 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-10 21:19:53 +08:00
mac
150e627811 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-10 20:14:53 +08:00
mac
295bf5a81c Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-10 20:06:03 +08:00
mac
e8a26059d5 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-10 19:52:00 +08:00
mac
543d620384 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-10 19:49:11 +08:00
mac
975034c589 feat: 新增测试工作台+tip样式调整+tip/tab位置对调
- 新增测试·无界工作台(所有人可见)
- 为所有opc_owner用户添加测试工作台权限
- 预算模块tip改淡黄色+新文案
- 预算/已发生模块tip和tab位置对调(tip在上)
- 版本号 v1.2.0.12
2026-07-10 19:49:11 +08:00
mac
71e90bd55f Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 17s
2026-07-10 19:32:00 +08:00
mac
3042791a90 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-10 19:11:32 +08:00
mac
a8243acc5a Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 21s
2026-07-10 18:22:54 +08:00
mac
cb0e3fdac2 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-10 17:33:05 +08:00
mac
f5c92d09af Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-10 13:38:11 +08:00
mac
e18626105b Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 16s
2026-07-10 13:27:22 +08:00
mac
b587530437 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-10 13:12:42 +08:00
mac
9a1260db68 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-10 13:10:04 +08:00
mac
7c33bc05f2 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-09 18:18:03 +08:00
mac
9de0a63be2 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-09 17:41:01 +08:00
mac
f3c94c57d6 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-09 16:52:35 +08:00
mac
9db627770b Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 16s
2026-07-09 16:33:49 +08:00
mac
75b988c77a Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-09 16:20:21 +08:00
mac
2b7f67436e Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-09 16:03:30 +08:00
mac
ed5455f309 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 20:48:52 +08:00
mac
452d410843 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 18:55:27 +08:00
mac
c95373b85e Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 20s
2026-07-08 17:45:48 +08:00
mac
6be10481d5 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 14s
2026-07-08 17:04:47 +08:00
mac
4ce0e66d90 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-08 16:49:11 +08:00
mac
0f9e1b6cf2 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 16:29:30 +08:00
mac
dc8d3d59d2 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-08 16:14:51 +08:00
mac
1e5c5b5706 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 16:05:45 +08:00
mac
b68144ebb0 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 15:52:45 +08:00
mac
3cae1d85cf Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 15s
2026-07-08 15:11:26 +08:00
mac
ca0cd9eab1 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 14:50:14 +08:00
mac
2bdc0e0779 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-08 14:16:31 +08:00
mac
93dca56c3f Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 13:46:10 +08:00
mac
8c4f50fc52 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-08 13:21:54 +08:00
mac
13864f1968 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-08 13:04:51 +08:00
mac
d11e8f9313 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 13:00:17 +08:00
mac
02a12a208c Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 14s
2026-07-08 12:23:32 +08:00
mac
b977627895 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-08 12:22:02 +08:00
mac
edf8e6179c Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-08 12:13:07 +08:00
mac
3148af05b3 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-08 11:42:07 +08:00
mac
4029933ada Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-08 11:34:00 +08:00
mac
95d3f9809c Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-07 19:22:08 +08:00
mac
8b040489e2 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-07 19:19:46 +08:00
mac
db9700c8ee Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-07 19:14:07 +08:00
mac
11e30f33b9 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-07 19:11:42 +08:00
mac
95f52b61d0 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-07 19:05:20 +08:00
mac
a4012f2189 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-07 19:03:00 +08:00
mac
e1c5e65c1e Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-07 19:00:53 +08:00
mac
a3d78b3ca0 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-07 18:44:10 +08:00
mac
9dc5631574 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-07 18:39:11 +08:00
mac
c5a59508d2 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-07 18:34:47 +08:00
mac
ab1d57492c Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-07 18:01:04 +08:00
mac
9a81a30feb Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 18s
2026-07-07 17:02:47 +08:00
mac
d6d21b03dd Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-07 15:56:13 +08:00
mac
b9a05e8732 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-07 13:15:54 +08:00
mac
440707e19a Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-07 12:56:27 +08:00
mac
e0dc1a56b2 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-06 21:42:24 +08:00
mac
f45afd954d Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-06 21:34:48 +08:00
mac
664da22329 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-06 20:58:19 +08:00
mac
73a6ac126c Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-06 19:34:24 +08:00
mac
878e26d4ca Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-06 19:19:41 +08:00
mac
e12ed0936b Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-06 19:10:18 +08:00
mac
8edb74f173 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-06 18:20:38 +08:00
mac
1a42c6e6f3 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-06 17:31:40 +08:00
mac
d21497bce3 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-06 15:39:56 +08:00
mac
574e3c5700 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-06 13:05:01 +08:00
mac
c70293b447 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-03 19:06:57 +08:00
mac
adeff08827 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-02 20:10:45 +08:00
mac
8bd40de41d Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 13s
2026-07-02 18:30:24 +08:00
mac
ac6eacea82 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-02 17:55:40 +08:00
mac
83c2a5ca94 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-02 14:50:45 +08:00
mac
fbd2290d29 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-07-02 14:31:06 +08:00
mac
97fcf88c61 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-06-29 17:02:55 +08:00
mac
a01afef599 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-06-29 16:52:37 +08:00
mac
28fa244fe5 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-06-29 16:45:21 +08:00
mac
ad3885e0be Merge branch 'dev'
Some checks failed
Deploy / deploy (push) Failing after 23s
2026-06-29 16:02:10 +08:00
mac
0fb7ee2992 Merge branch 'dev'
Some checks failed
Deploy / deploy (push) Failing after 14m52s
2026-06-26 12:21:02 +08:00
mac
f6792cad39 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 10s
2026-06-24 12:42:56 +08:00
mac
bed6e9192a Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 11s
2026-06-23 23:12:03 +08:00
mac
aaa213a765 fix(deploy): 修复 data 目录不存在导致 ln 软链失败
All checks were successful
Deploy / deploy (push) Successful in 10s
rsync 排除了 data/uploads 但没保留 data/ 空目录,
导致 ln -sfn shared/uploads data/uploads 时父目录不存在而失败。
在 ln 之前加 mkdir -p 确保目录存在。
2026-06-23 23:07:25 +08:00
Deploy Test
207629a9bb test: trigger workflow debug
Some checks failed
Deploy / deploy (push) Failing after 0s
2026-06-23 23:03:22 +08:00
13 changed files with 462 additions and 453 deletions

View File

@@ -53,3 +53,4 @@ curl http://127.0.0.1:5177/api/health
- 产品:慰心斋产品路线图中的 5 个产品版本 - 产品:慰心斋产品路线图中的 5 个产品版本
- 财务:首版财务样例和原财务 manager 合并方向 - 财务:首版财务样例和原财务 manager 合并方向
# test trigger

View File

@@ -28,6 +28,19 @@ if not _secret_key:
) )
app.secret_key = _secret_key app.secret_key = _secret_key
app.config["TEMPLATES_AUTO_RELOAD"] = True app.config["TEMPLATES_AUTO_RELOAD"] = True
# 自动读取 git tag 作为版本号,保持左上角与发布 tag 一致
import subprocess as _sp
def _git_version():
try:
tags = _sp.check_output(
["git", "tag", "--sort=-v:refname"],
cwd=_backend_dir, stderr=_sp.DEVNULL,
).decode().strip().split("\n")
return tags[0] if tags and tags[0] else "dev"
except Exception:
return "dev"
app.jinja_env.globals["app_version"] = _git_version()
app.register_blueprint(bp) app.register_blueprint(bp)
# 模块级执行迁移(保留 gunicorn --preload 行为:导入即触发) # 模块级执行迁移(保留 gunicorn --preload 行为:导入即触发)

View File

@@ -61,8 +61,8 @@ def attach_common(conn, resource, items):
return items return items
def monthly_finance(conn, tenant="科普·无界"): def monthly_finance(conn, tenant="科普·无界", year=None):
_year = date.today().year _year = year or date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)] months = [f"{_year}-{m:02d}" for m in range(1, 13)]
pfs = rows(conn, pfs = rows(conn,
"SELECT sign_amount, sign_month, budget_data, expense_data FROM project_finances WHERE tenant=?", "SELECT sign_amount, sign_month, budget_data, expense_data FROM project_finances WHERE tenant=?",

View File

@@ -489,6 +489,75 @@ def bootstrap():
conn.close() conn.close()
@bp.route("/api/finance/yearly")
@login_required
def finance_yearly():
"""按年份返回经营月报/季报所需的 12 月财务数据支持历史年份切换。year=0 表示所有年份"""
_year = request.args.get("year", type=int)
if _year is None:
_year = date.today().year
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:
if _year == 0:
# 所有年份:查出所有有数据的年份,合并 12 个月
yr_rows = rows(conn,
"SELECT DISTINCT SUBSTRING(sign_month,1,4) AS y FROM project_finances WHERE tenant=? AND sign_month IS NOT NULL AND sign_month!='' ORDER BY y",
[tenant])
all_years = [int(r["y"]) for r in yr_rows if r["y"] and r["y"].isdigit()]
if not all_years:
all_years = [date.today().year]
merged = [{"month": f"所有-{m+1:02d}", "revenue": 0, "gross": 0, "payment": 0, "cost": 0, "paid": 0, "sign": 0}
for m in range(12)]
for y in all_years:
fm = monthly_finance(conn, tenant, y)
for i, m in enumerate(fm):
for k in ("revenue", "gross", "payment", "cost", "paid", "sign"):
merged[i][k] = round(merged[i][k] + (m[k] or 0), 2)
return jsonify({"year": 0, "financeMonthly": merged})
return jsonify({"year": _year, "financeMonthly": monthly_finance(conn, tenant, _year)})
finally:
conn.close()
@bp.route("/api/finance/copy-to-plan/<int:source_id>", methods=["POST"])
@login_required
def copy_finance_to_plan(source_id):
"""将已发生模块的项目复制到预算模块"""
table, cols = TABLES["planFinances"]
conn = db()
try:
src = one(conn, "SELECT * FROM project_finances WHERE id=?", [source_id])
if not src:
return jsonify({"error": "源项目不存在"}), 404
# 构建目标数据customer_name 前加"副本-"
data = {}
for col in cols:
if col == "customer_name":
data[col] = "副本-" + (src.get("customer_name") or "")
elif col == "project_type":
data[col] = "待签约"
elif col == "weight":
data[col] = "20%"
elif col == "created_at" or col == "updated_at":
data[col] = ""
else:
data[col] = src.get(col, "")
values = [data.get(col, "") for col in cols]
cur = _exec(conn,
f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})",
values)
conn.commit()
return jsonify({"ok": True, "id": cur.lastrowid})
finally:
conn.close()
# ---------- 通用 CRUD ---------- # ---------- 通用 CRUD ----------
@bp.route("/api/<resource>/list", methods=["GET"]) @bp.route("/api/<resource>/list", methods=["GET"])

View File

@@ -6,8 +6,11 @@ applyUserTenants().then(() => {
return load(); return load();
}).then(() => { }).then(() => {
const savedTab = localStorage.getItem("opc-active-tab"); const savedTab = localStorage.getItem("opc-active-tab");
if (savedTab && savedTab !== "home") { const validTabs = ["plan", "finance", "projects", "proposals", "products", "performance", "overview_plan", "overview_finance"];
if (savedTab && validTabs.includes(savedTab) && document.getElementById(savedTab)) {
switchTab(savedTab); switchTab(savedTab);
} else {
switchTab(state.active);
} }
}).catch((error) => { }).catch((error) => {
document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${esc(error.message)}</section>`; document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${esc(error.message)}</section>`;

View File

@@ -98,22 +98,28 @@ function renderFinance() {
})(); })();
const sm = pf.sign_month || ""; const sm = pf.sign_month || "";
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-brand-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`; const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-brand-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`; return `<tr class="border-b ${fmMarkBg(pf.id) || 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
}; };
const now2 = new Date(); const now2 = new Date();
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0"); const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
if (!state.finMonth) state.finMonth = defaultMonth2; if (!state.finMonth) state.finMonth = defaultMonth2;
if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3); if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3);
if (!state.finYear) state.finYear = now2.getFullYear(); if (state.finYear == null) state.finYear = now2.getFullYear();
if (!state.finView) state.finView = 'annual'; if (state.finMonthlyYear === undefined) state.finMonthlyYear = state.finYear;
if (!['annual','quarterly','monthly'].includes(state.finView)) state.finView = 'annual';
if (!['overview','quarterlyOverview','projects','expense'].includes(state.finFilter)) state.finFilter = 'projects';
if (state.finView === 'monthly' && state.finMonth) {
const monthSuffix = state.finMonth.slice(-2);
state.finMonth = state.finYear + '-' + monthSuffix;
}
const qLabels = ['Q1','Q2','Q3','Q4']; const qLabels = ['Q1','Q2','Q3','Q4'];
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]]; const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
const nowD = new Date(); const nowD = new Date();
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3); const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
const activeMonths = qMonths[activeQ]; const activeMonths = qMonths[activeQ];
const yearOpts = [2024,2025,2026,2027]; const yearOpts = [2020,2021,2022,2023,2024,2025,2026,2027];
const finHeaderBase = `<div class="flex items-center gap-2"><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><button onclick="setFinView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='annual'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全年</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-brand-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-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`; const finHeaderBase = `<div class="flex items-center gap-2"><span class="text-sm text-slate-500">年份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white">${`<option value="0" ${state.finYear===0?'selected':''}>所有年份</option>`}${yearOpts.map(y => `<option value="${y}" ${state.finYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><button onclick="setFinView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='annual'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全年</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-brand-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-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`; const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
const isOverviewMode = false; const isOverviewMode = false;
@@ -242,13 +248,21 @@ function renderFinance() {
</div> </div>
</div><div class="flex justify-end gap-3 pt-2 finance-form-actions"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()"></button><button type="submit" class="btn btn-primary btn-sm px-8"></button></div></form></div></div> </div><div class="flex justify-end gap-3 pt-2 finance-form-actions"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()"></button><button type="submit" class="btn btn-primary btn-sm px-8"></button></div></form></div></div>
${(() => { ${(() => {
if (state.finFilter === 'overview') { if (state.finFilter === 'overview' || state.finFilter === 'quarterlyOverview') {
setTimeout(() => renderFinanceOverview(), 10); const isQ = state.finFilter === 'quarterlyOverview';
return `<div id="financeOverview"></div>`; const renderFn = isQ ? renderFinanceQuarterlyOverview : renderFinanceOverview;
} const containerId = isQ ? 'financeQuarterlyOverview' : 'financeOverview';
if (state.finFilter === 'quarterlyOverview') { if (state.finMonthlyYear !== state.finYear) {
setTimeout(() => renderFinanceQuarterlyOverview(), 10); const y = state.finYear;
return `<div id="financeQuarterlyOverview"></div>`; fetch(`/api/finance/yearly?year=${y}&tenant=${encodeURIComponent(state.tenant)}`).then(r => r.json()).then(d => {
if (d && d.financeMonthly) { state.data.financeMonthly = d.financeMonthly; state.finMonthlyYear = d.year; }
else { state.data.financeMonthly = []; state.finMonthlyYear = y; }
renderFn();
}).catch(() => { state.finMonthlyYear = y; renderFn(); });
} else {
setTimeout(renderFn, 10);
}
return `<div id="${containerId}"></div>`;
} }
if (state.finFilter === 'expense') { if (state.finFilter === 'expense') {
setTimeout(() => renderExpense(), 10); setTimeout(() => renderExpense(), 10);
@@ -335,13 +349,13 @@ function renderFinance() {
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>'; const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>'; const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
const payable = cost - paid; const payable = cost - paid;
const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-700'; const payableCls = payable > 0 ? 'text-green-600' : payable < 0 ? 'text-red-600' : 'text-slate-700';
const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>'; const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>';
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>'; const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'; const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600'; const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const pending = rev - payment; const pending = rev - payment;
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700'; const pendingCls = pending > 0 ? 'text-green-600' : pending < 0 ? 'text-red-600' : 'text-slate-700';
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>'; const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
var budgetArr = []; var budgetArr = [];
var expenseArr = []; var expenseArr = [];
@@ -365,11 +379,11 @@ function renderFinance() {
var bCf = bPay - ePaid; var bCf = bPay - ePaid;
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-600">' + m + '</td><td class="px-2 py-px text-center text-xs text-brand-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>'; 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-600">' + m + '</td><td class="px-2 py-px text-center text-xs text-brand-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
}).join(''); }).join('');
return `<tr class="border-b ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" 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-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-brand-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 font-semibold ${pendingCls}">${pendingVal}</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 font-semibold ${payableCls}">${payableVal}</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></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-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`; return `<tr class="border-b ${fmMarkBg(pf.id) || 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" 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-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-brand-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 font-semibold ${pendingCls}">${pendingVal}</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 font-semibold ${payableCls}">${payableVal}</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></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-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
}; };
// 待签约简版行 // 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => { const tdRowUnsigned = (pf, cost, gross) => {
return `<tr class="border-b ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" 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>`; return `<tr class="border-b ${fmMarkBg(pf.id) || 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" 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') { if (state.finView === 'monthly') {
@@ -378,17 +392,26 @@ function renderFinance() {
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0"); const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
if (!state.finMonth) state.finMonth = defaultMonth; if (!state.finMonth) state.finMonth = defaultMonth;
const selMonth = state.finMonth; const selMonth = state.finMonth;
const isAllMonth = selMonth.startsWith('0-');
const monthSuffix = selMonth.slice(-2);
var dataItems = []; var dataItems = [];
allPfs.forEach(pf => { allPfs.forEach(pf => {
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {} let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {} let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
const b = bd.find(x => (x.month || "") === selMonth) || {}; let rev = 0, payment = 0, gross = 0, cost = 0, paid = 0;
const e = ed.find(x => (x.month || "") === selMonth) || {}; if (isAllMonth) {
const rev = Math.round(parseFloat(b.rev || 0) || 0); bd.forEach(x => { if ((x.month||"").substring(5) === monthSuffix) { rev += parseFloat(x.rev||0)||0; gross += parseFloat(x.gross||0)||0; payment += parseFloat(x.payment||0)||0; } });
const payment = Math.round(parseFloat(b.payment || 0) || 0); ed.forEach(x => { if ((x.month||"").substring(5) === monthSuffix) { cost += parseFloat(x.cost||0)||0; paid += parseFloat(x.paid||0)||0; } });
const gross = Math.round(parseFloat(b.gross || 0) || 0); rev = Math.round(rev); payment = Math.round(payment); gross = Math.round(gross); cost = Math.round(cost); paid = Math.round(paid);
const cost = Math.round(parseFloat(e.cost || 0) || 0); } else {
const paid = Math.round(parseFloat(e.paid || 0) || 0); const b = bd.find(x => (x.month || "") === selMonth) || {};
const e = ed.find(x => (x.month || "") === selMonth) || {};
rev = Math.round(parseFloat(b.rev || 0) || 0);
payment = Math.round(parseFloat(b.payment || 0) || 0);
gross = Math.round(parseFloat(b.gross || 0) || 0);
cost = Math.round(parseFloat(e.cost || 0) || 0);
paid = Math.round(parseFloat(e.paid || 0) || 0);
}
if (!rev && !payment && !cost && !paid && !gross) return; if (!rev && !payment && !cost && !paid && !gross) return;
dataItems.push({ dataItems.push({
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '', pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
@@ -408,8 +431,12 @@ function renderFinance() {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross; sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross)); 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 signTotal = isAllMonth
const signCnt = allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).length; ? Math.round(allPfs.filter(x => { const sm = x.sign_month || ""; return sm.length >= 7 && sm.substring(5) === monthSuffix; }).reduce((a, p) => a + (p.sign_amount || 0), 0))
: Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = isAllMonth
? allPfs.filter(x => { const sm = x.sign_month || ""; return sm.length >= 7 && sm.substring(5) === monthSuffix; }).length
: allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据'); return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据');
} }
@@ -425,12 +452,12 @@ function renderFinance() {
const qRange = qRanges[selQ]; const qRange = qRanges[selQ];
const sumBudget = (pf, field) => { const sumBudget = (pf, field) => {
let total = 0; let total = 0;
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) {} 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 || state.finYear === 0)) total += parseFloat(b[field] || 0); }); } catch (e) {}
return total; return total;
}; };
const sumExpense = (pf, field) => { const sumExpense = (pf, field) => {
let total = 0; let total = 0;
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) {} 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 || state.finYear === 0)) total += parseFloat(e[field] || 0); }); } catch (e) {}
return total; return total;
}; };
var dataItems = []; var dataItems = [];
@@ -459,25 +486,52 @@ function renderFinance() {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross; sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross)); 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 signTotal = Math.round(allPfs.filter(x => {
const signCnt = allPfs.filter(x => qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length; const m = parseInt((x.sign_month || "0").substring(5)) || 0;
const qy = parseInt((x.sign_month || "0").substring(0, 4)) || 0;
return qRange.includes(m) && (qy === state.finYear || state.finYear === 0);
}).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(x => {
const m = parseInt((x.sign_month || "0").substring(5)) || 0;
const qy = parseInt((x.sign_month || "0").substring(0, 4)) || 0;
return qRange.includes(m) && (qy === state.finYear || state.finYear === 0);
}).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据'); return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据');
} }
// 总视图 // 总视图
const calcTotals = (pf) => { const calcTotals = (pf, year) => {
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {} let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
let expense = []; try { expense = JSON.parse(pf.expense_data || "[]"); } catch (e) {} let expense = []; try { expense = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
let rev = 0, payment = 0, gross = 0; let rev = 0, payment = 0, gross = 0;
budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; }); budget.forEach(b => {
if (year !== 0) {
const by = parseInt((b.month || "").substring(0, 4)) || 0;
if (by && by !== year) return;
}
rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0;
});
let cost = 0, paid = 0; let cost = 0, paid = 0;
expense.forEach(e => { cost += parseFloat(e.cost || 0) || 0; paid += parseFloat(e.paid || 0) || 0; }); expense.forEach(e => {
if (year !== 0) {
const ey = parseInt((e.month || "").substring(0, 4)) || 0;
if (ey && ey !== year) return;
}
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) }; return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
}; };
const allPfs = pfs; const allPfs = pfs;
var dataItems = allPfs.map(pf => { var dataItems = [];
var t = calcTotals(pf); allPfs.forEach(pf => {
return { var t = calcTotals(pf, state.finYear);
if (state.finYear !== 0) {
const sy = parseInt((pf.sign_month || "").substring(0, 4)) || 0;
const hasFinance = t.rev || t.payment || t.cost || t.paid || t.gross;
const hasSign = sy === state.finYear;
if (!hasFinance && !hasSign) return;
}
dataItems.push({
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '', pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
rev: t.rev, gross: t.gross, cost: t.cost, rev: t.rev, gross: t.gross, cost: t.cost,
payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid, payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid,
@@ -485,7 +539,7 @@ function renderFinance() {
gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0, gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
pay_rate: t.rev && t.payment ? Math.round(t.payment / t.rev * 100) : 0, pay_rate: t.rev && t.payment ? Math.round(t.payment / t.rev * 100) : 0,
paid_rate: t.cost && t.paid ? Math.round(t.paid / t.cost * 100) : 0, paid_rate: t.cost && t.paid ? Math.round(t.paid / t.cost * 100) : 0,
}; });
}); });
var sortK = state.finSort && state.finSort.key ? state.finSort.key.split('|')[1] : null; var sortK = state.finSort && state.finSort.key ? state.finSort.key.split('|')[1] : null;
dataItems = sortData(dataItems, sortK); dataItems = sortData(dataItems, sortK);
@@ -495,8 +549,16 @@ function renderFinance() {
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross; sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross)); 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 signTotal = Math.round(allPfs.filter(p => {
const signCnt = allPfs.length; if (state.finYear === 0) return true;
const sy = parseInt((p.sign_month || "").substring(0, 4)) || 0;
return sy === state.finYear;
}).reduce((a, p) => a + (p.sign_amount || 0), 0));
const signCnt = allPfs.filter(p => {
if (state.finYear === 0) return true;
const sy = parseInt((p.sign_month || "").substring(0, 4)) || 0;
return sy === state.finYear;
}).length;
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据'); return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据');
})()} })()}
</div>`; </div>`;
@@ -992,7 +1054,9 @@ function renderFinanceOverview() {
const fm = financeMonthly || []; const fm = financeMonthly || [];
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月']; const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流']; const LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)]; const annualSums = { sign:0, revenue:0, gross:0, cost:0, paid:0, payment:0 };
fm.forEach(x => { annualSums.sign += x.sign||0; annualSums.revenue += x.revenue||0; annualSums.gross += x.gross||0; annualSums.cost += x.cost||0; annualSums.paid += x.paid||0; annualSums.payment += x.payment||0; });
const annualVals = [annualSums.sign, annualSums.revenue, annualSums.gross, annualSums.cost, annualSums.paid, annualSums.payment, annualSums.payment - annualSums.paid];
const fmFields = ['sign','revenue','gross','cost','paid','payment',null]; 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>'; 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>'; } 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>'; }
@@ -1022,6 +1086,8 @@ function renderFinanceOverview() {
if ((finExp[ei].status || '') === '暂不计入') continue; if ((finExp[ei].status || '') === '暂不计入') continue;
var em = (finExp[ei].expense_month || '').substring(0, 7); var em = (finExp[ei].expense_month || '').substring(0, 7);
var mi2 = parseInt(em.substring(5)) - 1; var mi2 = parseInt(em.substring(5)) - 1;
var emYear = parseInt(em.substring(0, 4)) || 0;
if (state.finYear !== 0 && emYear && emYear !== state.finYear) continue;
if (mi2 >= 0 && mi2 < 12) expMonthly[mi2] += parseFloat(finExp[ei].amount || 0); 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 expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
@@ -1038,11 +1104,12 @@ function renderFinanceOverview() {
for (var dri = 0; dri < 3; dri++) { for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri]; var dr = deptRows[dri];
var rowCls = dri === 2 ? 'font-semibold' : ''; var rowCls = dri === 2 ? 'font-semibold' : '';
var valCls = dri === 2 ? 'text-green-600' : 'text-slate-700'; var valCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-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>'; 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>'; rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + valCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
for (var dmi = 0; dmi < 12; dmi++) { 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>'; var cellCls = dri === 2 ? (dr.vals[dmi] >= 0 ? 'text-green-600' : 'text-red-600') : valCls;
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
} }
deptTbody += rowHtml + '</tr>'; deptTbody += rowHtml + '</tr>';
} }
@@ -1057,6 +1124,8 @@ function renderFinanceOverview() {
if ((finExp[epi].status || '') === '暂不计入') continue; if ((finExp[epi].status || '') === '暂不计入') continue;
var epm = (finExp[epi].expense_month || '').substring(0, 7); var epm = (finExp[epi].expense_month || '').substring(0, 7);
var epmi = parseInt(epm.substring(5)) - 1; var epmi = parseInt(epm.substring(5)) - 1;
var epmYear = parseInt(epm.substring(0, 4)) || 0;
if (state.finYear !== 0 && epmYear && epmYear !== state.finYear) continue;
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(finExp[epi].incurred_amount || 0); 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 expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
@@ -1083,14 +1152,15 @@ function renderFinanceOverview() {
} }
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4'); var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><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">' + const yearSelectOv = `<div class="card px-4 py-2 mb-4"><div class="flex items-center gap-2"><span class="text-sm font-medium text-slate-500">📅 选择年份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.finYear===0?' selected':''}>所有年份</option>${[2020,2021,2022,2023,2024,2025,2026,2027].map(y => '<option value="'+y+'"'+(state.finYear===y?' selected':'')+'>'+y+'年</option>').join('')}</select></div></div>`;
document.querySelector("#financeOverview").innerHTML = yearSelectOv + '<div class="grid gap-2.5">' +
deptCard + deptCard +
cfCard + cfCard +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><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-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><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">' + '<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-600">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-600">' + (state.finYear===0 ? '所有年份' : state.finYear) + '</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-600">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-600">' + (state.finYear===0 ? '所有年份' : state.finYear) + '</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-600">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-600">' + (state.finYear===0 ? '所有年份' : state.finYear) + '</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
'</div></div>'; '</div></div>';
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
@@ -1111,7 +1181,9 @@ function renderFinanceQuarterlyOverview() {
// 财务概览: 7 rows // 财务概览: 7 rows
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流']; 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 annualSums = { sign:0, revenue:0, gross:0, cost:0, paid:0, payment:0 };
fm.forEach(x => { annualSums.sign += x.sign||0; annualSums.revenue += x.revenue||0; annualSums.gross += x.gross||0; annualSums.cost += x.cost||0; annualSums.paid += x.paid||0; annualSums.payment += x.payment||0; });
var annualVals = [annualSums.sign, annualSums.revenue, annualSums.gross, annualSums.cost, annualSums.paid, annualSums.payment, annualSums.payment - annualSums.paid];
var fmFields = ['sign','revenue','gross','cost','paid','payment',null]; 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]]; var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
for (var qi = 0; qi < 4; qi++) { for (var qi = 0; qi < 4; qi++) {
@@ -1156,11 +1228,13 @@ function renderFinanceQuarterlyOverview() {
if ((finExp[ei].status || '') === '暂不计入') continue; if ((finExp[ei].status || '') === '暂不计入') continue;
var em = (finExp[ei].expense_month || '').substring(0, 7); var em = (finExp[ei].expense_month || '').substring(0, 7);
var emi = parseInt(em.substring(5)) - 1; var emi = parseInt(em.substring(5)) - 1;
var emYear = parseInt(em.substring(0, 4)) || 0;
if (state.finYear !== 0 && emYear && emYear !== state.finYear) continue;
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expQ[eqi] += parseFloat(finExp[ei].amount || 0); } 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 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 grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
var grossAnnual = m.gross_annual || 0; var grossAnnual = 0; for (var ei = 0; ei < fm.length; 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>'; 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>'; if (qi > 0) deptThead += '<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>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>'; }
deptThead += '</tr>'; deptThead += '</tr>';
@@ -1186,12 +1260,14 @@ function renderFinanceQuarterlyOverview() {
// 业务线现金流: quarterly // 业务线现金流: quarterly
var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]]; var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]];
var cfAnnual = m.payment_annual || 0; var cfAnnual = 0; for (var ei = 0; ei < fm.length; ei++) cfAnnual += (fm[ei] ? fm[ei].payment : 0);
var expPaidQ = [0,0,0,0]; var expPaidQ = [0,0,0,0];
for (var ei = 0; ei < finExp.length; ei++) { for (var ei = 0; ei < finExp.length; ei++) {
if ((finExp[ei].status || '') === '暂不计入') continue; if ((finExp[ei].status || '') === '暂不计入') continue;
var em = (finExp[ei].expense_month || '').substring(0, 7); var em = (finExp[ei].expense_month || '').substring(0, 7);
var emi = parseInt(em.substring(5)) - 1; var emi = parseInt(em.substring(5)) - 1;
var emYear = parseInt(em.substring(0, 4)) || 0;
if (state.finYear !== 0 && emYear && emYear !== state.finYear) continue;
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expPaidQ[eqi] += parseFloat(finExp[ei].incurred_amount || 0); } 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 expPaidAnnual = 0; for (var ei = 0; ei < 4; ei++) expPaidAnnual += expPaidQ[ei];
@@ -1217,7 +1293,8 @@ function renderFinanceQuarterlyOverview() {
cfTbody += rowHtml + '</tr>'; cfTbody += rowHtml + '</tr>';
} }
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' + const yearSelectQ = `<div class="card px-4 py-2 mb-4"><div class="flex items-center gap-2"><span class="text-sm font-medium text-slate-500">📅 选择年份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.finYear===0?' selected':''}>所有年份</option>${[2020,2021,2022,2023,2024,2025,2026,2027].map(y => '<option value="'+y+'"'+(state.finYear===y?' selected':'')+'>'+y+'年</option>').join('')}</select></div></div>`;
document.querySelector("#financeQuarterlyOverview").innerHTML = yearSelectQ + '<div class="grid gap-2.5">' +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><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-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><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-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') + card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><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-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
@@ -1267,7 +1344,21 @@ window.setFinMonth = (m) => { state.finView = 'monthly'; state.finMonth = m; loc
// ---- 项目标记上下文菜单 ---- // ---- 项目标记上下文菜单 ----
if (!state._fmMarks) state._fmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_fm_marks')) || { red: {} }; } catch(e) { return { red: {} }; } })(); if (!state._fmMarks) state._fmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_fm_marks')) || { color: {} }; } catch(e) { return { color: {} }; } })();
if (!state._fmMarks.color) state._fmMarks.color = {};
const FM_COLORS = [
{ key: 'red', label: '标红', hex: '#ef4444', bg: 'bg-red-50' },
{ key: 'yellow', label: '标黄', hex: '#eab308', bg: 'bg-yellow-50' },
{ key: 'blue', label: '标蓝', hex: '#3b82f6', bg: 'bg-blue-50' },
{ key: 'green', label: '标绿', hex: '#22c55e', bg: 'bg-green-50' },
{ key: 'purple', label: '标紫', hex: '#a855f7', bg: 'bg-purple-50' },
];
const fmMarkBg = function(id) {
var c = (state._fmMarks.color||{})[id];
return c ? { red:'bg-red-50',yellow:'bg-yellow-50',blue:'bg-blue-50',green:'bg-green-50',purple:'bg-purple-50'}[c] || '' : '';
};
window.finShowCtx = function(e, pfId) { window.finShowCtx = function(e, pfId) {
e.preventDefault(); e.preventDefault();
var menu = document.getElementById('finCtxMenu'); var menu = document.getElementById('finCtxMenu');
@@ -1276,12 +1367,37 @@ window.finShowCtx = function(e, pfId) {
menu.id = 'finCtxMenu'; menu.id = 'finCtxMenu';
menu.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.15);border:1px solid #e2e8f0;padding:4px 0;min-width:130px'; menu.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.15);border:1px solid #e2e8f0;padding:4px 0;min-width:130px';
menu.innerHTML = '<div style="padding:4px 12px;font-size:11px;font-weight:600;color:#94a3b8">项目标记</div>' + menu.innerHTML = '<div style="padding:4px 12px;font-size:11px;font-weight:600;color:#94a3b8">项目标记</div>' +
'<div data-act="red" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;margin-right:8px"></span>标红</div>'; FM_COLORS.map(function(c) {
return '<div data-act="' + c.key + '" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155;display:flex;align-items:center"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + c.hex + ';margin-right:8px;flex-shrink:0"></span>' + c.label + '</div>';
}).join('') +
'<div style="border-top:1px solid #e2e8f0;margin:4px 0"></div>' +
'<div data-act="copy_plan" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155;display:flex;align-items:center"><span style="margin-right:6px;flex-shrink:0">📋</span>复制项目到预算</div>' +
'<div style="border-top:1px solid #e2e8f0;margin:2px 0"></div>' +
'<div data-act="clear" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#94a3b8">取消标记</div>';
menu.addEventListener('click', function(ev) { menu.addEventListener('click', function(ev) {
var target = ev.target.closest('[data-act]');
if (!target) return;
ev.stopPropagation(); ev.stopPropagation();
var act = target.getAttribute('data-act');
var id = menu.getAttribute('data-pfid'); var id = menu.getAttribute('data-pfid');
menu.style.display = 'none'; menu.style.display = 'none';
if (state._fmMarks.red[id]) { delete state._fmMarks.red[id]; } else { state._fmMarks.red[id] = true; } if (act === 'copy_plan') {
if (!confirm('确认将此项目复制到预算模块?\n\n项目名称前将自动添加「副本-」前缀。')) return;
fetch('/api/finance/copy-to-plan/' + id, { method: 'POST' }).then(r => r.json()).then(d => {
if (d.ok) { alert('复制成功!请切换到预算模块查看。'); renderFinance(); }
else { alert('复制失败:' + (d.error || '未知错误')); }
}).catch(function() { alert('复制失败,请检查网络连接。'); });
return;
}
if (act === 'clear') {
delete state._fmMarks.color[id];
} else {
if (state._fmMarks.color[id] === act) {
delete state._fmMarks.color[id];
} else {
state._fmMarks.color[id] = act;
}
}
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks)); localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance(); renderFinance();
}); });
@@ -1290,15 +1406,6 @@ window.finShowCtx = function(e, pfId) {
menu.setAttribute('data-pfid', pfId); menu.setAttribute('data-pfid', pfId);
menu.style.display = 'block'; menu.style.display = 'block';
menu.style.left = e.pageX + 'px'; menu.style.left = e.pageX + 'px';
menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px'; menu.style.top = Math.min(e.pageY, window.innerHeight - 280) + 'px';
}; };
document.addEventListener('click', function() { var m = document.getElementById('finCtxMenu'); if (m) m.style.display = 'none'; }); document.addEventListener('click', function() { var m = document.getElementById('finCtxMenu'); if (m) m.style.display = 'none'; });
window.finCtxMenu_act = function(e, type) {
e.stopPropagation();
var pfId = document.getElementById("finCtxMenu").getAttribute("data-pfid");
if (type === "clear") { delete state._fmMarks.red[pfId]; delete state._fmMarks.pin[pfId]; }
else { if (state._fmMarks[type][pfId]) { delete state._fmMarks[type][pfId]; } else { state._fmMarks[type][pfId] = true; } }
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance();
};

View File

@@ -1,248 +0,0 @@
// home.js — 首页渲染 + 财务趋势图
function renderHome() {
const { summary, financeMonthly } = state.data;
const m = summary.metrics;
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;
const yrUnpaidWan = Math.round(Math.abs(yrUnpaid) / 10000);
const payRateCls = yrPayRate < 30 ? 'text-red-600' : yrPayRate < 80 ? 'text-amber-600' : 'text-green-600';
const unpaidLabel = yrUnpaid >= 0
? '你还有 <strong class=\"text-red-600\">' + yrUnpaidWan + '万</strong> 未回款'
: '超额回款 <strong class=\"text-green-600\">' + yrUnpaidWan + '万</strong>';
const payReminder = yrRev > 0 ? '<div class=\"flex items-center gap-3 px-4 py-3 rounded-lg border\" style=\"background:linear-gradient(135deg,#fef3c7,#fde68a);border-color:#f59e0b\">' +
'<i data-lucide=\"bell-ring\" class=\"text-amber-600 flex-shrink-0\" style=\"width:18px;height:18px\"></i>' +
'<span class=\"text-sm text-amber-900\"><strong>回款提醒:</strong>本年度确收 <strong>' + moneyInt(yrRev) + '</strong>,回款 <strong>' + moneyInt(yrPay) + '</strong>' + unpaidLabel + ',当前回款率:<strong class=\"' + payRateCls + '\">' + yrPayRate + '%</strong></span>' +
'</div>' : '';
const rows1 = [
["年度累计", moneyInt(m.signed_annual || m.signed_amount)],
["本季度累计", moneyInt(m.signed_q2 || 0)],
["本月累计", moneyInt(m.signed_month || 0)],
["上本季度累计", moneyInt(m.signed_prev_q || 0)],
["上月累计", moneyInt(m.signed_prev_month || 0)],
];
const rows2 = [
["年度累计", moneyInt(m.revenue_annual)],
["本季度累计", moneyInt(m.revenue_q2)],
["本月累计", moneyInt(m.monthly_revenue)],
["上本季度累计", moneyInt(m.revenue_prev_q || 0)],
["上月累计", moneyInt(m.revenue_prev_month || 0)],
];
const rows3 = [
["年度累计", moneyInt(m.gross_annual)],
["本季度累计", moneyInt(m.gross_q2)],
["本月累计", moneyInt(m.monthly_net_profit)],
["上本季度累计", moneyInt(m.gross_prev_q || 0)],
["上月累计", moneyInt(m.gross_prev_month || 0)],
];
const rows4 = [
["年度累计", moneyInt(m.payment_annual || 0)],
["本季度累计", moneyInt(m.payment_q2 || 0)],
["本月累计", moneyInt(m.payment_month || 0)],
["上本季度累计", moneyInt(m.payment_prev_q || 0)],
["上月累计", moneyInt(m.payment_prev_month || 0)],
];
const rows5 = [
["年度累计", moneyInt(m.cost_annual || 0)],
["本季度累计", moneyInt(m.cost_q2 || 0)],
["本月累计", moneyInt(m.cost_month || 0)],
["上本季度累计", moneyInt(m.cost_prev_q || 0)],
["上月累计", moneyInt(m.cost_prev_month || 0)],
];
const rows7 = [
["年度累计", moneyInt(m.paid_annual || 0)],
["本季度累计", moneyInt(m.paid_q2 || 0)],
["本月累计", moneyInt(m.paid_month || 0)],
["上本季度累计", moneyInt(m.paid_prev_q || 0)],
["上月累计", moneyInt(m.paid_prev_month || 0)],
];
const rowsProjExpense = [
["年度累计", moneyInt(m.proj_expense_annual || 0)],
["本季度累计", moneyInt(m.proj_expense_q2 || 0)],
["本月累计", moneyInt(m.proj_expense_month || 0)],
["上本季度累计", moneyInt(m.proj_expense_prev_q || 0)],
["上月累计", moneyInt(m.proj_expense_prev_month || 0)],
];
const rows8 = [
["年度累计", moneyInt(m.cashflow_annual || 0)],
["本季度累计", moneyInt(m.cashflow_q2 || 0)],
["本月累计", moneyInt(m.cashflow_month || 0)],
["上本季度累计", moneyInt(m.cashflow_prev_q || 0)],
["上月累计", moneyInt(m.cashflow_prev_month || 0)],
];
const rows9 = [
["年度累计", moneyInt(m.profit_annual || 0)],
["本季度累计", moneyInt(m.profit_q2 || 0)],
["本月累计", moneyInt(m.profit_month || 0)],
["上本季度累计", moneyInt(m.profit_prev_q || 0)],
["上月累计", moneyInt(m.profit_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 COLORS = ['text-slate-700','text-brand-600','text-green-600','text-rose-600','text-indigo-600','text-amber-600','text-purple-600','text-cyan-600'];
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, rows5, rows9, rows4, rows7, rows8];
// 现金流和项目利润的原始值数组,用于正负着色
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 valCls = function(ri) { return (ri === 4 || ri === 7) ? '' : 'text-slate-800'; };
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>';
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>';
}
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
${`<div class="grid grid-cols-4 gap-3">
${[
["项目管理", m.total_projects, "finance"],
["重点工作与台账", m.total_proposals, "projects"],
["业务方案", m.total_products, "proposals"],
["产品迭代", m.upcoming_products, "products"],
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
</div>`}
${payReminder}
${(() => {
var d = [
['项目毛利', [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0],
[m.profit_q2||0,m.profit_prev_q||0], [m.profit_month||0,m.profit_prev_month||0]],
['部门费用', [m.expense_annual||0, m.expense_q2||0, (m.expense_month||0), m.expense_prev_q||0, m.expense_prev_month||0],
[m.expense_q2||0,m.expense_prev_q||0], [(m.expense_month||0),m.expense_prev_month||0]],
['部门净利', [
(m.profit_annual||0)-(m.expense_annual||0), (m.profit_q2||0)-(m.expense_q2||0), (m.profit_month||0)-(m.expense_month||0),
(m.profit_prev_q||0)-(m.expense_prev_q||0), (m.profit_prev_month||0)-(m.expense_prev_month||0)
], [
(m.profit_q2||0)-(m.expense_q2||0), (m.profit_prev_q||0)-(m.expense_prev_q||0)
], [
(m.profit_month||0)-(m.expense_month||0), (m.profit_prev_month||0)-(m.expense_prev_month||0)
]],
['项目现金流', [m.cashflow_annual||0, m.cashflow_q2||0, m.cashflow_month||0, m.cashflow_prev_q||0, m.cashflow_prev_month||0],
[m.cashflow_q2||0,m.cashflow_prev_q||0], [m.cashflow_month||0,m.cashflow_prev_month||0]],
['部门费用流出', [m.expense_paid_annual||0, m.expense_paid_q2||0, m.expense_paid_month||0, m.expense_paid_prev_q||0, m.expense_paid_prev_month||0],
[m.expense_paid_q2||0,m.expense_paid_prev_q||0], [m.expense_paid_month||0,m.expense_paid_prev_month||0]],
['部门现金流', [m.dept_cf_annual||0, m.dept_cf_q2||0, m.dept_cf_month||0, m.dept_cf_prev_q||0, m.dept_cf_prev_month||0],
[m.dept_cf_q2||0,m.dept_cf_prev_q||0], [m.dept_cf_month||0,m.dept_cf_prev_month||0]]
];
var buildDeptTable = function(title, dataRows) {
var h = '';
for (var di = 0; di < dataRows.length; di++) {
var r = dataRows[di];
var rawNet = r[1][0];
var isNet = r[0] === '部门净利' || r[0] === '部门现金流';
var isCF = r[0] === '项目现金流';
var cls = isNet ? (rawNet >= 0 ? 'text-green-600' : 'text-red-600') : isCF ? (rawNet >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-800';
h += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + r[0] + '</td>' +
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][0]) + '</td>' +
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][3]) + '</td>' +
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][4]) + '</td>' +
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][1]) + '</td>' +
'<td class="py-2 text-right">' + qoq(r[2][0], r[2][1]) + '</td>' +
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][2]) + '</td>' +
'<td class="py-2 text-right">' + qoq(r[3][0], r[3][1]) + '</td></tr>';
}
return card('<h3 class="text-sm font-bold text-slate-700 mb-3">' + title + '</h3><div class="overflow-x-auto"><table class="w-full text-sm"><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></thead><tbody>' + h + '</tbody></table></div>', 'p-4');
};
return buildDeptTable('部门经营情况', d.slice(0, 3)) + buildDeptTable('部门现金流', d.slice(3, 6));
})()}
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")}
</div>
`;
}
function chartOptions(yCallback) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } },
scales: {
x: { ticks: { font: { size: 10 } }, grid: { display: false } },
y: { ticks: { font: { size: 11 }, callback: yCallback } },
},
};
}
const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v;
const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月");
function renderCharts(data) {
const labels = monthLabels(data);
const baseOpts = chartOptions(moneyTick);
// 图1月度签约
const c1 = document.querySelector("#chartSign");
if (c1 && window.Chart) {
if (state.chart) state.chart.destroy();
state.chart = new Chart(c1, {
type: "line",
data: { labels, datasets: [
{ label: "签约金额", data: data.map((x) => x.sign || 0), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 },
]},
options: baseOpts,
});
}
// 图2月度确收与毛利
const c2 = document.querySelector("#chartRev");
if (c2 && window.Chart) {
if (state.chart2) state.chart2.destroy();
state.chart2 = new Chart(c2, {
type: "line",
data: { labels, datasets: [
{ label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "var(--brand-600)", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 },
{ label: "毛利", data: data.map((x) => x.gross || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 },
]},
options: baseOpts,
});
}
// 图3月度回款与已付
const c3 = document.querySelector("#chartCash");
if (c3 && window.Chart) {
if (state.chart3) state.chart3.destroy();
state.chart3 = new Chart(c3, {
type: "line",
data: { labels, datasets: [
{ label: "回款", data: data.map((x) => x.payment || 0), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 },
{ label: "已付", data: data.map((x) => x.cost || 0), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 },
]},
options: baseOpts,
});
}
// 图4月度项目利润
const c4 = document.querySelector("#chartProfit");
if (c4 && window.Chart) {
if (state.chart4) state.chart4.destroy();
state.chart4 = new Chart(c4, {
type: "line",
data: { labels, datasets: [
{ label: "利润", data: data.map((x) => (x.gross || 0)), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 },
]},
options: baseOpts,
});
}
}

View File

@@ -103,15 +103,15 @@ function renderPlan() {
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0"); const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
if (!state.planMonth) state.planMonth = defaultMonth2; if (!state.planMonth) state.planMonth = defaultMonth2;
if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3); if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3);
if (!state.planYear) state.planYear = now2.getFullYear(); if (state.planYear == null) state.planYear = now2.getFullYear();
if (!state.planView) state.planView = 'annual'; if (!['annual','quarterly','monthly'].includes(state.planView)) state.planView = 'annual';
const qLabels = ['Q1','Q2','Q3','Q4']; const qLabels = ['Q1','Q2','Q3','Q4'];
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]]; const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
const nowD = new Date(); const nowD = new Date();
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3); const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
const activeMonths = qMonths[activeQ]; const activeMonths = qMonths[activeQ];
const yearOpts = [2024,2025,2026,2027]; const yearOpts = [2020,2021,2022,2023,2024,2025,2026,2027];
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setPlanStatusFilter('all')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='all'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全部</button><button onclick="setPlanStatusFilter('unsigned')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='unsigned'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">待签约</button><button onclick="setPlanStatusFilter('signed')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='signed'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">已签约</button><button onclick="setPlanStatusFilter('paymentonly')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='paymentonly'?'bg-brand-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="setPlanYear(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.planYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><button onclick="setPlanView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='annual'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全年</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='quarterly'&&activeQ===i?'bg-brand-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.planYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='monthly'&&state.planMonth===ms?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`; const finHeaderBase = `<div class="flex items-center gap-2"><span class="text-sm text-slate-500">年份:</span><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.planYear===0?' selected':''}>所有年份</option>${yearOpts.map(y => `<option value="${y}" ${state.planYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><button onclick="setPlanView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='annual'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全年</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='quarterly'&&activeQ===i?'bg-brand-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.planYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='monthly'&&state.planMonth===ms?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`; const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
const isOverviewMode = false; const isOverviewMode = false;
@@ -369,10 +369,10 @@ function renderPlan() {
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>'; const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600'; const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const payable = cost - paid; const payable = cost - paid;
const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-400'; const payableCls = payable > 0 ? 'text-green-600' : payable < 0 ? 'text-red-600' : 'text-slate-400';
const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>'; const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>';
const pending = rev - payment; const pending = rev - payment;
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700'; const pendingCls = pending > 0 ? 'text-green-600' : pending < 0 ? 'text-red-600' : 'text-slate-700';
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>'; const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
var budgetArr = []; var budgetArr = [];
var expenseArr = []; var expenseArr = [];
@@ -463,12 +463,12 @@ function renderPlan() {
const qRange = qRanges[selQ]; const qRange = qRanges[selQ];
const sumBudget = (pf, field) => { const sumBudget = (pf, field) => {
let total = 0; let total = 0;
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.planYear) 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.planYear || state.planYear === 0)) total += parseFloat(b[field] || 0); }); } catch (e) {}
return total; return total;
}; };
const sumExpense = (pf, field) => { const sumExpense = (pf, field) => {
let total = 0; let total = 0;
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.planYear) 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.planYear || state.planYear === 0)) total += parseFloat(e[field] || 0); }); } catch (e) {}
return total; return total;
}; };
let dataItems = []; let dataItems = [];
@@ -1036,10 +1036,10 @@ window.deletePlanItem = async () => {
// 从 planFinances 计算月度数据 // 从 planFinances 计算月度数据
function computePlanMonthly(planFinances) { function computePlanMonthly(planFinances, year) {
var pfs = planFinances || []; var pfs = planFinances || [];
var months = []; var months = [];
var year = new Date().getFullYear(); year = year || new Date().getFullYear();
for (var mi = 1; mi <= 12; mi++) { months.push(year + '-' + String(mi).padStart(2, '0')); } for (var mi = 1; mi <= 12; mi++) { months.push(year + '-' + String(mi).padStart(2, '0')); }
var data = []; var data = [];
for (var i = 0; i < 12; i++) { for (var i = 0; i < 12; i++) {
@@ -1062,7 +1062,7 @@ function computePlanMonthly(planFinances) {
// 总览渲染 // 总览渲染
function renderPlanOverview() { function renderPlanOverview() {
const planFinances = state.data.planFinances || []; const planFinances = state.data.planFinances || [];
const fm = computePlanMonthly(planFinances); const fm = computePlanMonthly(planFinances, state.planYear);
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN"); const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
const moneyWan = (v) => { const moneyWan = (v) => {
const n = Number(v || 0); const n = Number(v || 0);
@@ -1113,6 +1113,8 @@ function renderPlanOverview() {
for (var ei = 0; ei < planExp.length; ei++) { for (var ei = 0; ei < planExp.length; ei++) {
var em = (planExp[ei].expense_month || '').substring(0, 7); var em = (planExp[ei].expense_month || '').substring(0, 7);
var mi = parseInt(em.substring(5)) - 1; var mi = parseInt(em.substring(5)) - 1;
var emYear = parseInt(em.substring(0, 4)) || 0;
if (state.planYear !== 0 && emYear && emYear !== state.planYear) continue;
if (mi >= 0 && mi < 12) expMonthly[mi] += parseFloat(planExp[ei].amount || 0); if (mi >= 0 && mi < 12) expMonthly[mi] += parseFloat(planExp[ei].amount || 0);
} }
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei]; var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
@@ -1149,6 +1151,8 @@ function renderPlanOverview() {
for (var epi = 0; epi < planExp.length; epi++) { for (var epi = 0; epi < planExp.length; epi++) {
var epm = (planExp[epi].expense_month || '').substring(0, 7); var epm = (planExp[epi].expense_month || '').substring(0, 7);
var epmi = parseInt(epm.substring(5)) - 1; var epmi = parseInt(epm.substring(5)) - 1;
var epmYear = parseInt(epm.substring(0, 4)) || 0;
if (state.planYear !== 0 && epmYear && epmYear !== state.planYear) continue;
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(planExp[epi].incurred_amount || 0); if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(planExp[epi].incurred_amount || 0);
} }
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi]; var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
@@ -1175,14 +1179,15 @@ function renderPlanOverview() {
} }
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4'); var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
document.querySelector("#planOverview").innerHTML = '<div class="grid gap-2.5">' + const yearSelectOv = `<div class="card px-4 py-2 mb-4"><div class="flex items-center gap-2"><span class="text-sm font-medium text-slate-500">📅 选择年份:</span><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.planYear===0?' selected':''}>所有年份</option>${[2020,2021,2022,2023,2024,2025,2026,2027].map(y => '<option value="'+y+'"'+(state.planYear===y?' selected':'')+'>'+y+'年</option>').join('')}</select></div></div>`;
document.querySelector("#planOverview").innerHTML = yearSelectOv + '<div class="grid gap-2.5">' +
deptCard + deptCard +
cfCard + cfCard +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,成本,支出,现金流</span></h3><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-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,成本,支出,现金流</span></h3><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">' + '<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-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') + card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-600">' + (state.planYear===0 ? '所有年份' : state.planYear) + '</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') + card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">' + (state.planYear===0 ? '所有年份' : state.planYear) + '</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') + card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-600">' + (state.planYear===0 ? '所有年份' : state.planYear) + '</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
'</div></div>'; '</div></div>';
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
@@ -1195,7 +1200,7 @@ function renderPlanOverview() {
function renderPlanQuarterlyOverview() { function renderPlanQuarterlyOverview() {
const planFinances = state.data.planFinances || []; const planFinances = state.data.planFinances || [];
const fm = computePlanMonthly(planFinances); const fm = computePlanMonthly(planFinances, state.planYear);
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN"); const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>'; const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
var annualSums2 = { sign: 0, revenue: 0, gross: 0, cost: 0, payment: 0, paid: 0 }; var annualSums2 = { sign: 0, revenue: 0, gross: 0, cost: 0, payment: 0, paid: 0 };
@@ -1312,7 +1317,8 @@ function renderPlanQuarterlyOverview() {
cfTbody += rowHtml + '</tr>'; cfTbody += rowHtml + '</tr>';
} }
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' + const yearSelectQ = `<div class="card px-4 py-2 mb-4"><div class="flex items-center gap-2"><span class="text-sm font-medium text-slate-500">📅 选择年份:</span><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.planYear===0?' selected':''}>所有年份</option>${[2020,2021,2022,2023,2024,2025,2026,2027].map(y => '<option value="'+y+'"'+(state.planYear===y?' selected':'')+'>'+y+'年</option>').join('')}</select></div></div>`;
document.querySelector("#planQuarterlyOverview").innerHTML = yearSelectQ + '<div class="grid gap-2.5">' +
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><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-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><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-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') + card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,成本,支出,现金流</span></h3><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-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,成本,支出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +

View File

@@ -19,7 +19,13 @@ async function createResource(event, resource) {
const name = data.project_name || data.target_customer || data.customer_or_project_name || data.product_name || ""; 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); if (result.id && name) logActivity(resType, result.id, "创建了" + name);
form.reset(); form.reset();
try { state.data.products = await api("/api/products/list?tenant=" + encodeURIComponent(state.tenant)); renderProducts(); } catch(e) {} if (resource === "operations") {
try { state.data.operations = await api("/api/operations/list?tenant=" + encodeURIComponent(state.tenant)); renderProjects(); } catch(e) {}
} else if (resource === "proposals") {
try { state.data.proposals = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant)); renderProposals(); } catch(e) {}
} else {
try { state.data.products = await api("/api/products/list?tenant=" + encodeURIComponent(state.tenant)); renderProducts(); } catch(e) {}
}
} catch (error) { } catch (error) {
toast("创建失败:" + error.message, "error"); toast("创建失败:" + error.message, "error");
} }

View File

@@ -173,14 +173,6 @@ window.selectProject = (id) => {
renderProjectTasks(id); renderProjectTasks(id);
}; };
window.togglePhase = (phaseId) => {
const wrap = document.querySelector(`#${phaseId}`);
if (!wrap) return;
wrap.classList.toggle("collapsed");
const toggle = document.querySelector(`#${phaseId}-toggle`);
if (toggle) toggle.style.transform = wrap.classList.contains("collapsed") ? "rotate(-90deg)" : "";
};
window.showProjectContext = (event, id) => { window.showProjectContext = (event, id) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -306,7 +298,7 @@ function renderProjects() {
pending: tasks.filter(t => t.status === '未开始').length, pending: tasks.filter(t => t.status === '未开始').length,
}; };
document.querySelector("#projects").innerHTML = /*html*/` document.querySelector("#projects").innerHTML = /*html*/`
<div class="grid grid-cols-5 gap-3 mb-4"> <div id="projectHeadCards" class="grid grid-cols-5 gap-3 mb-4">
${[ ${[
["项目总数", items.length, "folder"], ["项目总数", items.length, "folder"],
["任务总数", taskStats.total, "list-checks"], ["任务总数", taskStats.total, "list-checks"],
@@ -317,15 +309,6 @@ function renderProjects() {
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div> <div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
`).join("")} `).join("")}
</div> </div>
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-1" id="taskViewToggle">
<button class="btn btn-sm ${state.taskView === 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('compact')" title="标题视图"><i data-lucide="list" style="width:16px;height:16px"></i></button>
<button class="btn btn-sm ${state.taskView !== 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('detail')" title="详细视图"><i data-lucide="align-left" style="width:16px;height:16px"></i></button>
</div>
<button class="btn btn-primary btn-sm" onclick="openTaskFormForSelected()">
<i data-lucide="plus"></i>新增任务
</button>
</div>
<div class="project-board"> <div class="project-board">
<div class="project-board-body"> <div class="project-board-body">
<div class="project-tree"> <div class="project-tree">
@@ -349,6 +332,15 @@ function renderProjects() {
</div> </div>
</div> </div>
<div class="task-feed"> <div class="task-feed">
<div class="flex justify-between items-center px-4 py-2 border-b border-slate-100 bg-white" style="flex-shrink:0">
<div class="flex items-center gap-1" id="taskViewToggle">
<button class="btn btn-sm ${state.taskView === 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('compact')" title="标题视图"><i data-lucide="list" style="width:16px;height:16px"></i></button>
<button class="btn btn-sm ${state.taskView !== 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('detail')" title="详细视图"><i data-lucide="align-left" style="width:16px;height:16px"></i></button>
</div>
<button class="btn btn-primary btn-sm" onclick="openTaskFormForSelected()">
<i data-lucide="plus"></i>新增任务
</button>
</div>
${state.selectedProject ? '<div class="task-feed-body">' + renderTaskListHTML(state.selectedProject) + '</div>' : ` ${state.selectedProject ? '<div class="task-feed-body">' + renderTaskListHTML(state.selectedProject) + '</div>' : `
<div class="project-empty"> <div class="project-empty">
<div class="text-center"> <div class="text-center">
@@ -381,44 +373,33 @@ function renderTaskListHTML(projectId) {
const project = state.data.operations.find((x) => x.id === projectId); const project = state.data.operations.find((x) => x.id === projectId);
if (!project) return ""; if (!project) return "";
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId); const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
const filtered = tasks;
const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"]; const taskItem = (t) => `<div class="task-item ${t.status === '已结束' ? 'task-done' : ''} ${t.priority === 'P0' ? 'task-p0' : t.priority === 'P1' ? 'task-p1' : ''} ${state.taskView === 'detail' ? 'task-detail' : ''}" data-id="${t.id}" draggable="true" ondragstart="handleTaskDragStart(event, ${t.id})" ondragend="event.currentTarget.classList.remove('dragging')" onclick="openTaskForm(${projectId}, ${t.id})">
const customPhases = [...new Set(filtered.map(t => t.phase).filter(Boolean))]; <span class="task-grip"><i data-lucide="grip-vertical"></i></span>
const phaseOrder = [...defaultPhases]; <span class="task-status-badge status-${esc(t.status) || '未开始'}" onclick="event.stopPropagation(); cycleTaskStatus(${t.id}, ${projectId})" title="点击切换状态">${esc(t.status) || '未开始'}</span>
customPhases.forEach(p => { if (!phaseOrder.includes(p)) phaseOrder.push(p); }); <span class="task-priority-badge priority-${(t.priority || 'P2').toLowerCase()}" onclick="event.stopPropagation(); cycleTaskPriority(${t.id}, ${projectId})" title="点击切换优先级">${esc(t.priority) || 'P2'}</span>
const phases = phaseOrder.filter(p => filterPhaseTasks(filtered, p).length > 0); <div class="task-content">
const phaseTasks = phases.map(p => ({ phase: p, tasks: filterPhaseTasks(filtered, p) })); <span class="task-title">${esc(t.task)}</span>
${state.taskView === 'detail' && t.notes ? '<span class="task-desc">' + esc(t.notes) + '</span>' : ""}
${state.taskView === 'detail' && t.blockers ? '<span class="task-blocker">\u26a0 ' + esc(t.blockers) + '</span>' : ""}
</div>
<span class="task-meta">${esc(t.owner) || ''}</span>
<span class="task-expand-toggle" onclick="event.stopPropagation();toggleTaskExpand(${t.id})"><i data-lucide="chevron-down" id="tke_icon_${t.id}"></i></span>
</div>
<div class="task-expand-body hidden" id="tke_body_${t.id}">
<div class="flex flex-col gap-3 px-6 py-3 text-sm">
<div><span class="text-slate-400 text-xs">时间</span><p class="text-slate-700 mt-1">${esc(t.start_date) || '—'}${esc(t.due_date) || '—'}</p></div>
<div><span class="text-slate-400 text-xs">任务进展</span><p class="text-slate-700 mt-1">${esc(t.notes) || '—'}</p></div>
<div><span class="text-slate-400 text-xs">卡点与备注</span><p class="${t.blockers ? 'text-red-600' : 'text-slate-700'} mt-1">${t.blockers ? '\u26a0 ' + esc(t.blockers) : '—'}</p></div>
</div>
</div>`;
return ` return `
${phaseTasks.map(({ phase, tasks: pt }) => { <div class="task-section-list-wrap">
if (!pt.length) return ""; <div class="task-section-list" ondrop="handleTaskDrop(event, ${projectId}, '')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')">
const phaseId = "phase-" + projectId + "-" + phase.replace(/\s/g, ""); ${tasks.length ? tasks.map(taskItem).join("") : '<div class="task-empty">暂无任务,点击上方按钮创建</div>'}
return `<div class="task-section"> </div>
<div class="task-section-hd" onclick="togglePhase('${phaseId}')"> </div>
<span class="task-section-toggle" id="${phaseId}-toggle"><i data-lucide="chevron-down"></i></span>
<span class="task-section-icon"><i data-lucide="layers"></i></span>
<span class="task-section-label">${phase}</span>
<span class="task-section-n">${pt.length}</span>
</div>
<div class="task-section-list-wrap" id="${phaseId}">
<div class="task-section-list" data-phase="${phase}" ondrop="handleTaskDrop(event, ${projectId}, '${phase}')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')">
${pt.map((t) => `<div class="task-item ${t.status === '已结束' ? 'task-done' : ''} ${t.priority === 'P0' ? 'task-p0' : t.priority === 'P1' ? 'task-p1' : ''} ${state.taskView === 'detail' ? 'task-detail' : ''}" data-id="${t.id}" draggable="true" ondragstart="handleTaskDragStart(event, ${t.id})" ondragend="event.currentTarget.classList.remove('dragging')">
<span class="task-grip"><i data-lucide="grip-vertical"></i></span>
<span class="task-status-badge status-${esc(t.status) || '未开始'}" onclick="event.stopPropagation(); cycleTaskStatus(${t.id}, ${projectId})" title="点击切换状态">${esc(t.status) || '未开始'}</span>
<span class="task-priority-badge priority-${(t.priority || 'P2').toLowerCase()}" onclick="event.stopPropagation(); cycleTaskPriority(${t.id}, ${projectId})" title="点击切换优先级">${esc(t.priority) || 'P2'}</span>
<div class="task-content" onclick="openTaskForm(${projectId}, ${t.id})">
<span class="task-title">${esc(t.task)}</span>
${state.taskView === 'detail' && t.notes ? '<span class="task-desc">' + esc(t.notes) + '</span>' : ""}
${state.taskView === 'detail' && t.blockers ? '<span class="task-blocker">\u26a0 ' + esc(t.blockers) + '</span>' : ""}
</div>
<span class="task-meta">${esc(t.owner) || ''}</span>
<span class="task-meta text-slate-400">${esc(t.due_date) || ''}</span>
</div>`).join("")}
</div>
</div>
</div>`;
}).join("")}
${filtered.length === 0 ? '<div class="task-empty">暂无任务,点击上方按钮创建</div>' : ''}
`; `;
} }
@@ -440,48 +421,56 @@ window.openTaskFormForSelected = () => {
window.openTaskForm = (projectId, taskId) => { window.openTaskForm = (projectId, taskId) => {
if (!projectId) return; if (!projectId) return;
// 确保 drawer 存在 const modal = document.getElementById("taskModal");
let drawer = document.querySelector(`#task-drawer-${projectId}`); if (!modal) return;
if (!drawer) {
drawer = document.createElement("div");
drawer.id = `task-drawer-${projectId}`;
drawer.className = "task-drawer";
document.body.appendChild(drawer);
}
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"];
const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))];
const phases = [...new Set([...defaultPhases, ...customPhases])];
const task = taskId ? (state.data.tasks || []).find((t) => t.id === taskId) : null; const task = taskId ? (state.data.tasks || []).find((t) => t.id === taskId) : null;
drawer.innerHTML = `<div class="task-drawer-hd"><span class="task-drawer-title">${task ? "编辑任务" : "新增任务"}</span><div class="flex items-center gap-2">${task ? `<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteTask(${projectId})"><i data-lucide="trash-2"></i>删除</button>` : ""}<button class="task-close" onclick="closeTaskDrawer(${projectId})"><i data-lucide="x"></i></button></div></div> modal.className = "hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40";
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${projectId})"> modal.innerHTML = `<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
<input type="hidden" name="task_id" id="task-id-${projectId}" value="${task ? task.id : ''}"> <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">
<div class="task-field-row"> <h3 class="text-lg font-bold text-slate-800">${task ? "编辑任务" : "新增任务"}</h3>
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name-${projectId}" class="form-ctrl" value="${task ? esc(task.task) : ''}"></label> <div class="flex items-center gap-2">
<label class="task-field"><span>任务分组</span><select name="phase" id="task-phase-${projectId}" class="form-ctrl">${phases.map((p) => `<option ${task && task.phase === p ? "selected" : ""}>${p}</option>`).join("")}</select></label> ${task ? '<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" id="taskDelBtn"><i data-lucide="trash-2"></i>删除</button>' : ""}
<button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" id="taskClsBtn"><i data-lucide="x"></i></button>
</div> </div>
<div class="task-field-row"> </div>
<label class="task-field"><span>优先级</span><select name="priority" id="task-priority-${projectId}" class="form-ctrl"><option ${task && task.priority === 'P0' ? 'selected' : ''}>P0</option><option ${task && task.priority === 'P1' ? 'selected' : ''}>P1</option><option ${(!task || task.priority === 'P2') ? 'selected' : ''}>P2</option><option ${task && task.priority === 'P3' ? 'selected' : ''}>P3</option></select></label> <form class="px-6 py-4 flex flex-col gap-4" onsubmit="event.preventDefault()">
<label class="task-field"><span>状态</span><select name="status" id="task-status-${projectId}" class="form-ctrl"><option ${(!task || task.status === '') ? 'selected' : ''}></option><option ${task && task.status === '' ? 'selected' : ''}></option><option ${task && task.status === '' ? 'selected' : ''}></option></select></label> <input type="hidden" name="task_id" value="${task ? task.id : ''}">
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>任务名称</span><input name="task" required class="form-ctrl" value="${task ? esc(task.task) : ''}"></label>
<label class="task-field"><span>优先级</span><select name="priority" class="form-ctrl"><option ${task && task.priority === 'P0' ? 'selected' : ''}>P0</option><option ${task && task.priority === 'P1' ? 'selected' : ''}>P1</option><option ${(!task || task.priority === 'P2') ? 'selected' : ''}>P2</option><option ${task && task.priority === 'P3' ? 'selected' : ''}>P3</option></select></label>
</div> </div>
<div class="task-field-row"> <div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner-${projectId}" class="form-ctrl" value="${task ? esc(task.owner) : ''}"></label> <label class="task-field"><span>状态</span><select name="status" class="form-ctrl"><option ${(!task || task.status === '未开始') ? 'selected' : ''}>未开始</option><option ${task && task.status === '进行中' ? 'selected' : ''}>进行中</option><option ${task && task.status === '已结束' ? 'selected' : ''}>已结束</option></select></label>
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due-${projectId}" class="form-ctrl" value="${task ? esc(task.due_date) : ''}"></label>
</div> </div>
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes-${projectId}" class="form-ctrl">${task ? esc(task.notes) : ''}</textarea></label> <div class="grid grid-cols-3 gap-3">
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers-${projectId}" class="form-ctrl" placeholder="">${task ? esc(task.blockers) : ''}</textarea></label> <label class="task-field"><span>负责人</span><input name="owner" class="form-ctrl" value="${task ? esc(task.owner) : ''}"></label>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100"> <label class="task-field"><span>开始时间</span><input name="start_date" type="date" class="form-ctrl" value="${task ? esc(task.start_date) : ''}"></label>
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer(${projectId})">取消</button> <label class="task-field"><span>截止时间</span><input name="due_date" type="date" class="form-ctrl" value="${task ? esc(task.due_date) : ''}"></label>
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn-${projectId}">${task ? "保存" : "确认新增"}</button>
</div> </div>
</form>`; <label class="task-field"><span>任务进展</span><textarea name="notes" rows="3" class="form-ctrl">${task ? esc(task.notes) : ''}</textarea></label>
drawer.classList.add("open"); <label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" class="form-ctrl" placeholder="风险卡点、依赖项等">${task ? esc(task.blockers) : ''}</textarea></label>
<div class="flex justify-end gap-2 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" id="taskCancelBtn">取消</button>
<button type="submit" class="btn btn-primary btn-sm" id="taskSubmitBtn">${task ? "保存" : "确认新增"}</button>
</div>
</form>
</div>`;
// 绑定事件
var delBtn = document.getElementById("taskDelBtn");
if (delBtn) delBtn.onclick = function() { deleteTask(projectId); };
var clsBtn = document.getElementById("taskClsBtn");
if (clsBtn) clsBtn.onclick = function() { closeTaskDrawer(projectId); };
var cancelBtn = document.getElementById("taskCancelBtn");
if (cancelBtn) cancelBtn.onclick = function() { closeTaskDrawer(projectId); };
var submitBtn = document.getElementById("taskSubmitBtn");
if (submitBtn) submitBtn.onclick = function(e) { e.preventDefault(); submitTaskForm(e, projectId); };
modal.classList.remove("hidden");
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
}; };
window.closeTaskDrawer = (projectId) => { window.closeTaskDrawer = (projectId) => {
const drawer = document.querySelector(`#task-drawer-${projectId}`); const modal = document.getElementById("taskModal");
if (drawer) drawer.classList.remove("open"); if (modal) modal.classList.add("hidden");
refreshTaskList(projectId); refreshTaskList(projectId);
}; };
@@ -492,10 +481,34 @@ window.refreshTaskList = (projectId) => {
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
} }
}; };
window.refreshProjectHeadCards = () => {
const container = document.querySelector("#projectHeadCards");
if (!container) return;
const items = state.data.operations || [];
const tasks = state.data.tasks || [];
const stats = {
total: tasks.length,
ongoing: tasks.filter(t => t.status === '进行中').length,
done: tasks.filter(t => t.status === '已结束').length,
pending: tasks.filter(t => t.status === '未开始').length,
};
const cards = [
["项目总数", items.length, "folder"],
["任务总数", stats.total, "list-checks"],
["进行中", stats.ongoing, "play-circle"],
["已结束", stats.done, "check-circle"],
["未开始", stats.pending, "circle"],
];
container.innerHTML = cards.map(([label, value, icon]) => `
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
`).join("");
if (window.lucide) window.lucide.createIcons();
};
window.submitTaskForm = async (event, projectId) => { window.submitTaskForm = async (event, projectId) => {
event.preventDefault(); event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries()); const form = document.getElementById("taskModal").querySelector("form");
const data = Object.fromEntries(new FormData(form).entries());
data.project_id = Number(projectId); data.project_id = Number(projectId);
data.tenant = state.tenant; data.tenant = state.tenant;
const taskId = data.task_id; const taskId = data.task_id;
@@ -507,11 +520,14 @@ window.submitTaskForm = async (event, projectId) => {
if (task) Object.assign(task, data); if (task) Object.assign(task, data);
if (data.task) logActivity("task", taskId, "更新了任务「" + data.task + "」"); if (data.task) logActivity("task", taskId, "更新了任务「" + data.task + "」");
closeTaskDrawer(projectId); closeTaskDrawer(projectId);
refreshProjectHeadCards();
} else { } else {
const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) }); const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」"); if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」");
if (!state.data.tasks) state.data.tasks = [];
state.data.tasks.push({ id: result.id, ...data });
closeTaskDrawer(projectId); closeTaskDrawer(projectId);
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} refreshProjectHeadCards();
} }
} catch (error) { } catch (error) {
toast("保存失败:" + error.message, "error"); toast("保存失败:" + error.message, "error");
@@ -537,6 +553,7 @@ window.cycleTaskStatus = async (taskId, projectId) => {
badge.className = "task-status-badge status-" + newStatus; badge.className = "task-status-badge status-" + newStatus;
} }
} }
refreshProjectHeadCards();
} catch (error) { } catch (error) {
toast("更新失败:" + error.message, "error"); toast("更新失败:" + error.message, "error");
} }
@@ -568,8 +585,25 @@ window.cycleTaskPriority = async (taskId, projectId) => {
} }
}; };
window.toggleTaskExpand = (taskId) => {
const body = document.getElementById("tke_body_" + taskId);
const icon = document.getElementById("tke_icon_" + taskId);
if (!body || !icon) return;
const isHidden = body.classList.contains("hidden");
if (isHidden) {
body.classList.remove("hidden");
icon.style.transform = "rotate(180deg)";
} else {
body.classList.add("hidden");
icon.style.transform = "";
}
if (window.lucide) window.lucide.createIcons();
};
window.deleteTask = async (projectId) => { window.deleteTask = async (projectId) => {
const taskId = document.querySelector(`#task-id-${projectId}`).value; const modal = document.getElementById("taskModal");
if (!modal) return;
const taskId = modal.querySelector('input[name="task_id"]').value;
if (!taskId) return; if (!taskId) return;
if (!confirm("确认删除该任务?此操作不可撤销。")) return; if (!confirm("确认删除该任务?此操作不可撤销。")) return;
try { try {
@@ -581,6 +615,7 @@ window.deleteTask = async (projectId) => {
state.data.tasks = (state.data.tasks || []).filter(t => t.id !== parseInt(taskId)); state.data.tasks = (state.data.tasks || []).filter(t => t.id !== parseInt(taskId));
const row = document.querySelector(`.task-item[data-id="${taskId}"]`); const row = document.querySelector(`.task-item[data-id="${taskId}"]`);
if (row) row.remove(); if (row) row.remove();
refreshProjectHeadCards();
} catch (error) { } catch (error) {
toast("删除失败:" + error.message, "error"); toast("删除失败:" + error.message, "error");
} }

View File

@@ -1,7 +1,7 @@
// utils.js — 全局工具函数与共享状态 // utils.js — 全局工具函数与共享状态
const state = { const state = {
active: "home", active: "plan",
data: null, data: null,
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界", tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
opFilter: "all", opFilter: "all",
@@ -120,7 +120,6 @@ function renderTable(headers, rows, rowClicks) {
} }
var _loadingSteps = [ var _loadingSteps = [
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return true; } },
{ fn: 'renderOverviewPlan', label: '正在加载预算总览', skip: function() { return state.tenant !== '总览'; } }, { fn: 'renderOverviewPlan', label: '正在加载预算总览', skip: function() { return state.tenant !== '总览'; } },
{ fn: 'renderOverviewFinance', label: '正在加载发生总览', skip: function() { return state.tenant !== '总览'; } }, { fn: 'renderOverviewFinance', label: '正在加载发生总览', skip: function() { return state.tenant !== '总览'; } },
{ fn: 'renderProjects', label: '正在加载业务机会列表' }, { fn: 'renderProjects', label: '正在加载业务机会列表' },
@@ -235,7 +234,7 @@ var _tabLoaded = {};
var _proposalsInitialized = false; var _proposalsInitialized = false;
async function ensureTabData(tab) { async function ensureTabData(tab) {
if (tab === 'home' || tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return; if (tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return;
var resource = _tabResourceMap[tab]; var resource = _tabResourceMap[tab];
if (!resource) return; if (!resource) return;
var dataKey = resource; var dataKey = resource;
@@ -249,6 +248,8 @@ async function ensureTabData(tab) {
try { state.data.expense = await api("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} 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.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) {} try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
} else if (tab === 'projects') {
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
} else if (tab === 'plan') { } else if (tab === 'plan') {
try { state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {} try { state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
} }
@@ -265,7 +266,7 @@ function switchTab(tab) {
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab)); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
// 更新顶部标题 // 更新顶部标题
const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' }; const titles = { finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' };
const titleEl = document.querySelector('#workspaceTitle'); const titleEl = document.querySelector('#workspaceTitle');
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台'; if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
@@ -283,28 +284,15 @@ function updateSidebarTabs() {
if (isOverview) { if (isOverview) {
btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "" : "none"; btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "" : "none";
} else { } else {
btn.style.display = (tab === "overview_plan" || tab === "overview_finance" || tab === "home") ? "none" : ""; btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "none" : "";
} }
}); });
} }
function render() {
if (!state.data) return;
if (state.tenant === '总览') renderHome();
renderProjects();
renderProposals();
renderProducts();
renderPerformance();
renderFinance();
renderPlan();
if (window.lucide) window.lucide.createIcons();
}
function renderActive() { function renderActive() {
if (!state.data) return; if (!state.data) return;
const tab = state.active; const tab = state.active;
if (tab === "home") renderHome(); if (tab === "overview_plan") renderOverviewPlan();
else if (tab === "overview_plan") renderOverviewPlan();
else if (tab === "overview_finance") renderOverviewFinance(); else if (tab === "overview_finance") renderOverviewFinance();
else if (tab === "projects") renderProjects(); else if (tab === "projects") renderProjects();
else if (tab === "proposals") renderProposals(); else if (tab === "proposals") renderProposals();
@@ -403,11 +391,14 @@ window.switchTenant = async (tenant) => {
localStorage.setItem("opc-active-tab", "overview_plan"); localStorage.setItem("opc-active-tab", "overview_plan");
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "overview_plan")); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "overview_plan"));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "overview_plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "overview_plan"));
} else if (state.active === 'home' || state.active === 'overview_plan' || state.active === 'overview_finance') { } else if (state.active === 'overview_plan' || state.active === 'overview_finance') {
state.active = 'plan'; state.active = 'plan';
localStorage.setItem("opc-active-tab", "plan"); localStorage.setItem("opc-active-tab", "plan");
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.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")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan"));
} else {
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === state.active));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === state.active));
} }
await load(); await load();
}; };
@@ -424,3 +415,18 @@ window.doLogout = async () => {
await api("/api/auth/logout", { method: "POST" }); await api("/api/auth/logout", { method: "POST" });
location.href = "/login"; location.href = "/login";
}; };
// 图表工具函数(曾被 home.js 中 plan/finance 模块复用)
const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v;
const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月");
function chartOptions(yCallback) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } },
scales: {
x: { ticks: { font: { size: 10 } }, grid: { display: false } },
y: { ticks: { font: { size: 11 }, callback: yCallback } },
},
};
}

View File

@@ -537,6 +537,26 @@ body {
font-size: 13px; font-size: 13px;
} }
.task-expand-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
color: #94a3b8;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.15s, background 0.15s;
}
.task-expand-toggle:hover { color: #475569; background: #f1f5f9; }
.task-expand-toggle i { transition: transform 0.2s ease; }
.task-expand-body {
border-top: 1px solid #f1f5f9;
background: #fafbfc;
}
/* 业务方案列表项 */ /* 业务方案列表项 */
.proposal-item { .proposal-item {
display: flex; display: flex;
@@ -1109,8 +1129,6 @@ td {
} }
/* Task Modal — Plane style */ /* Task Modal — Plane style */
.task-modal { display: none; }
.task-modal.active { display: block; }
.task-overlay { .task-overlay {
position: fixed; inset: 0; background: rgba(15,23,42,0.35); z-index: 200; position: fixed; inset: 0; background: rgba(15,23,42,0.35); z-index: 200;
display: flex; align-items: flex-start; justify-content: center; display: flex; align-items: flex-start; justify-content: center;

View File

@@ -63,10 +63,6 @@
<div class="w-8 h-px bg-slate-700 my-2"></div> <div class="w-8 h-px bg-slate-700 my-2"></div>
<!-- 导航 Tab 图标 --> <!-- 导航 Tab 图标 -->
<div class="flex flex-col items-center gap-1 w-14" id="sidebarTabs"> <div class="flex flex-col items-center gap-1 w-14" id="sidebarTabs">
<div class="sidebar-tab active" data-tab="home" onclick="switchTab('home')" title="部门">
<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> <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>
@@ -106,7 +102,7 @@
<header class="topbar border-b border-slate-200 bg-white px-8 py-5"> <header class="topbar border-b border-slate-200 bg-white px-8 py-5">
<div class="flex items-center gap-3 w-full"> <div class="flex items-center gap-3 w-full">
<div class="flex-1"> <div class="flex-1">
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-brand-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.41</span></p> <p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-brand-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">{{ app_version }}</span></p>
<div class="flex items-center gap-4 mt-1"> <div class="flex items-center gap-4 mt-1">
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1> <h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-brand-50 to-indigo-50 border border-brand-100 rounded-full px-4 py-1.5"> <div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-brand-50 to-indigo-50 border border-brand-100 rounded-full px-4 py-1.5">
@@ -122,21 +118,19 @@
</header> </header>
<main class="px-8 py-6"> <main class="px-8 py-6">
<section id="home" class="panel active"></section>
<section id="overview_plan" class="panel"></section> <section id="overview_plan" class="panel"></section>
<section id="overview_finance" class="panel"></section> <section id="overview_finance" class="panel"></section>
<section id="projects" class="panel"></section> <section id="projects" class="panel"></section>
<section id="proposals" class="panel"></section> <section id="proposals" class="panel"></section>
<section id="products" class="panel"></section> <section id="products" class="panel"></section>
<section id="performance" class="panel"></section> <section id="performance" class="panel"></section>
<section id="performance" class="panel"></section>
<section id="finance" class="panel"></section> <section id="finance" class="panel"></section>
<section id="plan" class="panel"></section> <section id="plan" class="panel active"></section>
</main> </main>
</div><!-- 关闭主内容区 --> </div><!-- 关闭主内容区 -->
</div><!-- 关闭 flex 容器 --> </div><!-- 关闭 flex 容器 -->
<aside id="drawer" class="drawer" aria-hidden="true"></aside> <aside id="drawer" class="drawer" aria-hidden="true"></aside>
<div id="taskModal" class="task-modal"></div> <div id="taskModal"></div>
<!-- 新增项目模态框 --> <!-- 新增项目模态框 -->
<div id="newProjectModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeNewProjectModal()"> <div id="newProjectModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeNewProjectModal()">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()"> <div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()">
@@ -155,7 +149,6 @@
</div> </div>
</div> </div>
<script src="{{ url_for('static', filename='modules/utils.js') }}"></script> <script src="{{ url_for('static', filename='modules/utils.js') }}"></script>
<script src="{{ url_for('static', filename='modules/home.js') }}"></script>
<script src="{{ url_for('static', filename='modules/projects.js') }}"></script> <script src="{{ url_for('static', filename='modules/projects.js') }}"></script>
<script src="{{ url_for('static', filename='modules/proposals.js') }}"></script> <script src="{{ url_for('static', filename='modules/proposals.js') }}"></script>
<script src="{{ url_for('static', filename='modules/products.js') }}"></script> <script src="{{ url_for('static', filename='modules/products.js') }}"></script>