Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa3ec75179 | ||
|
|
f619ed34a7 | ||
|
|
4ad19d438f | ||
|
|
89810942cd | ||
|
|
30b1a50490 | ||
|
|
6b3198d36f | ||
|
|
01718e9acc | ||
|
|
8e688efb62 | ||
|
|
87f0abbbd8 | ||
|
|
7c9ee77992 | ||
|
|
c2ed20caeb | ||
|
|
2340c69e26 | ||
|
|
a3f269046d | ||
|
|
983fd439d9 | ||
|
|
ec062fb2ec | ||
|
|
cae6bcf0e1 | ||
|
|
c4af452115 | ||
|
|
ce7152ab6a | ||
|
|
8c318fb3ad | ||
|
|
e7bf679255 | ||
|
|
9c2beee51e | ||
|
|
c5be3ce134 | ||
|
|
6a582c65b8 | ||
|
|
14368e83d0 | ||
|
|
3fff80425b | ||
|
|
63ddffb12a | ||
|
|
02abe41793 | ||
|
|
2c7cd21095 | ||
|
|
f30d40fe70 | ||
|
|
9273be3143 | ||
|
|
eb300731a3 | ||
|
|
2aab73a86a | ||
|
|
67bc00314c | ||
|
|
e0000c2abb | ||
|
|
d34e52eff4 | ||
|
|
356c129668 | ||
|
|
8e38f50c99 | ||
|
|
94b9f25938 | ||
|
|
3a40e78c3e | ||
|
|
536b165a9b | ||
|
|
a1a4f41d79 | ||
|
|
75227c51c3 | ||
|
|
d4889d56ec | ||
|
|
f5dd152ca4 | ||
|
|
35706f123d | ||
|
|
2c20900e75 | ||
|
|
e4d1cd59f3 | ||
|
|
774cdec9e3 | ||
|
|
1673603b02 | ||
|
|
19fa00e220 | ||
|
|
db93eb8d31 | ||
|
|
06ba9b2bff | ||
|
|
94ad7aed55 | ||
|
|
f4b69449b2 | ||
|
|
043938670a | ||
|
|
7bdde246bc | ||
|
|
9862d9fe56 | ||
|
|
ea1e477c31 | ||
|
|
150e627811 | ||
|
|
dcc59a9bc6 | ||
|
|
295bf5a81c | ||
|
|
ead6f2ac46 | ||
|
|
e8a26059d5 | ||
|
|
9471ee3e85 | ||
|
|
543d620384 | ||
|
|
975034c589 | ||
|
|
d4fa828f8c | ||
|
|
71e90bd55f | ||
|
|
ceb54e29ec | ||
|
|
3042791a90 | ||
|
|
9ca5b11e20 | ||
|
|
a8243acc5a | ||
|
|
07fa3e6e10 | ||
|
|
cb0e3fdac2 | ||
|
|
7bf30a7cc2 | ||
|
|
f5c92d09af | ||
|
|
e18626105b | ||
|
|
b587530437 | ||
|
|
9a1260db68 | ||
|
|
7c33bc05f2 | ||
|
|
9de0a63be2 | ||
|
|
f3c94c57d6 | ||
|
|
9db627770b | ||
|
|
75b988c77a | ||
|
|
2b7f67436e | ||
|
|
ed5455f309 | ||
|
|
452d410843 | ||
|
|
c95373b85e | ||
|
|
6be10481d5 | ||
|
|
4ce0e66d90 | ||
|
|
0f9e1b6cf2 | ||
|
|
dc8d3d59d2 | ||
|
|
1e5c5b5706 | ||
|
|
b68144ebb0 | ||
|
|
3cae1d85cf | ||
|
|
ca0cd9eab1 | ||
|
|
2bdc0e0779 | ||
|
|
93dca56c3f | ||
|
|
8c4f50fc52 | ||
|
|
13864f1968 | ||
|
|
d11e8f9313 | ||
|
|
02a12a208c | ||
|
|
b977627895 | ||
|
|
edf8e6179c | ||
|
|
3148af05b3 | ||
|
|
4029933ada | ||
|
|
95d3f9809c | ||
|
|
8b040489e2 | ||
|
|
db9700c8ee | ||
|
|
11e30f33b9 | ||
|
|
95f52b61d0 | ||
|
|
a4012f2189 | ||
|
|
e1c5e65c1e | ||
|
|
a3d78b3ca0 | ||
|
|
9dc5631574 | ||
|
|
c5a59508d2 | ||
|
|
ab1d57492c | ||
|
|
9a81a30feb | ||
|
|
d6d21b03dd | ||
|
|
b9a05e8732 | ||
|
|
440707e19a | ||
|
|
e0dc1a56b2 | ||
|
|
f45afd954d | ||
|
|
664da22329 | ||
|
|
73a6ac126c | ||
|
|
878e26d4ca | ||
|
|
e12ed0936b | ||
|
|
8edb74f173 | ||
|
|
1a42c6e6f3 | ||
|
|
d21497bce3 | ||
|
|
574e3c5700 | ||
|
|
c70293b447 | ||
|
|
adeff08827 | ||
|
|
8bd40de41d | ||
|
|
ac6eacea82 | ||
|
|
83c2a5ca94 | ||
|
|
fbd2290d29 | ||
|
|
97fcf88c61 | ||
|
|
a01afef599 | ||
|
|
28fa244fe5 | ||
|
|
ad3885e0be | ||
|
|
0fb7ee2992 | ||
|
|
f6792cad39 | ||
|
|
bed6e9192a | ||
|
|
aaa213a765 | ||
|
|
207629a9bb |
@@ -38,6 +38,28 @@ jobs:
|
||||
--exclude='.gitea' \
|
||||
"${CLONE_DIR}/" "${RELEASE_DIR}/"
|
||||
|
||||
echo "=== 2.5 Ensure .env secrets ==="
|
||||
# .env 不随 git 走,线上凭据由服务器本地 shared/.env 提供。
|
||||
# 若缺失则从 CI secrets 自动补齐;已存在的变量保留,避免覆盖线上配置/导致 session 失效。
|
||||
mkdir -p "${DEPLOY_BASE}/shared"
|
||||
SHARED_ENV="${DEPLOY_BASE}/shared/.env"
|
||||
touch "${SHARED_ENV}"
|
||||
ensure_env_var() {
|
||||
key="$1"; val="$2"
|
||||
if [ -z "$val" ]; then
|
||||
echo " ! ${key} 未提供(CI secret 为空),跳过写入"
|
||||
return
|
||||
fi
|
||||
if ! grep -q "^${key}=" "${SHARED_ENV}"; then
|
||||
echo "${key}=${val}" >> "${SHARED_ENV}"
|
||||
echo " + 已写入 ${key}"
|
||||
else
|
||||
echo " = ${key} 已存在,保留原值"
|
||||
fi
|
||||
}
|
||||
ensure_env_var "DB_PASSWORD" "${{ secrets.OPC_DB_PASSWORD }}"
|
||||
ensure_env_var "SECRET_KEY" "${{ secrets.OPC_SECRET_KEY }}"
|
||||
|
||||
echo "=== 3. Link shared resources ==="
|
||||
mkdir -p "${RELEASE_DIR}/data"
|
||||
# .env from shared dir (not in git)
|
||||
|
||||
@@ -53,3 +53,4 @@ curl http://127.0.0.1:5177/api/health
|
||||
- 产品:慰心斋产品路线图中的 5 个产品版本
|
||||
- 财务:首版财务样例和原财务 manager 合并方向
|
||||
|
||||
# test trigger
|
||||
|
||||
@@ -39,12 +39,22 @@ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------- 数据库连接 ----------
|
||||
def _require_env(name):
|
||||
"""强制读取必需环境变量,缺失即抛出清晰错误(禁止弱默认降级)"""
|
||||
val = os.environ.get(name)
|
||||
if not val:
|
||||
raise RuntimeError(
|
||||
f"缺少必需的环境变量 {name},请在 .env 中显式配置(关键凭据禁止硬编码兜底)"
|
||||
)
|
||||
return val
|
||||
|
||||
|
||||
def db():
|
||||
return mysql.connector.connect(
|
||||
host=os.environ.get("DB_HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("DB_PORT", "3306")),
|
||||
user=os.environ.get("DB_USER", "opc"),
|
||||
password=os.environ.get("DB_PASSWORD", "opc123456"),
|
||||
password=_require_env("DB_PASSWORD"),
|
||||
database=os.environ.get("DB_NAME", "opc"),
|
||||
charset="utf8mb4",
|
||||
collation="utf8mb4_unicode_ci",
|
||||
@@ -55,10 +65,44 @@ def now():
|
||||
return datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
def _convert_placeholders(sql):
|
||||
"""将 ? 占位符转为 MySQL 的 %s,仅替换字符串字面量之外的 ?。
|
||||
|
||||
避免 `sql.replace('?', '%s')` 的全局替换缺陷:当 SQL 字面量或 LIKE 模式
|
||||
中恰好含 `?` 时会误替换、导致参数错位。这里用状态机跳过单/双引号内的内容。
|
||||
"""
|
||||
out = []
|
||||
in_single = in_double = False
|
||||
i, n = 0, len(sql)
|
||||
while i < n:
|
||||
ch = sql[i]
|
||||
if ch == "\\" and i + 1 < n: # 跳过转义序列
|
||||
out.append(ch)
|
||||
out.append(sql[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == "'" and not in_double:
|
||||
in_single = not in_single
|
||||
elif ch == '"' and not in_single:
|
||||
in_double = not in_double
|
||||
out.append("%s" if (ch == "?" and not in_single and not in_double) else ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _exec(conn, sql, args=()):
|
||||
"""执行 SQL,自动将 ? 转为 MySQL 的 %s"""
|
||||
"""执行 SQL,将 ? 占位符安全地转为 MySQL 的 %s。
|
||||
|
||||
占位符数量与参数数量必须一致,否则立即报错,防止静默错位。
|
||||
"""
|
||||
converted = _convert_placeholders(sql)
|
||||
n_ph = converted.count("%s")
|
||||
if n_ph != len(args):
|
||||
raise ValueError(
|
||||
f"SQL 占位符数量({n_ph})与参数数量({len(args)})不匹配,请检查 SQL 与传参: {sql}"
|
||||
)
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute(sql.replace("?", "%s"), args)
|
||||
cur.execute(converted, args)
|
||||
return cur
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ app = Flask(
|
||||
template_folder=str(ROOT / "templates"),
|
||||
static_folder=str(ROOT / "static"),
|
||||
)
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "opc-dev-secret-2026")
|
||||
# secret_key 必须从环境变量注入,禁止硬编码兜底——否则 session 可被伪造。
|
||||
_secret_key = os.environ.get("SECRET_KEY")
|
||||
if not _secret_key:
|
||||
raise RuntimeError(
|
||||
"缺少必需的环境变量 SECRET_KEY,请在 .env 中配置(建议: "
|
||||
"python3 -c 'import secrets;print(secrets.token_hex(32))')"
|
||||
)
|
||||
app.secret_key = _secret_key
|
||||
app.config["TEMPLATES_AUTO_RELOAD"] = True
|
||||
app.register_blueprint(bp)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ def run_migrations():
|
||||
"""
|
||||
from migrations.tables import migrate_create_tables
|
||||
from migrations.columns import migrate_add_columns
|
||||
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status, migrate_rename_xuehui_to_xueshu, migrate_rename_zong_to_overview
|
||||
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard, migrate_fix_expense_status, migrate_rename_xuehui_to_xueshu, migrate_rename_zong_to_overview, migrate_add_test_tenant
|
||||
from migrations.data_split import migrate_data_split
|
||||
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
|
||||
|
||||
@@ -30,6 +30,7 @@ def run_migrations():
|
||||
migrate_fix_expense_status()
|
||||
migrate_rename_xuehui_to_xueshu()
|
||||
migrate_rename_zong_to_overview()
|
||||
migrate_add_test_tenant()
|
||||
migrate_data_split()
|
||||
migrate_seed_users()
|
||||
migrate_seed_demo_data()
|
||||
|
||||
@@ -157,3 +157,26 @@ def migrate_rename_zong_to_overview():
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def migrate_add_test_tenant():
|
||||
"""为所有 opc_owner 用户添加 测试·无界 工作台权限"""
|
||||
from db import db, mysql
|
||||
|
||||
conn = db()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SELECT id, username FROM users WHERE role = 'opc_owner'")
|
||||
users = cur.fetchall()
|
||||
added = 0
|
||||
for u in users:
|
||||
cur.execute("SELECT id FROM user_tenants WHERE user_id = %s AND tenant = %s", (u["id"], "测试·无界"))
|
||||
if not cur.fetchone():
|
||||
cur.execute("INSERT INTO user_tenants (user_id, tenant) VALUES (%s, %s)", (u["id"], "测试·无界"))
|
||||
added += 1
|
||||
if added:
|
||||
conn.commit()
|
||||
print(f"[migrate] 为 {added} 个 opc_owner 用户添加了 '测试·无界' 工作台权限")
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -14,7 +14,7 @@ from helpers import add_file_index, attach_common, monthly_finance
|
||||
|
||||
bp = Blueprint("api", __name__)
|
||||
|
||||
ALL_TENANTS = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界"]
|
||||
ALL_TENANTS = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界", "测试·无界"]
|
||||
|
||||
TABLES = {
|
||||
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
|
||||
@@ -75,7 +75,7 @@ def auth_login():
|
||||
session["display_name"] = user["display_name"]
|
||||
session["role"] = user["role"]
|
||||
if user["role"] == "admin":
|
||||
session["tenants"] = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界"]
|
||||
session["tenants"] = ["总览", "科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学术·无界", "测试·无界"]
|
||||
else:
|
||||
ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],))
|
||||
session["tenants"] = [x["tenant"] for x in ut]
|
||||
@@ -126,10 +126,8 @@ def auth_me():
|
||||
if "user_id" not in session:
|
||||
return jsonify({"logged_in": False})
|
||||
tenants = session.get("tenants", [])
|
||||
tenants = [t if t != "总工作台" else "总览" for t in tenants]
|
||||
tenants = [t if t != "总工作台" else "科普·无界" for t in tenants]
|
||||
tenants = [t if t != "学会·无界" else "学术·无界" for t in tenants]
|
||||
if "总览" not in tenants:
|
||||
tenants = ["总览"] + tenants
|
||||
return jsonify({
|
||||
"logged_in": True,
|
||||
"user": {"id": session["user_id"], "username": session["username"], "display_name": session["display_name"], "role": session["role"]},
|
||||
@@ -263,47 +261,12 @@ def bootstrap():
|
||||
_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]
|
||||
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
|
||||
if "总览" not in allowed:
|
||||
allowed = ["总览"] + allowed
|
||||
if tenant not in allowed:
|
||||
tenant = allowed[0]
|
||||
tenant = allowed[0] if allowed else ""
|
||||
conn = db()
|
||||
try:
|
||||
if tenant == "总览":
|
||||
real_tenants = [t for t in allowed if t != "总览"]
|
||||
all_metrics = []
|
||||
all_monthly = []
|
||||
all_recent = []
|
||||
for t in real_tenants:
|
||||
t_ops = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [t]))
|
||||
t_sales = attach_common(conn, "sales", rows(conn, "SELECT * FROM sales_leads WHERE tenant=? ORDER BY id DESC", [t]))
|
||||
t_products = attach_common(conn, "products", rows(conn, "SELECT * FROM product_versions WHERE tenant=? ORDER BY id DESC", [t]))
|
||||
t_proposals = attach_common(conn, "proposals", rows(conn, "SELECT * FROM business_proposals WHERE tenant=? ORDER BY id DESC", [t]))
|
||||
t_pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [t])
|
||||
t_expense = rows(conn, "SELECT * FROM expense_records WHERE tenant=? ORDER BY id DESC", [t])
|
||||
t_metrics = compute_metrics(t_pfs, t_ops, t_sales, t_products, t_proposals, t_expense, _year, conn, t)
|
||||
all_metrics.append(t_metrics)
|
||||
all_monthly.append(monthly_finance(conn, t))
|
||||
all_recent.extend(rows(conn, "SELECT * FROM follow_up_records WHERE tenant=? ORDER BY id DESC LIMIT 4", [t]))
|
||||
agg = {}
|
||||
for key in ["total_projects","total_proposals","total_products","upcoming_products","signed_amount","signed_annual","signed_q2","signed_month","signed_prev_q","signed_prev_month","revenue_annual","revenue_q2","monthly_revenue","revenue_prev_q","revenue_prev_month","gross_annual","gross_q2","monthly_net_profit","gross_prev_q","gross_prev_month","payment_annual","payment_q2","payment_month","payment_prev_q","payment_prev_month","cost_annual","cost_q2","cost_month","cost_prev_q","cost_prev_month","expense_annual","expense_q2","expense_month","expense_prev_q","expense_prev_month","paid_annual","paid_q2","paid_month","paid_prev_q","paid_prev_month","cashflow_annual","cashflow_q2","cashflow_month","cashflow_prev_q","cashflow_prev_month","expense_paid_annual","expense_paid_q2","expense_paid_month","expense_paid_prev_q","expense_paid_prev_month","dept_cf_annual","dept_cf_q2","dept_cf_month","dept_cf_prev_q","dept_cf_prev_month","profit_annual","profit_q2","profit_month","profit_prev_q","profit_prev_month","proj_expense_annual","proj_expense_q2","proj_expense_month","proj_expense_prev_q","proj_expense_prev_month"]:
|
||||
agg[key] = sum(m.get(key, 0) for m in all_metrics)
|
||||
merged_monthly = []
|
||||
for i in range(12):
|
||||
m = {"month": all_monthly[0][i]["month"] if all_monthly and len(all_monthly[0]) > i else f"{_year}-{i+1:02d}"}
|
||||
for field in ["revenue","gross","payment","cost","paid","sign"]:
|
||||
m[field] = sum(tl[i][field] if i < len(tl) else 0 for tl in all_monthly)
|
||||
merged_monthly.append(m)
|
||||
summary = {
|
||||
"project_name": "总览",
|
||||
"metrics": agg,
|
||||
"recent": sorted(all_recent, key=lambda x: x.get("id", 0), reverse=True)[:8],
|
||||
"risks": [],
|
||||
}
|
||||
return jsonify({"summary": summary, "financeMonthly": merged_monthly, "tenant": tenant, "tenants": allowed})
|
||||
|
||||
# 普通工作台:只查 summary 所需的数据(project_finances + operations + sales + products + proposals + expense)
|
||||
pfs = rows(conn, "SELECT * FROM project_finances WHERE tenant=? ORDER BY id DESC", [tenant])
|
||||
operations = attach_common(conn, "operations", rows(conn, "SELECT * FROM operation_projects WHERE tenant=? ORDER BY id ASC", [tenant]))
|
||||
@@ -761,8 +724,542 @@ def delete_file(file_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
# ---------- 总览部门损益矩阵 API ----------
|
||||
|
||||
@bp.route("/api/overview/dept-matrix")
|
||||
@login_required
|
||||
def overview_dept_matrix():
|
||||
"""返回各工作台的损益数据矩阵(按年/季/月筛选)"""
|
||||
conn = db()
|
||||
_year = int(request.args.get("year", date.today().year))
|
||||
view = request.args.get("view", "monthly")
|
||||
|
||||
# 12个月数组
|
||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||
|
||||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||||
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_gross": [0]*12, "dept_expense": [0]*12, "profit": [0]*12}}
|
||||
|
||||
for tenant in tenants:
|
||||
# 项目毛利(从 budget_data JSON)
|
||||
pfs = rows(conn, "SELECT budget_data FROM plan_finances WHERE tenant=?", [tenant])
|
||||
gross = [0]*12
|
||||
for pf in pfs:
|
||||
try:
|
||||
bd = json.loads(pf.get("budget_data") or "[]")
|
||||
except:
|
||||
bd = []
|
||||
for b in bd:
|
||||
m = (b.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
gross[i] += float(b.get("gross") or 0)
|
||||
|
||||
# 部门费用(从 plan_expense_records)
|
||||
expense = [0]*12
|
||||
exps = rows(conn, "SELECT expense_month, amount FROM plan_expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
|
||||
for e in exps:
|
||||
m = (e.get("expense_month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
expense[i] += float(e.get("amount") or 0)
|
||||
|
||||
profit = [gross[i] - expense[i] for i in range(12)]
|
||||
|
||||
result["data"][tenant] = {
|
||||
"project_gross": gross,
|
||||
"dept_expense": expense,
|
||||
"profit": profit
|
||||
}
|
||||
|
||||
for i in range(12):
|
||||
result["total"]["project_gross"][i] += gross[i]
|
||||
result["total"]["dept_expense"][i] += expense[i]
|
||||
result["total"]["profit"][i] += profit[i]
|
||||
|
||||
# 按视图聚合
|
||||
if view == "quarterly":
|
||||
def to_quarterly(arr12):
|
||||
qr = [(0,1,2),(3,4,5),(6,7,8),(9,10,11)]
|
||||
return [arr12[q[0]]+arr12[q[1]]+arr12[q[2]] for q in qr]
|
||||
result["months"] = ["Q1","Q2","Q3","Q4"]
|
||||
for t in tenants:
|
||||
for key in ["project_gross","dept_expense","profit"]:
|
||||
result["data"][t][key] = to_quarterly(result["data"][t][key])
|
||||
for key in ["project_gross","dept_expense","profit"]:
|
||||
result["total"][key] = to_quarterly(result["total"][key])
|
||||
elif view == "monthly":
|
||||
sel_month = request.args.get("month", "")
|
||||
if sel_month and sel_month in months:
|
||||
mi = months.index(sel_month)
|
||||
result["months"] = [sel_month]
|
||||
for t in tenants:
|
||||
for key in ["project_gross","dept_expense","profit"]:
|
||||
result["data"][t][key] = [result["data"][t][key][mi]]
|
||||
for key in ["project_gross","dept_expense","profit"]:
|
||||
result["total"][key] = [result["total"][key][mi]]
|
||||
# else: yearly (default) — keep all 12 months
|
||||
|
||||
# Compute annual totals
|
||||
result["annual"] = {}
|
||||
for t in tenants:
|
||||
d = result["data"][t]
|
||||
result["annual"][t] = {
|
||||
"project_gross": sum(d["project_gross"]),
|
||||
"dept_expense": sum(d["dept_expense"]),
|
||||
"profit": sum(d["profit"])
|
||||
}
|
||||
result["annual"]["total"] = {
|
||||
"project_gross": sum(result["total"]["project_gross"]),
|
||||
"dept_expense": sum(result["total"]["dept_expense"]),
|
||||
"profit": sum(result["total"]["profit"])
|
||||
}
|
||||
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
@bp.route("/api/overview/finance-dept-matrix")
|
||||
@login_required
|
||||
def overview_finance_dept_matrix():
|
||||
"""返回各工作台已发生损益数据矩阵"""
|
||||
conn = db()
|
||||
_year = date.today().year
|
||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||||
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_gross": [0]*12, "dept_expense": [0]*12, "profit": [0]*12}}
|
||||
|
||||
for tenant in tenants:
|
||||
pfs = rows(conn, "SELECT budget_data FROM project_finances WHERE tenant=?", [tenant])
|
||||
gross = [0]*12
|
||||
for pf in pfs:
|
||||
try:
|
||||
bd = json.loads(pf.get("budget_data") or "[]")
|
||||
except:
|
||||
bd = []
|
||||
for b in bd:
|
||||
m = (b.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
gross[i] += float(b.get("gross") or 0)
|
||||
|
||||
expense = [0]*12
|
||||
exps = rows(conn, "SELECT expense_month, amount FROM expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
|
||||
for e in exps:
|
||||
m = (e.get("expense_month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
expense[i] += float(e.get("amount") or 0)
|
||||
|
||||
profit = [gross[i] - expense[i] for i in range(12)]
|
||||
result["data"][tenant] = {"project_gross": gross, "dept_expense": expense, "profit": profit}
|
||||
for i in range(12):
|
||||
result["total"]["project_gross"][i] += gross[i]
|
||||
result["total"]["dept_expense"][i] += expense[i]
|
||||
result["total"]["profit"][i] += profit[i]
|
||||
|
||||
result["annual"] = {}
|
||||
for t in tenants:
|
||||
d = result["data"][t]
|
||||
result["annual"][t] = {"project_gross": sum(d["project_gross"]), "dept_expense": sum(d["dept_expense"]), "profit": sum(d["profit"])}
|
||||
result["annual"]["total"] = {"project_gross": sum(result["total"]["project_gross"]), "dept_expense": sum(result["total"]["dept_expense"]), "profit": sum(result["total"]["profit"])}
|
||||
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/api/overview/dept-cf-matrix")
|
||||
@login_required
|
||||
def overview_dept_cf_matrix():
|
||||
"""返回各工作台预算现金流数据矩阵"""
|
||||
conn = db()
|
||||
_year = date.today().year
|
||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||||
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_cf": [0]*12, "dept_cf": [0]*12, "bl_cf": [0]*12}}
|
||||
|
||||
for tenant in tenants:
|
||||
pfs = rows(conn, "SELECT budget_data, expense_data FROM plan_finances WHERE tenant=?", [tenant])
|
||||
project_cf = [0]*12
|
||||
for pf in pfs:
|
||||
try: bd = json.loads(pf.get("budget_data") or "[]")
|
||||
except: bd = []
|
||||
try: ed = json.loads(pf.get("expense_data") or "[]")
|
||||
except: ed = []
|
||||
for b in bd:
|
||||
m = (b.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
project_cf[i] += float(b.get("payment") or 0)
|
||||
for e in ed:
|
||||
m = (e.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
project_cf[i] -= float(e.get("paid") or 0)
|
||||
|
||||
dept_cf = [0]*12
|
||||
exps = rows(conn, "SELECT expense_month, incurred_amount FROM plan_expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
|
||||
for e in exps:
|
||||
m = (e.get("expense_month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
dept_cf[i] += float(e.get("incurred_amount") or 0)
|
||||
|
||||
bl_cf = [project_cf[i] - dept_cf[i] for i in range(12)]
|
||||
result["data"][tenant] = {"project_cf": project_cf, "dept_cf": dept_cf, "bl_cf": bl_cf}
|
||||
for i in range(12):
|
||||
result["total"]["project_cf"][i] += project_cf[i]
|
||||
result["total"]["dept_cf"][i] += dept_cf[i]
|
||||
result["total"]["bl_cf"][i] += bl_cf[i]
|
||||
|
||||
result["annual"] = {}
|
||||
for t in tenants:
|
||||
d = result["data"][t]
|
||||
result["annual"][t] = {"project_cf": sum(d["project_cf"]), "dept_cf": sum(d["dept_cf"]), "bl_cf": sum(d["bl_cf"])}
|
||||
result["annual"]["total"] = {"project_cf": sum(result["total"]["project_cf"]), "dept_cf": sum(result["total"]["dept_cf"]), "bl_cf": sum(result["total"]["bl_cf"])}
|
||||
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/api/overview/finance-dept-cf-matrix")
|
||||
@login_required
|
||||
def overview_finance_dept_cf_matrix():
|
||||
"""返回各工作台已发生现金流数据矩阵"""
|
||||
conn = db()
|
||||
_year = date.today().year
|
||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||||
result = {"tenants": tenants, "months": months, "data": {}, "total": {"project_cf": [0]*12, "dept_cf": [0]*12, "bl_cf": [0]*12}}
|
||||
|
||||
for tenant in tenants:
|
||||
pfs = rows(conn, "SELECT budget_data, expense_data FROM project_finances WHERE tenant=?", [tenant])
|
||||
project_cf = [0]*12
|
||||
for pf in pfs:
|
||||
try: bd = json.loads(pf.get("budget_data") or "[]")
|
||||
except: bd = []
|
||||
try: ed = json.loads(pf.get("expense_data") or "[]")
|
||||
except: ed = []
|
||||
for b in bd:
|
||||
m = (b.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
project_cf[i] += float(b.get("payment") or 0)
|
||||
for e in ed:
|
||||
m = (e.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
project_cf[i] -= float(e.get("paid") or 0)
|
||||
|
||||
dept_cf = [0]*12
|
||||
exps = rows(conn, "SELECT expense_month, incurred_amount FROM expense_records WHERE tenant=? AND expense_month LIKE %s", [tenant, f"{_year}-%"])
|
||||
for e in exps:
|
||||
m = (e.get("expense_month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
dept_cf[i] += float(e.get("incurred_amount") or 0)
|
||||
|
||||
bl_cf = [project_cf[i] - dept_cf[i] for i in range(12)]
|
||||
result["data"][tenant] = {"project_cf": project_cf, "dept_cf": dept_cf, "bl_cf": bl_cf}
|
||||
for i in range(12):
|
||||
result["total"]["project_cf"][i] += project_cf[i]
|
||||
result["total"]["dept_cf"][i] += dept_cf[i]
|
||||
result["total"]["bl_cf"][i] += bl_cf[i]
|
||||
|
||||
result["annual"] = {}
|
||||
for t in tenants:
|
||||
d = result["data"][t]
|
||||
result["annual"][t] = {"project_cf": sum(d["project_cf"]), "dept_cf": sum(d["dept_cf"]), "bl_cf": sum(d["bl_cf"])}
|
||||
result["annual"]["total"] = {"project_cf": sum(result["total"]["project_cf"]), "dept_cf": sum(result["total"]["dept_cf"]), "bl_cf": sum(result["total"]["bl_cf"])}
|
||||
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/api/overview/dept-project-matrix")
|
||||
@login_required
|
||||
def overview_dept_project_matrix():
|
||||
"""返回各工作台预算项目经营详情矩阵"""
|
||||
conn = db()
|
||||
_year = date.today().year
|
||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||||
keys = ["sign","revenue","gross","cost","paid","payment","cashflow"]
|
||||
result = {"tenants": tenants, "months": months, "data": {}, "total": {k: [0]*12 for k in keys}}
|
||||
|
||||
for tenant in tenants:
|
||||
tdata = {k: [0]*12 for k in keys}
|
||||
pfs = rows(conn, "SELECT sign_amount, sign_month, budget_data, expense_data FROM plan_finances WHERE tenant=?", [tenant])
|
||||
for pf in pfs:
|
||||
sm = (pf.get("sign_month") or "")[:7]
|
||||
if sm in months:
|
||||
i = months.index(sm)
|
||||
tdata["sign"][i] += float(pf.get("sign_amount") or 0)
|
||||
try: bd = json.loads(pf.get("budget_data") or "[]")
|
||||
except: bd = []
|
||||
try: ed = json.loads(pf.get("expense_data") or "[]")
|
||||
except: ed = []
|
||||
for b in bd:
|
||||
m = (b.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
tdata["revenue"][i] += float(b.get("rev") or 0)
|
||||
tdata["gross"][i] += float(b.get("gross") or 0)
|
||||
tdata["payment"][i] += float(b.get("payment") or 0)
|
||||
for e in ed:
|
||||
m = (e.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
tdata["cost"][i] += float(e.get("cost") or 0)
|
||||
tdata["paid"][i] += float(e.get("paid") or 0)
|
||||
for i in range(12):
|
||||
tdata["cashflow"][i] = tdata["payment"][i] - tdata["paid"][i]
|
||||
result["data"][tenant] = tdata
|
||||
for k in keys:
|
||||
for i in range(12):
|
||||
result["total"][k][i] += tdata[k][i]
|
||||
|
||||
result["annual"] = {}
|
||||
for t in tenants:
|
||||
result["annual"][t] = {k: sum(result["data"][t][k]) for k in keys}
|
||||
result["annual"]["total"] = {k: sum(result["total"][k]) for k in keys}
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/api/overview/finance-dept-project-matrix")
|
||||
@login_required
|
||||
def overview_finance_dept_project_matrix():
|
||||
"""返回各工作台已发生项目经营详情矩阵"""
|
||||
conn = db()
|
||||
_year = date.today().year
|
||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||
tenants = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||||
keys = ["sign","revenue","gross","cost","paid","payment","cashflow"]
|
||||
result = {"tenants": tenants, "months": months, "data": {}, "total": {k: [0]*12 for k in keys}}
|
||||
|
||||
for tenant in tenants:
|
||||
tdata = {k: [0]*12 for k in keys}
|
||||
pfs = rows(conn, "SELECT sign_amount, sign_month, budget_data, expense_data FROM project_finances WHERE tenant=?", [tenant])
|
||||
for pf in pfs:
|
||||
sm = (pf.get("sign_month") or "")[:7]
|
||||
if sm in months:
|
||||
i = months.index(sm)
|
||||
tdata["sign"][i] += float(pf.get("sign_amount") or 0)
|
||||
try: bd = json.loads(pf.get("budget_data") or "[]")
|
||||
except: bd = []
|
||||
try: ed = json.loads(pf.get("expense_data") or "[]")
|
||||
except: ed = []
|
||||
for b in bd:
|
||||
m = (b.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
tdata["revenue"][i] += float(b.get("rev") or 0)
|
||||
tdata["gross"][i] += float(b.get("gross") or 0)
|
||||
tdata["payment"][i] += float(b.get("payment") or 0)
|
||||
for e in ed:
|
||||
m = (e.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
tdata["cost"][i] += float(e.get("cost") or 0)
|
||||
tdata["paid"][i] += float(e.get("paid") or 0)
|
||||
for i in range(12):
|
||||
tdata["cashflow"][i] = tdata["payment"][i] - tdata["paid"][i]
|
||||
result["data"][tenant] = tdata
|
||||
for k in keys:
|
||||
for i in range(12):
|
||||
result["total"][k][i] += tdata[k][i]
|
||||
|
||||
result["annual"] = {}
|
||||
for t in tenants:
|
||||
result["annual"][t] = {k: sum(result["data"][t][k]) for k in keys}
|
||||
result["annual"]["total"] = {k: sum(result["total"][k]) for k in keys}
|
||||
conn.close()
|
||||
return jsonify(result)
|
||||
|
||||
# ---------- health ----------
|
||||
|
||||
@bp.route("/api/health")
|
||||
def health():
|
||||
return jsonify({"ok": True, "service": "opc-manager"})
|
||||
|
||||
# ---------- 总览聚合 API ----------
|
||||
|
||||
OVERVIEW_TENANTS = ["学术·无界", "科普·无界", "科研·无界", "医患·无界", "MCN·无界"]
|
||||
|
||||
|
||||
def _aggregate_monthly_from_json(conn, table, tenants):
|
||||
"""聚合多个 tenant 的表 (含 budget_data/expense_data JSON) 到 12 个月数组"""
|
||||
_year = date.today().year
|
||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||
rev, gross, payment, cost, paid = [0]*12, [0]*12, [0]*12, [0]*12, [0]*12
|
||||
sign_monthly = [0]*12
|
||||
sign_total = 0
|
||||
sign_cnt = 0
|
||||
|
||||
for tenant in tenants:
|
||||
pfs = rows(conn, f"SELECT sign_amount, sign_month, budget_data, expense_data FROM {table} WHERE tenant=?", [tenant])
|
||||
for pf in pfs:
|
||||
sm = pf.get("sign_month") or ""
|
||||
if sm.startswith(str(_year)):
|
||||
sign_total += float(pf.get("sign_amount") or 0)
|
||||
sign_cnt += 1
|
||||
# 签单按月拆分
|
||||
sm7 = sm[:7]
|
||||
if sm7 in months:
|
||||
sign_monthly[months.index(sm7)] += float(pf.get("sign_amount") or 0)
|
||||
try:
|
||||
bd = json.loads(pf.get("budget_data") or "[]")
|
||||
except:
|
||||
bd = []
|
||||
try:
|
||||
ed = json.loads(pf.get("expense_data") or "[]")
|
||||
except:
|
||||
ed = []
|
||||
for b in bd:
|
||||
m = (b.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
rev[i] += float(b.get("rev") or 0)
|
||||
gross[i] += float(b.get("gross") or 0)
|
||||
payment[i] += float(b.get("payment") or 0)
|
||||
for e in ed:
|
||||
m = (e.get("month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
cost[i] += float(e.get("cost") or 0)
|
||||
paid[i] += float(e.get("paid") or 0)
|
||||
|
||||
return {
|
||||
"months": months,
|
||||
"rev": rev, "gross": gross, "payment": payment, "cost": cost, "paid": paid,
|
||||
"cashflow": [payment[i] - paid[i] for i in range(12)],
|
||||
"sign_total": sign_total, "sign_cnt": sign_cnt,
|
||||
"sign_monthly": sign_monthly,
|
||||
}
|
||||
|
||||
|
||||
def _aggregate_expenses(conn, table, tenants):
|
||||
"""聚合多个 tenant 的平台费用到 12 个月"""
|
||||
_year = date.today().year
|
||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||
amount = [0]*12
|
||||
incurred = [0]*12
|
||||
for tenant in tenants:
|
||||
exps = rows(conn, f"SELECT expense_month, amount, incurred_amount FROM {table} WHERE tenant=? AND expense_month LIKE ?", [tenant, f"{_year}-%"])
|
||||
for e in exps:
|
||||
m = (e.get("expense_month") or "")[:7]
|
||||
if m in months:
|
||||
i = months.index(m)
|
||||
amount[i] += float(e.get("amount") or 0)
|
||||
incurred[i] += float(e.get("incurred_amount") or 0)
|
||||
return amount, incurred
|
||||
|
||||
|
||||
def _build_overview_result(agg, exp_amount, exp_incurred):
|
||||
"""根据聚合数据构建前端使用的 overview JSON"""
|
||||
months = agg["months"]
|
||||
rev = agg["rev"]
|
||||
gross = agg["gross"]
|
||||
payment = agg["payment"]
|
||||
cost = agg["cost"]
|
||||
paid = agg["paid"]
|
||||
cf = agg["cashflow"]
|
||||
|
||||
# 部门损益情况
|
||||
dept_profit = [gross[i] - exp_amount[i] for i in range(12)]
|
||||
dept = {
|
||||
"project_gross": gross,
|
||||
"dept_expense": exp_amount,
|
||||
"profit": dept_profit,
|
||||
"annual": [sum(gross), sum(exp_amount), sum(dept_profit)],
|
||||
}
|
||||
|
||||
# 部门现金流
|
||||
bl_cf = [cf[i] - exp_incurred[i] for i in range(12)]
|
||||
dept_cf = {
|
||||
"project_cf": cf,
|
||||
"dept_cf_item": exp_incurred,
|
||||
"bl_cf": bl_cf,
|
||||
"annual": [sum(cf), sum(exp_incurred), sum(bl_cf)],
|
||||
}
|
||||
|
||||
# 项目经营详情
|
||||
project = {
|
||||
"sign": agg["sign_total"],
|
||||
"sign_monthly": agg["sign_monthly"],
|
||||
"revenue": rev,
|
||||
"gross": gross,
|
||||
"cost": cost,
|
||||
"paid": paid,
|
||||
"payment": payment,
|
||||
"cashflow": cf,
|
||||
"annual": [agg["sign_total"], sum(rev), sum(gross), sum(cost), sum(paid), sum(payment), sum(cf)],
|
||||
}
|
||||
|
||||
return {"dept": dept, "dept_cf": dept_cf, "project": project, "months": months}
|
||||
|
||||
|
||||
def _quarterly_from_monthly(arr12):
|
||||
"""将 12 个月数组转换为 4 个季度"""
|
||||
q_ranges = [(0,1,2),(3,4,5),(6,7,8),(9,10,11)]
|
||||
return [arr12[q[0]]+arr12[q[1]]+arr12[q[2]] for q in q_ranges]
|
||||
|
||||
|
||||
@bp.route("/api/overview/plan-monthly")
|
||||
@login_required
|
||||
def overview_plan_monthly():
|
||||
conn = db()
|
||||
agg = _aggregate_monthly_from_json(conn, "plan_finances", OVERVIEW_TENANTS)
|
||||
exp_amount, exp_incurred = _aggregate_expenses(conn, "plan_expense_records", OVERVIEW_TENANTS)
|
||||
conn.close()
|
||||
return jsonify(_build_overview_result(agg, exp_amount, exp_incurred))
|
||||
|
||||
|
||||
@bp.route("/api/overview/plan-quarterly")
|
||||
@login_required
|
||||
def overview_plan_quarterly():
|
||||
conn = db()
|
||||
agg = _aggregate_monthly_from_json(conn, "plan_finances", OVERVIEW_TENANTS)
|
||||
exp_amount, exp_incurred = _aggregate_expenses(conn, "plan_expense_records", OVERVIEW_TENANTS)
|
||||
conn.close()
|
||||
# Convert monthly to quarterly
|
||||
result = _build_overview_result(agg, exp_amount, exp_incurred)
|
||||
for key in ["sign_monthly","revenue","gross","payment","cost","paid","cashflow"]:
|
||||
result["project"][key] = _quarterly_from_monthly(result["project"][key])
|
||||
result["dept"]["project_gross"] = _quarterly_from_monthly(result["dept"]["project_gross"])
|
||||
result["dept"]["dept_expense"] = _quarterly_from_monthly(result["dept"]["dept_expense"])
|
||||
result["dept"]["profit"] = _quarterly_from_monthly(result["dept"]["profit"])
|
||||
result["dept_cf"]["project_cf"] = _quarterly_from_monthly(result["dept_cf"]["project_cf"])
|
||||
result["dept_cf"]["dept_cf_item"] = _quarterly_from_monthly(result["dept_cf"]["dept_cf_item"])
|
||||
result["dept_cf"]["bl_cf"] = _quarterly_from_monthly(result["dept_cf"]["bl_cf"])
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/api/overview/finance-monthly")
|
||||
@login_required
|
||||
def overview_finance_monthly():
|
||||
conn = db()
|
||||
agg = _aggregate_monthly_from_json(conn, "project_finances", OVERVIEW_TENANTS)
|
||||
exp_amount, exp_incurred = _aggregate_expenses(conn, "expense_records", OVERVIEW_TENANTS)
|
||||
conn.close()
|
||||
return jsonify(_build_overview_result(agg, exp_amount, exp_incurred))
|
||||
|
||||
|
||||
@bp.route("/api/overview/finance-quarterly")
|
||||
@login_required
|
||||
def overview_finance_quarterly():
|
||||
conn = db()
|
||||
agg = _aggregate_monthly_from_json(conn, "project_finances", OVERVIEW_TENANTS)
|
||||
exp_amount, exp_incurred = _aggregate_expenses(conn, "expense_records", OVERVIEW_TENANTS)
|
||||
conn.close()
|
||||
result = _build_overview_result(agg, exp_amount, exp_incurred)
|
||||
for key in ["sign_monthly","revenue","gross","payment","cost","paid","cashflow"]:
|
||||
result["project"][key] = _quarterly_from_monthly(result["project"][key])
|
||||
result["dept"]["project_gross"] = _quarterly_from_monthly(result["dept"]["project_gross"])
|
||||
result["dept"]["dept_expense"] = _quarterly_from_monthly(result["dept"]["dept_expense"])
|
||||
result["dept"]["profit"] = _quarterly_from_monthly(result["dept"]["profit"])
|
||||
result["dept_cf"]["project_cf"] = _quarterly_from_monthly(result["dept_cf"]["project_cf"])
|
||||
result["dept_cf"]["dept_cf_item"] = _quarterly_from_monthly(result["dept_cf"]["dept_cf_item"])
|
||||
result["dept_cf"]["bl_cf"] = _quarterly_from_monthly(result["dept_cf"]["bl_cf"])
|
||||
return jsonify(result)
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
// app.js — 入口文件(加载模块 + 初始化)
|
||||
|
||||
// 恢复上次的工作台和标签页
|
||||
const savedTenant = localStorage.getItem("opc-active-tenant");
|
||||
if (savedTenant) {
|
||||
state.tenant = savedTenant;
|
||||
const label = savedTenant.replace("·无界", "");
|
||||
document.querySelector("#workspaceTitle").textContent = label + " OPC 工作台";
|
||||
const tLabel = document.querySelector("#currentTenantLabel");
|
||||
if (tLabel) { tLabel.textContent = label || "工作台"; tLabel.title = savedTenant; }
|
||||
}
|
||||
const savedTab = localStorage.getItem("opc-active-tab");
|
||||
|
||||
// 初始化
|
||||
applyUserTenants();
|
||||
updateSidebarTabs();
|
||||
load().then(() => {
|
||||
if (state.tenant === "总览") {
|
||||
switchTab("home");
|
||||
} else if (savedTab && savedTab !== "home") {
|
||||
applyUserTenants().then(() => {
|
||||
updateSidebarTabs();
|
||||
return load();
|
||||
}).then(() => {
|
||||
const savedTab = localStorage.getItem("opc-active-tab");
|
||||
if (savedTab && savedTab !== "home") {
|
||||
switchTab(savedTab);
|
||||
}
|
||||
}).catch((error) => {
|
||||
|
||||
@@ -50,7 +50,7 @@ async function loadUserList() {
|
||||
<td class="py-3 px-2 font-medium text-slate-800">${esc(u.username)}</td>
|
||||
<td class="py-3 px-2 text-slate-700">${esc(u.display_name)}</td>
|
||||
<td class="py-3 px-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${u.role === 'admin' ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'}">${u.role === 'admin' ? '管理员' : 'OPC负责人'}</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${u.role === 'admin' ? 'bg-brand-100 text-brand-700' : 'bg-slate-100 text-slate-600'}">${u.role === 'admin' ? '管理员' : 'OPC负责人'}</span>
|
||||
</td>
|
||||
<td class="py-3 px-2 text-slate-600 text-xs">${(u.tenants || []).map(t => `<span class="inline-block bg-slate-100 rounded px-1.5 py-0.5 mr-1 mb-1">${esc(t)}</span>`).join('') || '<span class="text-slate-400">—</span>'}</td>
|
||||
<td class="py-3 px-2 text-right">
|
||||
|
||||
@@ -70,7 +70,7 @@ function openDrawer(resource, id) {
|
||||
${resource === "proposals" ? `<section><h3 class="drawer-section-title">附件</h3>${fileGroup("proposal", item.id, "", "附件", item.files || [])}</section>` : ""}
|
||||
${followupTarget ? `<section>
|
||||
<h3 class="drawer-section-title">活动 / 跟进</h3>
|
||||
<div class="grid gap-2">${(item.followups || []).map((f) => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" onclick="deleteFollowup(event, ${f.id}, '${resource}', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
|
||||
<div class="grid gap-2">${(item.followups || []).map((f) => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-brand-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" onclick="deleteFollowup(event, ${f.id}, '${resource}', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
|
||||
<form class="comment-box mt-3" onsubmit="submitComment(event,'${followupTarget}',${item.id},'${resource}')">
|
||||
<div class="squire-toolbar">
|
||||
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
|
||||
|
||||
@@ -4,7 +4,7 @@ function renderExpense() {
|
||||
var records = state.data.expense || [];
|
||||
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
|
||||
var esc = function(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
||||
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
|
||||
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
|
||||
var STATUS_OPTIONS = ['计入费用','暂不计入'];
|
||||
|
||||
// 排序状态
|
||||
@@ -78,12 +78,16 @@ function renderExpense() {
|
||||
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setExpenseQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.expenseQuarter + '"', 'value="' + state.expenseQuarter + '" selected') + '</select>';
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-sm text-slate-500 ml-3">类型:</span><select onchange="setExpenseFilter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
|
||||
// 类型 Tab 按钮
|
||||
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setExpenseFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
|
||||
EXPENSE_TYPES.forEach(function(t) {
|
||||
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
|
||||
var cnt = records.filter(function(r){return r.expense_type===t}).length;
|
||||
typeTabs += '<button onclick="setExpenseFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
|
||||
});
|
||||
toolbar += '</select>' + dateSelect;
|
||||
typeTabs += '</div>';
|
||||
|
||||
// Toolbar(视图+日期选择器)
|
||||
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setExpenseView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select>' + dateSelect;
|
||||
|
||||
// 排序箭头
|
||||
var sortArrow = function(key) {
|
||||
@@ -104,11 +108,12 @@ function renderExpense() {
|
||||
}
|
||||
|
||||
// 模态框 monthOpts for form (with current value preselected)
|
||||
var curMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||
var modalMonthOpts = '<option value="">请选择</option>';
|
||||
for (var yr2 = thisYear - 1; yr2 <= thisYear + 1; yr2++)
|
||||
for (var m2 = 1; m2 <= 12; m2++) {
|
||||
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
|
||||
modalMonthOpts += '<option value="' + mv2 + '">' + mv2 + '</option>';
|
||||
modalMonthOpts += '<option value="' + mv2 + '"' + (mv2 === curMonth ? ' selected' : '') + '>' + mv2 + '</option>';
|
||||
}
|
||||
var statusOpts = STATUS_OPTIONS.map(function(s) { return '<option value="' + s + '">' + s + '</option>'; }).join('');
|
||||
|
||||
@@ -119,12 +124,12 @@ function renderExpense() {
|
||||
var target = document.querySelector("#financeExpense") || document.querySelector("#financeTabExpense") || document.querySelector("#expense");
|
||||
if (!target) return;
|
||||
target.innerHTML = '<div class="grid gap-4">' +
|
||||
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
|
||||
'<div class="grid grid-cols-3 gap-3">' +
|
||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
|
||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
|
||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">已发生金额</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
|
||||
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
|
||||
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
|
||||
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">总费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
|
||||
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">已发生金额</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
|
||||
'</div>' +
|
||||
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openExpenseModal()">新增费用</button></div></div>' +
|
||||
modal +
|
||||
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="toggleExpenseSort(\'incurred_amount\')">已发生金额' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
|
||||
'</div>';
|
||||
@@ -194,13 +199,10 @@ window.saveExpense = async function(event) {
|
||||
var resp = await fetch(url, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: Object.assign({}, data, { tenant: state.tenant }) }) });
|
||||
if (!resp.ok) { var err = await resp.text(); alert("保存失败:" + err); return; }
|
||||
|
||||
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
|
||||
if (bResp.ok) {
|
||||
var bd = await bResp.json();
|
||||
state.data = bd;
|
||||
applyUserTenants();
|
||||
renderExpense();
|
||||
}
|
||||
try {
|
||||
state.data.expense = await fetch("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)).then(r => r.json());
|
||||
} catch(e) {}
|
||||
renderExpense();
|
||||
closeExpenseModal();
|
||||
};
|
||||
|
||||
@@ -209,11 +211,8 @@ window.deleteExpenseItem = async function() {
|
||||
if (!id || !confirm("确定删除?")) return;
|
||||
var resp = await fetch("/api/expense/" + id, { method: "DELETE" });
|
||||
if (!resp.ok) { alert("删除失败"); return; }
|
||||
var bResp = await fetch("/api/bootstrap?tenant=" + encodeURIComponent(state.tenant));
|
||||
if (bResp.ok) {
|
||||
var bd = await bResp.json();
|
||||
state.data = bd;
|
||||
applyUserTenants();
|
||||
renderExpense();
|
||||
}
|
||||
try {
|
||||
state.data.expense = await fetch("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)).then(r => r.json());
|
||||
} catch(e) {}
|
||||
renderExpense();
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// finance.js — 经营管理(财务)模块
|
||||
|
||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
||||
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(1)} 万`;
|
||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
|
||||
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(0)} 万`;
|
||||
|
||||
function renderFinance() {
|
||||
const pfs = state.data.projectFinances || [];
|
||||
@@ -78,7 +78,7 @@ function renderFinance() {
|
||||
if (isRevView) {
|
||||
const rev = b.rev || 0;
|
||||
const gross = b.gross || 0;
|
||||
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
|
||||
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-brand-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
|
||||
} else {
|
||||
const payment = b.payment || 0;
|
||||
const cost = b.cost || 0;
|
||||
@@ -89,7 +89,7 @@ function renderFinance() {
|
||||
if (isRevView) {
|
||||
const totalRev = pf.total_rev || 0;
|
||||
const totalGross = pf.total_gross || 0;
|
||||
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
|
||||
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-brand-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
|
||||
} else {
|
||||
let totalPayment = 0, totalCost = 0;
|
||||
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
|
||||
@@ -97,34 +97,36 @@ function renderFinance() {
|
||||
}
|
||||
})();
|
||||
const sm = pf.sign_month || "";
|
||||
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||
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>`;
|
||||
};
|
||||
|
||||
const now2 = new Date();
|
||||
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
|
||||
if (!state.finMonth) state.finMonth = defaultMonth2;
|
||||
if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3);
|
||||
if (!state.finYear) state.finYear = now2.getFullYear();
|
||||
if (!state.finView) state.finView = 'annual';
|
||||
const qLabels = ['Q1','Q2','Q3','Q4'];
|
||||
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
||||
const nowD = new Date();
|
||||
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
|
||||
const activeMonths = qMonths[activeQ];
|
||||
const yearOpts = [2024,2025,2026,2027];
|
||||
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setFinView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">年份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 rounded text-sm font-medium border border-slate-200 bg-white">${yearOpts.map(y => `<option value="${y}" ${state.finYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">月度:</span>${activeMonths.map(m => { const ms = `${state.finYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
||||
const 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 finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
||||
|
||||
const isOverviewMode = state.tenant === "总览";
|
||||
const isOverviewMode = false;
|
||||
const finFilterTabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:0"><button onclick="switchFinFilter('overview')" class="finance-tab${state.finFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600">经营月报</button><button onclick="switchFinFilter('quarterlyOverview')" class="finance-tab${state.finFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchFinFilter('projects')" class="finance-tab${state.finFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchFinFilter('expense')" class="finance-tab${state.finFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
|
||||
|
||||
document.querySelector("#finance").innerHTML = `<div class="grid gap-2">
|
||||
${finFilterTabs}
|
||||
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
|
||||
<i data-lucide="lightbulb" class="text-blue-600 flex-shrink-0" style="width:18px;height:18px"></i>
|
||||
<span class="text-sm text-blue-900"><strong>执行提示:</strong>先关注确收,毛利确保业务运营,再关注回款,回款率,最后逐步优化成本与支出</span>
|
||||
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#fef9c3,#fde047);border-color:#eab308">
|
||||
<i data-lucide="lightbulb" class="text-yellow-700 flex-shrink-0" style="width:18px;height:18px"></i>
|
||||
<span class="text-sm text-yellow-900"><strong>已发生模块:</strong>只记录,已经发生的确收,毛利,回款,成本,支出</span>
|
||||
</div>`}
|
||||
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4") : ''}
|
||||
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
||||
${finFilterTabs}
|
||||
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? '' : ''}
|
||||
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-600 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
|
||||
<div class="finance-tabs">
|
||||
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
||||
<button class="finance-tab" data-tab="revpay" onclick="switchFinanceTab('revpay')"><i data-lucide="dollar-sign" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>确收与回款</button>
|
||||
@@ -144,7 +146,7 @@ function renderFinance() {
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
|
||||
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
|
||||
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions(new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'))}</select></label>
|
||||
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
@@ -156,7 +158,7 @@ function renderFinance() {
|
||||
<label class="block"><span class="fin-label">联系电话</span><input name="contact_phone" class="form-ctrl" placeholder="请输入联系电话"></label>
|
||||
<label class="block"><span class="fin-label">其他</span><input name="other_info" class="form-ctrl" placeholder="备注信息"></label>
|
||||
</div>
|
||||
<div class="block"><span class="fin-label">业务类型</span><div class="flex flex-wrap gap-2 mt-1" id="businessTypeChecks">${fmTypes.map(t => `<label class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-slate-200 cursor-pointer hover:border-blue-300 text-sm bt-chip" onclick="toggleBtChip(this)"><input type="checkbox" name="business_type[]" value="${t}" class="hidden"><span>${t}</span></label>`).join("")}</div></div>
|
||||
<div class="block"><span class="fin-label">业务类型</span><div class="flex flex-wrap gap-2 mt-1" id="businessTypeChecks">${fmTypes.map(t => `<label class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-slate-200 cursor-pointer hover:border-brand-300 text-sm bt-chip" onclick="toggleBtChip(this)"><input type="checkbox" name="business_type[]" value="${t}" class="hidden"><span>${t}</span></label>`).join("")}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="financeTabExec" class="hidden">
|
||||
@@ -172,9 +174,9 @@ function renderFinance() {
|
||||
</div>
|
||||
<div id="financeTabRevpay" class="hidden">
|
||||
<div class="grid grid-cols-3 gap-3 mb-4" id="revpaySummary">
|
||||
<div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100">
|
||||
<p class="text-xs text-blue-600 font-medium">总确收</p>
|
||||
<p class="text-lg font-bold text-blue-700" id="revpayTotalRev">¥0</p>
|
||||
<div class="bg-brand-50 rounded-lg p-3 text-center border border-brand-100">
|
||||
<p class="text-xs text-brand-600 font-medium">总确收</p>
|
||||
<p class="text-lg font-bold text-brand-700" id="revpayTotalRev">¥0</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-3 text-center border border-green-100">
|
||||
<p class="text-xs text-green-600 font-medium">总毛利</p>
|
||||
@@ -185,7 +187,7 @@ function renderFinance() {
|
||||
<p class="text-lg font-bold text-amber-700" id="revpayTotalPayment">¥0</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
|
||||
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="revpayTable">
|
||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500">确收</th><th class="p-2.5 text-right font-medium text-slate-500">毛利</th><th class="p-2.5 text-right font-medium text-slate-500">回款</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||
<tbody id="revpayTbody"></tbody>
|
||||
</table>
|
||||
@@ -202,14 +204,14 @@ function renderFinance() {
|
||||
<p class="text-lg font-bold text-purple-700" id="costTotalPaid">¥0</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="costTable">
|
||||
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="costTable">
|
||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">费用类型</th><th class="p-2.5 text-right font-medium text-slate-500">成本</th><th class="p-2.5 text-right font-medium text-slate-500">支出</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||
<tbody id="costTbody"></tbody>
|
||||
</table>
|
||||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addCostRow()"><i data-lucide="plus"></i>添加月份</button>
|
||||
</div>
|
||||
<div id="financeTabTasks" class="hidden">
|
||||
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
|
||||
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
|
||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">任务类型</th><th class="p-2.5 text-right font-medium text-slate-500">任务数量</th><th class="p-2.5 text-right font-medium text-slate-500">已执行</th><th class="p-2.5 text-right font-medium text-slate-500">差额</th><th class="p-2.5 text-right font-medium text-slate-500">单价</th><th class="p-2.5 text-right font-medium text-slate-500">执行金额</th><th class="p-2.5 text-right font-medium text-slate-500">未执行金额</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||
<tbody id="taskTbody"></tbody>
|
||||
</table>
|
||||
@@ -253,23 +255,15 @@ function renderFinance() {
|
||||
return `<div id="financeExpense"></div>`;
|
||||
}
|
||||
const isUnsigned = false;
|
||||
const METRIC_CARDS = [
|
||||
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
|
||||
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
|
||||
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
|
||||
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
||||
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
||||
{ label: '支出', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
|
||||
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
||||
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
||||
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
||||
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
|
||||
].filter(c => true);
|
||||
|
||||
// 列排序
|
||||
if (!state.finSort) state.finSort = { key: null, asc: true };
|
||||
// 列排序(默认按确收金额从高到低)
|
||||
if (!state.finSort) state.finSort = { key: 'sig|rev', asc: false };
|
||||
const sortData = (data, key) => {
|
||||
if (!key || !state.finSort.key) return data;
|
||||
if (!key) return data;
|
||||
if (!state.finSort.key) {
|
||||
// 默认按确收金额从高到低
|
||||
return data.slice().sort(function(a, b) { return (parseFloat(b.rev) || 0) - (parseFloat(a.rev) || 0); });
|
||||
}
|
||||
var asc = state.finSort.asc ? 1 : -1;
|
||||
return data.slice().sort(function(a, b) {
|
||||
var va = a[key], vb = b[key];
|
||||
@@ -284,40 +278,52 @@ function renderFinance() {
|
||||
var h = '<thead><tr class="bg-slate-50 border-b border-slate-200">';
|
||||
cols.forEach(function(c) {
|
||||
var arrow = sortKey ? (sortKey === c.key ? (state.finSort.asc ? ' ↑' : ' ↓') : '') : '';
|
||||
h += '<th class="p-2 text-center font-semibold align-middle cursor-pointer select-none" onclick="setFinSort(\'' + sortKey + '|' + c.key + '\')">' + c.label + arrow + '</th>';
|
||||
h += '<th class="px-2 py-px text-center font-semibold align-middle cursor-pointer select-none" onclick="setFinSort(\'' + sortKey + '|' + c.key + '\')">' + c.label + arrow + '</th>';
|
||||
});
|
||||
h += '</tr></thead>';
|
||||
return h;
|
||||
};
|
||||
const SIGNED_COLS = [
|
||||
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||
{ key: '_expand', label: '' },
|
||||
{ key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
||||
{ key: 'payment', label: '回款' }, { key: 'pay_rate', label: '回款率' },
|
||||
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'paid_rate', label: '付款率' },
|
||||
{ key: 'payment', label: '回款' }, { key: 'pending', label: '应收' }, { key: 'pay_rate', label: '回款率' },
|
||||
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'payable', label: '应付' }, { key: 'paid_rate', label: '付款率' },
|
||||
{ key: 'cashflow', label: '现金流' },
|
||||
];
|
||||
const UNSIGNED_COLS = [
|
||||
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||
{ key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||
{ key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
|
||||
,
|
||||
];
|
||||
|
||||
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
||||
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt };
|
||||
const cards = METRIC_CARDS.map(c => {
|
||||
const v = c.value(ctx);
|
||||
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
|
||||
});
|
||||
const receivable = sumRev - sumPay;
|
||||
const payable = sumCost - sumPaid;
|
||||
const cashflow = sumPay - sumPaid;
|
||||
const netAsset = receivable - payable;
|
||||
const m = moneyWan;
|
||||
const rows_data = [
|
||||
['签约金额', m(signTotal), '∑签约金额'],
|
||||
['确收金额', m(sumRev), '∑确收金额'],
|
||||
['毛利', m(sumGross), '∑毛利'],
|
||||
['回款', m(sumPay), '∑回款金额'],
|
||||
['支出', m(sumPaid), '∑支出金额'],
|
||||
['现金流', m(cashflow), '回款 − 支出'],
|
||||
['应收', m(receivable), '确收 − 回款'],
|
||||
['应付', m(payable), '成本 − 支出'],
|
||||
['往来余额', m(netAsset), '应收 − 应付'],
|
||||
];
|
||||
const cardHtml = (r, i) => '<div class="card px-2 py-1.5 text-center flex-1" style="background:' + (['var(--brand-50)','var(--brand-50)','var(--brand-50)','#fef3c7','#fef3c7','#fef3c7','#fef9c3','#fef9c3','#fef9c3'][i] || '#fff') + '"><div class="text-xs text-slate-600">' + r[0] + '</div><div class="text-sm font-semibold text-slate-800">' + r[1] + '</div></div>';
|
||||
const fs = '<div class="flex gap-1 items-stretch py-2">' + cardHtml(rows_data[0],0) + cardHtml(rows_data[1],1) + cardHtml(rows_data[2],2) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[3],3) + cardHtml(rows_data[4],4) + cardHtml(rows_data[5],5) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[6],6) + cardHtml(rows_data[7],7) + cardHtml(rows_data[8],8) + '</div>';
|
||||
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
||||
const theadCols = isUnsigned ? 6 : 12;
|
||||
const tbody = rows.length ? `<tbody style="line-height:0.95">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
||||
|
||||
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
|
||||
const theadCols = isUnsigned ? 5 : 14;
|
||||
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-700">${emptyText}</td></tr>`;
|
||||
const subtitle = isUnsigned
|
||||
? '合同 → 毛利 → 成本 → 项目利润'
|
||||
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
|
||||
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
|
||||
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
||||
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流';
|
||||
return `${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
|
||||
card(fs, "px-4 py-2") +
|
||||
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-600 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
||||
};
|
||||
|
||||
const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>';
|
||||
@@ -328,14 +334,42 @@ function renderFinance() {
|
||||
const payRCls = payRVal === null ? '' : payRVal < 30 ? 'text-red-600' : payRVal < 80 ? 'text-amber-600' : 'text-green-600';
|
||||
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 payable = cost - paid;
|
||||
const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-700';
|
||||
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 cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
||||
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr>`;
|
||||
const pending = rev - payment;
|
||||
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700';
|
||||
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
|
||||
var budgetArr = [];
|
||||
var expenseArr = [];
|
||||
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
||||
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
|
||||
// 合并 budget 和 expense 的所有月份
|
||||
var allMonths = {};
|
||||
budgetArr.forEach(function(b) { allMonths[b.month || ''] = true; });
|
||||
expenseArr.forEach(function(e) { allMonths[e.month || ''] = true; });
|
||||
var detailRows = Object.keys(allMonths).filter(function(m) {
|
||||
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
|
||||
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
|
||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||
return bRev || bGross || bPay || eCost || ePaid;
|
||||
}).sort().map(function(m) {
|
||||
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
|
||||
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
|
||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||
var bCf = bPay - ePaid;
|
||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-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('');
|
||||
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>`;
|
||||
};
|
||||
// 待签约简版行
|
||||
const tdRowUnsigned = (pf, cost, gross) => {
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
|
||||
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>`;
|
||||
};
|
||||
|
||||
if (state.finView === 'monthly') {
|
||||
@@ -485,7 +519,7 @@ window.openFinanceModal = () => {
|
||||
modal.classList.remove("hidden");
|
||||
};
|
||||
|
||||
window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
|
||||
window.addRevpayRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), rev = '', gross = '', payment = '', rev_note = '') => {
|
||||
const tbody = document.querySelector("#revpayTbody");
|
||||
if (!tbody) return;
|
||||
const row = document.createElement("tr");
|
||||
@@ -499,7 +533,7 @@ window.addRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
};
|
||||
|
||||
window.addCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
|
||||
window.addCostRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), expense_type = '', cost = '', paid = '', exp_note = '') => {
|
||||
const tbody = document.querySelector("#costTbody");
|
||||
if (!tbody) return;
|
||||
const row = document.createElement("tr");
|
||||
@@ -565,13 +599,12 @@ window.initCostTable = (expenseData) => {
|
||||
const TASK_TYPES = ["科普视频", "科普专访", "科普文章", "问卷调研", "病例征集"];
|
||||
|
||||
function taskMonthOptions(selected) {
|
||||
const now = new Date();
|
||||
const opts = [];
|
||||
for (let i = -2; i <= 12; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||
const v = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
|
||||
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
||||
}
|
||||
for (let y = 2020; y <= 2027; y++)
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const v = y + "-" + String(m).padStart(2, "0");
|
||||
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
||||
}
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
@@ -583,7 +616,7 @@ window.addTaskRow = (taskMonth = '', taskType = '', taskCount = '', executedCoun
|
||||
const isPreset = TASK_TYPES.includes(taskType);
|
||||
const typeCell = isPreset || !taskType
|
||||
? `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="onTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option ${t === taskType ? 'selected' : ''}>${t}</option>`).join("")}<option value="__custom__" ${!isPreset && taskType ? 'selected' : ''}>自定义...</option></select>`
|
||||
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-700 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||
row.innerHTML = `<td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full">${taskMonthOptions(taskMonth || defaultMonth)}</select></td>
|
||||
<td class="flex items-center">${typeCell}</td>
|
||||
<td><input name="task_count[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${taskCount}" oninput="updateTaskDiff(this)"></td>
|
||||
@@ -628,9 +661,9 @@ function updateRowCalc(row) {
|
||||
window.toggleBtChip = (chip) => {
|
||||
const cb = chip.querySelector('input');
|
||||
cb.checked = !cb.checked;
|
||||
chip.classList.toggle('bg-blue-50', cb.checked);
|
||||
chip.classList.toggle('border-blue-400', cb.checked);
|
||||
chip.classList.toggle('text-blue-600', cb.checked);
|
||||
chip.classList.toggle('bg-brand-50', cb.checked);
|
||||
chip.classList.toggle('border-brand-400', cb.checked);
|
||||
chip.classList.toggle('text-brand-600', cb.checked);
|
||||
};
|
||||
|
||||
window.initTaskTable = (taskData) => {
|
||||
@@ -645,7 +678,7 @@ window.onTaskTypeChange = (sel) => {
|
||||
if (sel.value !== '__custom__') return;
|
||||
const td = sel.parentElement;
|
||||
const oldVal = sel.value;
|
||||
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-700 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
td.querySelector('input').focus();
|
||||
};
|
||||
@@ -677,11 +710,11 @@ window.editPfSignMonth = (event, pfId) => {
|
||||
try {
|
||||
await api(`/api/projectFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
|
||||
pf.sign_month = newValue;
|
||||
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-blue-600" onclick="editPfSignMonth(event, ${pfId})">${newValue || '—'}</span>`;
|
||||
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-brand-600" onclick="editPfSignMonth(event, ${pfId})">${newValue || '—'}</span>`;
|
||||
} catch (e) { toast("修改失败:" + e.message, "error"); }
|
||||
});
|
||||
select.addEventListener("blur", () => {
|
||||
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-blue-600" onclick="editPfSignMonth(event, ${pfId})">${currentValue || '—'}</span>`;
|
||||
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-brand-600" onclick="editPfSignMonth(event, ${pfId})">${currentValue || '—'}</span>`;
|
||||
});
|
||||
td.innerHTML = "";
|
||||
td.appendChild(select);
|
||||
@@ -689,7 +722,7 @@ window.editPfSignMonth = (event, pfId) => {
|
||||
};
|
||||
|
||||
window.switchFinanceTab = (tab) => {
|
||||
document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
||||
document.querySelectorAll("#financeModal .finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
||||
document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info");
|
||||
document.querySelector("#financeTabRevpay").classList.toggle("hidden", tab !== "revpay");
|
||||
document.querySelector("#financeTabCost").classList.toggle("hidden", tab !== "cost");
|
||||
@@ -708,8 +741,8 @@ async function loadFinFollowups(pfId) {
|
||||
try {
|
||||
const fups = await api(`/api/followups/project_finance/${pfId}`);
|
||||
list.innerHTML = fups.length
|
||||
? fups.map(f => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" type="button" onclick="deleteFinFollowup(event, ${f.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")
|
||||
: '<p class="text-sm text-slate-400 py-4 text-center">暂无跟进记录</p>';
|
||||
? fups.map(f => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-brand-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" type="button" onclick="deleteFinFollowup(event, ${f.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")
|
||||
: '<p class="text-sm text-slate-700 py-4 text-center">暂无跟进记录</p>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
list.querySelectorAll(".rich-content").forEach(el => {
|
||||
const html = el.dataset.html;
|
||||
@@ -752,6 +785,19 @@ window.deleteFinFollowup = async (event, followupId) => {
|
||||
if (pfId) await loadFinFollowups(pfId);
|
||||
};
|
||||
|
||||
window.finToggleExpand = (pfId) => {
|
||||
var row = document.getElementById('fin_expand_' + pfId);
|
||||
var icon = document.getElementById('fin_expand_icon_' + pfId);
|
||||
if (!row) return;
|
||||
if (row.classList.contains('hidden')) {
|
||||
row.classList.remove('hidden');
|
||||
if (icon) { icon.setAttribute('data-lucide', 'chevron-down'); if (window.lucide) window.lucide.createIcons(); }
|
||||
} else {
|
||||
row.classList.add('hidden');
|
||||
if (icon) { icon.setAttribute('data-lucide', 'chevron-right'); if (window.lucide) window.lucide.createIcons(); }
|
||||
}
|
||||
};
|
||||
|
||||
window.openPfEditModal = (pfId) => {
|
||||
const pf = (state.data.projectFinances || []).find(x => x.id === pfId);
|
||||
if (!pf) return;
|
||||
@@ -768,9 +814,9 @@ window.openPfEditModal = (pfId) => {
|
||||
cb.checked = btValues.includes(cb.value);
|
||||
const chip = cb.closest('.bt-chip');
|
||||
if (chip) {
|
||||
chip.classList.toggle('bg-blue-50', cb.checked);
|
||||
chip.classList.toggle('border-blue-400', cb.checked);
|
||||
chip.classList.toggle('text-blue-600', cb.checked);
|
||||
chip.classList.toggle('bg-brand-50', cb.checked);
|
||||
chip.classList.toggle('border-brand-400', cb.checked);
|
||||
chip.classList.toggle('text-brand-600', cb.checked);
|
||||
}
|
||||
});
|
||||
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
|
||||
@@ -910,7 +956,8 @@ window.createFinance = async (event) => {
|
||||
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
|
||||
closeFinanceModal();
|
||||
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderFinance();
|
||||
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance();
|
||||
} catch (error) {
|
||||
toast("保存失败:" + error.message, "error");
|
||||
}
|
||||
@@ -926,7 +973,8 @@ window.deleteFinanceItem = async () => {
|
||||
await api(`/api/projectFinances/${pfId}`, { method: "DELETE" });
|
||||
closeFinanceModal();
|
||||
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderFinance();
|
||||
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance();
|
||||
toast("已删除", "success");
|
||||
} catch (error) {
|
||||
toast("删除失败:" + error.message, "error");
|
||||
@@ -982,8 +1030,8 @@ function renderFinanceOverview() {
|
||||
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[dmi] + '</th>'; }
|
||||
deptThead += '</tr>';
|
||||
var deptRows = [
|
||||
{ label: '毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
|
||||
{ label: '平台费用', vals: expMonthly, annual: expAnnual },
|
||||
{ label: '项目毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
|
||||
{ label: '部门费用', vals: expMonthly, annual: expAnnual },
|
||||
{ label: '利润', vals: fm.map(function(x, i) { return (x ? x.gross : 0) - expMonthly[i]; }), annual: grossAnnual - expAnnual }
|
||||
];
|
||||
var deptTbody = '';
|
||||
@@ -998,7 +1046,7 @@ function renderFinanceOverview() {
|
||||
}
|
||||
deptTbody += rowHtml + '</tr>';
|
||||
}
|
||||
var deptCard = card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
|
||||
var deptCard = 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');
|
||||
|
||||
// 业务线现金流卡片
|
||||
var cfMonthly = [0,0,0,0,0,0,0,0,0,0,0,0];
|
||||
@@ -1016,8 +1064,8 @@ function renderFinanceOverview() {
|
||||
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||||
var cfRows = [
|
||||
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
|
||||
{ label: '平台费用(支出)', vals: expPaidMonthly, annual: expPaidAnnual },
|
||||
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
|
||||
{ label: '部门现金流', vals: expPaidMonthly, annual: expPaidAnnual },
|
||||
{ label: '经营现金流', vals: blCfMonthly, annual: blCfAnnual }
|
||||
];
|
||||
var cfTbody = '';
|
||||
for (var cfri = 0; cfri < 3; cfri++) {
|
||||
@@ -1033,16 +1081,16 @@ function renderFinanceOverview() {
|
||||
}
|
||||
cfTbody += cfRowHtml + '</tr>';
|
||||
}
|
||||
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(支出)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
||||
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">' +
|
||||
deptCard +
|
||||
cfCard +
|
||||
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务月度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
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">' +
|
||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartSign"></canvas></div>', 'p-4') +
|
||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartRev"></canvas></div>', 'p-4') +
|
||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与支出</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
|
||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-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">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">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
|
||||
'</div></div>';
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
@@ -1081,9 +1129,9 @@ function renderFinanceQuarterlyOverview() {
|
||||
}
|
||||
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
|
||||
thead += '</tr>';
|
||||
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
|
||||
var tbody = '';
|
||||
@@ -1114,11 +1162,11 @@ function renderFinanceQuarterlyOverview() {
|
||||
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
|
||||
var grossAnnual = m.gross_annual || 0;
|
||||
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; }
|
||||
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>';
|
||||
var deptRows = [
|
||||
{ label: '毛利', vals: grossQ, annual: grossAnnual },
|
||||
{ label: '平台费用', vals: expQ, annual: expAnnual },
|
||||
{ label: '项目毛利', vals: grossQ, annual: grossAnnual },
|
||||
{ label: '部门费用', vals: expQ, annual: expAnnual },
|
||||
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
|
||||
];
|
||||
var deptTbody = '';
|
||||
@@ -1131,6 +1179,7 @@ function renderFinanceQuarterlyOverview() {
|
||||
for (var qi = 0; qi < 4; qi++) {
|
||||
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
|
||||
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(dr.vals[qi], dr.vals[qi - 1]) + '</td>';
|
||||
}
|
||||
deptTbody += rowHtml + '</tr>';
|
||||
}
|
||||
@@ -1150,8 +1199,8 @@ function renderFinanceQuarterlyOverview() {
|
||||
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||||
var cfRows = [
|
||||
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
|
||||
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
|
||||
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
|
||||
{ label: '部门现金流', vals: expPaidQ, annual: expPaidAnnual },
|
||||
{ label: '经营现金流', vals: blCfQ, annual: blCfAnnual }
|
||||
];
|
||||
var cfTbody = '';
|
||||
for (var cfri = 0; cfri < 3; cfri++) {
|
||||
@@ -1163,14 +1212,15 @@ function renderFinanceQuarterlyOverview() {
|
||||
for (var qi = 0; qi < 4; qi++) {
|
||||
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
|
||||
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(cfdr.vals[qi], cfdr.vals[qi - 1]) + '</td>';
|
||||
}
|
||||
cfTbody += rowHtml + '</tr>';
|
||||
}
|
||||
|
||||
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
|
||||
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出 → 回款 → 项目现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
||||
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>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
'</div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
@@ -1189,7 +1239,7 @@ function renderOverviewCharts(data) {
|
||||
var c2 = document.querySelector("#finChartRev");
|
||||
if (c2 && window.Chart) {
|
||||
if (state.finChart2) state.finChart2.destroy();
|
||||
state.finChart2 = new Chart(c2, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "确收", data: data.map(function(x) { return x.revenue || 0; }), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, { label: "毛利", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }] } }));
|
||||
state.finChart2 = new Chart(c2, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "确收", data: data.map(function(x) { return x.revenue || 0; }), borderColor: "var(--brand-600)", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, { label: "毛利", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }] } }));
|
||||
}
|
||||
var c3 = document.querySelector("#finChartCash");
|
||||
if (c3 && window.Chart) {
|
||||
@@ -1202,3 +1252,53 @@ function renderOverviewCharts(data) {
|
||||
state.finChart4 = new Chart(c4, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "利润", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }] } }));
|
||||
}
|
||||
};
|
||||
|
||||
// ====== 筛选器控制函数 ======
|
||||
window.switchFinFilter = (f) => { state.finFilter = f; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
window.setFinView = (v) => { state.finView = v; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
window.setFinYear = (y) => { state.finYear = parseInt(y); localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
window.setFinQuarter = (q) => { state.finView = 'quarterly'; state.finQuarter = q; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
window.setFinMonth = (m) => { state.finView = 'monthly'; state.finMonth = m; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance(); };
|
||||
|
||||
|
||||
// ---- 项目标记上下文菜单 ----
|
||||
if (!state._fmMarks) state._fmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_fm_marks')) || { red: {} }; } catch(e) { return { red: {} }; } })();
|
||||
window.finShowCtx = function(e, pfId) {
|
||||
e.preventDefault();
|
||||
var menu = document.getElementById('finCtxMenu');
|
||||
if (!menu) {
|
||||
menu = document.createElement('div');
|
||||
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.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>';
|
||||
menu.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
var id = menu.getAttribute('data-pfid');
|
||||
menu.style.display = 'none';
|
||||
if (state._fmMarks.red[id]) { delete state._fmMarks.red[id]; } else { state._fmMarks.red[id] = true; }
|
||||
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||
renderFinance();
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
menu.setAttribute('data-pfid', pfId);
|
||||
menu.style.display = 'block';
|
||||
menu.style.left = e.pageX + 'px';
|
||||
menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px';
|
||||
};
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
function renderHome() {
|
||||
const { summary, financeMonthly } = state.data;
|
||||
const m = summary.metrics;
|
||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")} 元`;
|
||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
|
||||
// 回款提醒
|
||||
const yrRev = m.revenue_annual || 0, yrPay = m.payment_annual || 0, yrUnpaid = yrRev - yrPay;
|
||||
const yrPayRate = yrRev > 0 ? Math.round(yrPay / yrRev * 100) : 0;
|
||||
@@ -86,7 +86,7 @@ function renderHome() {
|
||||
const sign = pct >= 0 ? '+' : '';
|
||||
return '<span class="' + cls + '">' + sign + pct + '%</span>';
|
||||
};
|
||||
const COLORS = ['text-slate-700','text-blue-600','text-green-600','text-rose-600','text-indigo-600','text-amber-600','text-purple-600','text-cyan-600'];
|
||||
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];
|
||||
@@ -117,7 +117,7 @@ function renderHome() {
|
||||
}
|
||||
document.querySelector("#home").innerHTML = `
|
||||
<div class="grid gap-5">
|
||||
${state.tenant === "总览" ? "" : `<div class="grid grid-cols-4 gap-3">
|
||||
${`<div class="grid grid-cols-4 gap-3">
|
||||
${[
|
||||
["项目管理", m.total_projects, "finance"],
|
||||
["重点工作与台账", m.total_proposals, "projects"],
|
||||
@@ -212,7 +212,7 @@ function renderCharts(data) {
|
||||
state.chart2 = new Chart(c2, {
|
||||
type: "line",
|
||||
data: { labels, datasets: [
|
||||
{ label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 },
|
||||
{ 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,
|
||||
|
||||
281
static/modules/overview_finance.js
Normal file
281
static/modules/overview_finance.js
Normal file
@@ -0,0 +1,281 @@
|
||||
// overview_finance.js — 发生总览模块(5 个工作台数据聚合)
|
||||
|
||||
function renderOverviewFinance() {
|
||||
var view = state.overviewView || "monthly";
|
||||
if (view === 'matrix') { renderFinanceDeptMatrix(); return; }
|
||||
if (view === 'cf_matrix') { renderFinanceCfMatrix(); return; }
|
||||
if (view === 'project_matrix') { renderFinanceProjectMatrix(); return; }
|
||||
|
||||
var api = view === "quarterly" ? "/api/overview/finance-quarterly" : "/api/overview/finance-monthly";
|
||||
var container = document.querySelector("#overview_finance");
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="p-4 text-center text-slate-400">加载中...</div>';
|
||||
|
||||
fetch(api).then(r => r.json()).then(data => {
|
||||
if (state.overviewView !== view) return;
|
||||
renderOverviewFinanceData(data, view);
|
||||
}).catch(() => {
|
||||
if (state.overviewView !== view) return;
|
||||
container.innerHTML = '<div class="p-4 text-center text-slate-400">加载失败</div>';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function isAllZero(data, tenants, keys, mi) {
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
for (var ki = 0; ki < keys.length; ki++) {
|
||||
if ((data.data[tenants[ti]][keys[ki]][mi] || 0) !== 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// ====== 项目经营详情报表(顶部定义,避免调用时未注册) ======
|
||||
|
||||
function renderFinanceProjectMatrix() {
|
||||
var c = document.querySelector("#overview_finance");
|
||||
if (!c) return;
|
||||
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载项目经营详情矩阵...</div>';
|
||||
fetch('/api/overview/finance-dept-project-matrix').then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(function(data) {
|
||||
renderFinanceProjectMatrixData(data, c);
|
||||
}).catch(function(e) {
|
||||
c.innerHTML = '<div class="p-3 text-center text-red-500 text-sm">加载失败: ' + (e.message || '未知错误') + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderFinanceProjectMatrixData(data, container) {
|
||||
try {
|
||||
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
|
||||
var tenants = data.tenants;
|
||||
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
|
||||
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
|
||||
var keys = ["sign","revenue","gross","cost","paid","payment","cashflow"];
|
||||
var labels = ["合同金额","确收金额","确收毛利","当期成本","当期流出","回款","项目现金流"];
|
||||
|
||||
var vBtns = financeBtnBar('project_matrix');
|
||||
var cards = '';
|
||||
for (var mi = 11; mi >= 0; mi--) {
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
if (isAllZero(data, tenants, keys.filter(function(k) { return k !== 'sign'; }), mi)) continue;
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
|
||||
var tb = financeProjectMatrixTbody(data, tenants, mi, keys, labels, mf);
|
||||
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-brand-200" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
|
||||
}
|
||||
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
} catch(e) { container.innerHTML = '<div class="p-3 text-center text-red-500 text-sm">渲染失败: ' + (e.message || '未知错误') + '</div>'; }
|
||||
}
|
||||
|
||||
function financeProjectMatrixTbody(data, tenants, mi, keys, labels, mf) {
|
||||
var tb = '';
|
||||
for (var ri = 0; ri < keys.length; ri++) {
|
||||
var rowCls = ri === keys.length - 1 ? 'font-semibold' : '';
|
||||
var ts = 0;
|
||||
var vals = [];
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
var v = data.data[tenants[ti]][keys[ri]][mi] || 0;
|
||||
ts += v;
|
||||
vals.push(v);
|
||||
}
|
||||
var tCls = 'text-slate-700';
|
||||
if (ri === 2) tCls = 'text-green-600';
|
||||
if (ri === keys.length - 1) tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
var cls = 'text-slate-700';
|
||||
if (ri === 2) cls = 'text-green-600';
|
||||
if (ri === keys.length - 1) cls = vals[ti] >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(vals[ti]) + '</td>';
|
||||
}
|
||||
rh += '</tr>';
|
||||
tb += rh;
|
||||
}
|
||||
return tb;
|
||||
}
|
||||
|
||||
function renderOverviewFinanceData(data, view) {
|
||||
const container = document.querySelector("#overview_finance");
|
||||
if (!container) return;
|
||||
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
||||
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
||||
const isQuarterly = view === "quarterly";
|
||||
const headers = isQuarterly ? ['Q1','Q2','Q3','Q4'] : ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||||
const cols = isQuarterly ? 4 : 12;
|
||||
const d = data.dept, cf = data.dept_cf, p = data.project;
|
||||
|
||||
var btns = [];
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'monthly\')" class="finance-tab' + (view === 'monthly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">月报总览</button>');
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'quarterly\')" class="finance-tab' + (view === 'quarterly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">季报总览</button>');
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'matrix\')" class="finance-tab' + (view === 'matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">损益详细报表</button>');
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'cf_matrix\')" class="finance-tab' + (view === 'cf_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">现金流详细报表</button>');
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'project_matrix\')" class="finance-tab' + (view === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
|
||||
var viewBtns = '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
|
||||
|
||||
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||
for (var hi = 0; hi < cols; hi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[hi] + '</th>'; }
|
||||
deptThead += '</tr>';
|
||||
|
||||
var deptRows = [
|
||||
{ label: '项目毛利', vals: d.project_gross, annual: d.annual[0] },
|
||||
{ label: '部门费用', vals: d.dept_expense, annual: d.annual[1] },
|
||||
{ label: '利润', vals: d.profit, annual: d.annual[2] }
|
||||
];
|
||||
var deptTbody = '';
|
||||
for (var dri = 0; dri < 3; dri++) {
|
||||
var dr = deptRows[dri], rowCls = dri === 2 ? 'font-semibold' : '';
|
||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + dr.label + '</td>';
|
||||
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
||||
for (var i = 0; i < cols; i++) {
|
||||
var cellCls = dri === 2 ? (dr.vals[i] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[i]) + '</td>';
|
||||
}
|
||||
deptTbody += rowHtml + '</tr>';
|
||||
}
|
||||
var deptCard = 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');
|
||||
|
||||
var cfRows = [
|
||||
{ label: '项目现金流', vals: cf.project_cf, annual: cf.annual[0] },
|
||||
{ label: '部门现金流', vals: cf.dept_cf_item, annual: cf.annual[1] },
|
||||
{ label: '经营现金流', vals: cf.bl_cf, annual: cf.annual[2] }
|
||||
];
|
||||
var cfTbody = '';
|
||||
for (var cfri = 0; cfri < 3; cfri++) {
|
||||
var cfr = cfRows[cfri], cfRowCls = cfri === 2 ? 'font-semibold' : '';
|
||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + cfr.label + '</td>';
|
||||
var cAnnualCls = cfri === 2 ? (cfr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cAnnualCls + ' whitespace-nowrap">' + moneyIntL(cfr.annual) + '</td>';
|
||||
for (var i = 0; i < cols; i++) {
|
||||
var cCellCls = cfri === 2 ? (cfr.vals[i] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cCellCls + '">' + moneyIntL(cfr.vals[i]) + '</td>';
|
||||
}
|
||||
cfTbody += rowHtml + '</tr>';
|
||||
}
|
||||
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 LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
||||
var projVals = [p.sign_monthly || [], p.revenue, p.gross, p.cost, p.paid, p.payment, p.cashflow];
|
||||
var projAnnual = p.annual;
|
||||
var projThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||
for (var phi = 0; phi < cols; phi++) { projThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[phi] + '</th>'; }
|
||||
projThead += '</tr>';
|
||||
var projTbody = '';
|
||||
for (var ri = 0; ri < 7; ri++) {
|
||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap" style="min-width:100px">' + LABELS[ri] + '</td>';
|
||||
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(projAnnual[ri]) + '</td>';
|
||||
for (var i = 0; i < cols; i++) {
|
||||
var val = projVals[ri] ? (projVals[ri][i] || 0) : 0;
|
||||
var cls = 'text-slate-700';
|
||||
if (ri === 2) cls = 'text-green-600';
|
||||
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
||||
}
|
||||
projTbody += rowHtml + '</tr>';
|
||||
}
|
||||
var projCard = 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>' + projThead + '</thead><tbody>' + projTbody + '</tbody></table></div>', 'p-4');
|
||||
|
||||
container.innerHTML = '<div class="grid gap-2.5">' + viewBtns + deptCard + cfCard + projCard + '</div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
window.setOverviewFinanceView = (v) => { state.overviewView = v; renderOverviewFinance(); };
|
||||
|
||||
// ====== 损益报表 ======
|
||||
|
||||
function renderFinanceDeptMatrix() {
|
||||
var c = document.querySelector("#overview_finance");
|
||||
if (!c) return;
|
||||
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载损益矩阵...</div>';
|
||||
fetch('/api/overview/finance-dept-matrix').then(r => r.json()).then(function(data) { renderFinanceDeptMatrixData(data, c); })
|
||||
.catch(function() { c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载失败</div>'; });
|
||||
}
|
||||
|
||||
function renderFinanceDeptMatrixData(data, container) {
|
||||
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
|
||||
var tenants = data.tenants;
|
||||
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
|
||||
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
|
||||
var vBtns = financeBtnBar('matrix');
|
||||
var cards = '';
|
||||
for (var mi = 11; mi >= 0; mi--) {
|
||||
if (isAllZero(data, tenants, ['project_gross','dept_expense','profit'], mi)) continue;
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
var tb = financeMatrixTbody(data, tenants, mi, ['project_gross','dept_expense','profit'], ['项目毛利','部门费用','利润'], mf);
|
||||
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-brand-200" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
|
||||
}
|
||||
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
// ====== 现金流报表 ======
|
||||
|
||||
function renderFinanceCfMatrix() {
|
||||
var c = document.querySelector("#overview_finance");
|
||||
if (!c) return;
|
||||
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载现金流矩阵...</div>';
|
||||
fetch('/api/overview/finance-dept-cf-matrix').then(r => r.json()).then(function(data) { renderFinanceCfMatrixData(data, c); })
|
||||
.catch(function() { c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载失败</div>'; });
|
||||
}
|
||||
|
||||
function renderFinanceCfMatrixData(data, container) {
|
||||
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
|
||||
var tenants = data.tenants;
|
||||
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
|
||||
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
|
||||
var vBtns = financeBtnBar('cf_matrix');
|
||||
var cards = '';
|
||||
for (var mi = 11; mi >= 0; mi--) {
|
||||
if (isAllZero(data, tenants, ['project_cf','dept_cf','bl_cf'], mi)) continue;
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
var tb = financeMatrixTbody(data, tenants, mi, ['project_cf','dept_cf','bl_cf'], ['项目现金流','部门现金流','经营现金流'], mf);
|
||||
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-brand-200" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
|
||||
}
|
||||
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
// ====== 共用工具 ======
|
||||
|
||||
function financeBtnBar(active) {
|
||||
var btns = [];
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'monthly\')" class="finance-tab' + (active === 'monthly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">月报总览</button>');
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'quarterly\')" class="finance-tab' + (active === 'quarterly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">季报总览</button>');
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'matrix\')" class="finance-tab' + (active === 'matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">损益详细报表</button>');
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'cf_matrix\')" class="finance-tab' + (active === 'cf_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">现金流详细报表</button>');
|
||||
btns.push('<button onclick="setOverviewFinanceView(\'project_matrix\')" class="finance-tab' + (active === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
|
||||
return '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
|
||||
}
|
||||
|
||||
function financeMatrixTbody(data, tenants, mi, keys, labels, mf) {
|
||||
var tb = '';
|
||||
for (var ri = 0; ri < keys.length; ri++) {
|
||||
var rowCls = ri === keys.length - 1 ? 'font-semibold' : '';
|
||||
var ts = 0;
|
||||
var vals = [];
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
var v = data.data[tenants[ti]][keys[ri]][mi] || 0;
|
||||
ts += v;
|
||||
vals.push(v);
|
||||
}
|
||||
var tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
var cls = vals[ti] >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(vals[ti]) + '</td>';
|
||||
}
|
||||
rh += '</tr>';
|
||||
tb += rh;
|
||||
}
|
||||
return tb;
|
||||
}
|
||||
302
static/modules/overview_plan.js
Normal file
302
static/modules/overview_plan.js
Normal file
@@ -0,0 +1,302 @@
|
||||
// overview_plan.js — 预算总览模块(5 个工作台数据聚合)
|
||||
|
||||
function renderOverviewPlan() {
|
||||
var view = state.overviewView || "monthly";
|
||||
var container = document.querySelector("#overview_plan");
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="p-4 text-center text-slate-400">加载中...</div>';
|
||||
|
||||
if (view === 'matrix') { renderDeptMatrix(); return; }
|
||||
if (view === 'cf_matrix') { renderCfMatrix(); return; }
|
||||
if (view === 'project_matrix') { renderProjectMatrix(); return; }
|
||||
|
||||
var api = view === "quarterly" ? "/api/overview/plan-quarterly" : "/api/overview/plan-monthly";
|
||||
fetch(api).then(r => r.json()).then(data => {
|
||||
if (state.overviewView !== view) return;
|
||||
renderOverviewPlanData(data, view);
|
||||
}).catch(() => {
|
||||
if (state.overviewView !== view) return;
|
||||
container.innerHTML = '<div class="p-4 text-center text-slate-400">加载失败</div>';
|
||||
});
|
||||
}
|
||||
function renderProjectMatrix() {
|
||||
var c = document.querySelector("#overview_plan");
|
||||
if (!c) return;
|
||||
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载项目经营详情矩阵...</div>';
|
||||
fetch('/api/overview/dept-project-matrix').then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}).then(function(data) {
|
||||
renderProjectMatrixData(data, c);
|
||||
}).catch(function(e) {
|
||||
c.innerHTML = '<div class="p-3 text-center text-red-500 text-sm">加载失败: ' + (e.message || '未知错误') + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderProjectMatrixData(data, container) {
|
||||
try {
|
||||
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
|
||||
var tenants = data.tenants;
|
||||
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
|
||||
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
|
||||
var keys = ["sign","revenue","gross","cost","paid","payment","cashflow"];
|
||||
var labels = ["合同金额","确收金额","确收毛利","当期成本","当期流出","回款","项目现金流"];
|
||||
|
||||
var vBtns = btnBar('project_matrix');
|
||||
var cards = '';
|
||||
for (var mi = 11; mi >= 0; mi--) {
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
|
||||
if (isAllZero(data, tenants, keys.filter(function(k) { return k !== 'sign'; }), mi)) continue;
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
|
||||
var tb = projectMatrixTbody(data, tenants, mi, keys, labels, mf);
|
||||
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-brand-200" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
|
||||
}
|
||||
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
} catch(e) { container.innerHTML = '<div class="p-3 text-center text-red-500 text-sm">渲染失败: ' + (e.message || '未知错误') + '</div>'; }
|
||||
}
|
||||
|
||||
function projectMatrixTbody(data, tenants, mi, keys, labels, mf) {
|
||||
var tb = '';
|
||||
for (var ri = 0; ri < keys.length; ri++) {
|
||||
var rowCls = ri === keys.length - 1 ? 'font-semibold' : '';
|
||||
var ts = 0;
|
||||
var vals = [];
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
var v = data.data[tenants[ti]][keys[ri]][mi] || 0;
|
||||
ts += v;
|
||||
vals.push(v);
|
||||
}
|
||||
var tCls = 'text-slate-700';
|
||||
if (ri === 2) tCls = 'text-green-600';
|
||||
if (ri === keys.length - 1) tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
var cls = 'text-slate-700';
|
||||
if (ri === 2) cls = 'text-green-600';
|
||||
if (ri === keys.length - 1) cls = vals[ti] >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(vals[ti]) + '</td>';
|
||||
}
|
||||
rh += '</tr>';
|
||||
tb += rh;
|
||||
}
|
||||
return tb;
|
||||
}
|
||||
|
||||
function renderOverviewPlanData(data, view) {
|
||||
const container = document.querySelector("#overview_plan");
|
||||
if (!container) return;
|
||||
|
||||
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
||||
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
||||
|
||||
const isQuarterly = view === "quarterly";
|
||||
const headers = isQuarterly ? ['Q1','Q2','Q3','Q4'] : ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||||
const cols = isQuarterly ? 4 : 12;
|
||||
|
||||
const d = data.dept;
|
||||
const cf = data.dept_cf;
|
||||
const p = data.project;
|
||||
|
||||
// 视图切换按钮
|
||||
var btns = [];
|
||||
btns.push('<button onclick="setOverviewView(\'monthly\')" class="finance-tab' + (view === 'monthly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">月报总览</button>');
|
||||
btns.push('<button onclick="setOverviewView(\'quarterly\')" class="finance-tab' + (view === 'quarterly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">季报总览</button>');
|
||||
btns.push('<button onclick="setOverviewView(\'matrix\')" class="finance-tab' + (view === 'matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">损益详细报表</button>');
|
||||
btns.push('<button onclick="setOverviewView(\'cf_matrix\')" class="finance-tab' + (view === 'cf_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">现金流详细报表</button>'); btns.push('<button onclick="setOverviewView(\'project_matrix\')" class="finance-tab' + (view === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
|
||||
var viewBtns = '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
|
||||
|
||||
// ====== 部门损益情况 ======
|
||||
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||
for (var hi = 0; hi < cols; hi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[hi] + '</th>'; }
|
||||
deptThead += '</tr>';
|
||||
|
||||
var deptRows = [
|
||||
{ label: '项目毛利', vals: d.project_gross, annual: d.annual[0] },
|
||||
{ label: '部门费用', vals: d.dept_expense, annual: d.annual[1] },
|
||||
{ label: '利润', vals: d.profit, annual: d.annual[2] }
|
||||
];
|
||||
var deptTbody = '';
|
||||
for (var dri = 0; dri < 3; dri++) {
|
||||
var dr = deptRows[dri];
|
||||
var rowCls = dri === 2 ? 'font-semibold' : '';
|
||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + dr.label + '</td>';
|
||||
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
||||
for (var i = 0; i < cols; i++) {
|
||||
var cellCls = dri === 2 ? (dr.vals[i] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[i]) + '</td>';
|
||||
}
|
||||
deptTbody += rowHtml + '</tr>';
|
||||
}
|
||||
var deptCard = 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');
|
||||
|
||||
// ====== 部门现金流 ======
|
||||
var cfRows = [
|
||||
{ label: '项目现金流', vals: cf.project_cf, annual: cf.annual[0] },
|
||||
{ label: '部门现金流', vals: cf.dept_cf_item, annual: cf.annual[1] },
|
||||
{ label: '经营现金流', vals: cf.bl_cf, annual: cf.annual[2] }
|
||||
];
|
||||
var cfTbody = '';
|
||||
for (var cfri = 0; cfri < 3; cfri++) {
|
||||
var cfr = cfRows[cfri];
|
||||
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
|
||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + cfr.label + '</td>';
|
||||
var cAnnualCls = cfri === 2 ? (cfr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cAnnualCls + ' whitespace-nowrap">' + moneyIntL(cfr.annual) + '</td>';
|
||||
for (var i = 0; i < cols; i++) {
|
||||
var cCellCls = cfri === 2 ? (cfr.vals[i] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cCellCls + '">' + moneyIntL(cfr.vals[i]) + '</td>';
|
||||
}
|
||||
cfTbody += rowHtml + '</tr>';
|
||||
}
|
||||
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 LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
||||
var projVals = [p.sign_monthly || [], p.revenue, p.gross, p.cost, p.paid, p.payment, p.cashflow];
|
||||
var projAnnual = p.annual;
|
||||
|
||||
var projThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||
for (var phi = 0; phi < cols; phi++) { projThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[phi] + '</th>'; }
|
||||
projThead += '</tr>';
|
||||
|
||||
var projTbody = '';
|
||||
for (var ri = 0; ri < 7; ri++) {
|
||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap" style="min-width:100px">' + LABELS[ri] + '</td>';
|
||||
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(projAnnual[ri]) + '</td>';
|
||||
for (var i = 0; i < cols; i++) {
|
||||
var val = projVals[ri] ? (projVals[ri][i] || 0) : 0;
|
||||
var cls = 'text-slate-700';
|
||||
if (ri === 2) cls = 'text-green-600';
|
||||
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + moneyIntL(val) + '</td>';
|
||||
}
|
||||
projTbody += rowHtml + '</tr>';
|
||||
}
|
||||
var projCard = 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>' + projThead + '</thead><tbody>' + projTbody + '</tbody></table></div>', 'p-4');
|
||||
|
||||
container.innerHTML = '<div class="grid gap-2.5">' + viewBtns + deptCard + cfCard + projCard + '</div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
window.setOverviewView = (v) => { state.overviewView = v; renderOverviewPlan(); };
|
||||
|
||||
// ====== 损益报表 ======
|
||||
|
||||
function renderDeptMatrix() {
|
||||
var c = document.querySelector("#overview_plan");
|
||||
if (!c) return;
|
||||
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载损益矩阵...</div>';
|
||||
fetch('/api/overview/dept-matrix').then(r => r.json()).then(function(data) { renderDeptMatrixData(data, c); })
|
||||
.catch(function() { c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载失败</div>'; });
|
||||
}
|
||||
|
||||
function renderDeptMatrixData(data, container) {
|
||||
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
|
||||
var tenants = data.tenants;
|
||||
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
|
||||
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
|
||||
|
||||
var vBtns = btnBar('matrix');
|
||||
var cards = '';
|
||||
for (var mi = 11; mi >= 0; mi--) {
|
||||
if (isAllZero(data, tenants, ['project_gross','dept_expense','profit'], mi)) continue;
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
|
||||
var tb = matrixTbody(data, tenants, mi, ['project_gross','dept_expense','profit'], ['项目毛利','部门费用','利润'], mf);
|
||||
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-brand-200" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
|
||||
}
|
||||
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
// ====== 现金流报表 ======
|
||||
|
||||
function renderCfMatrix() {
|
||||
var c = document.querySelector("#overview_plan");
|
||||
if (!c) return;
|
||||
c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载现金流矩阵...</div>';
|
||||
fetch('/api/overview/dept-cf-matrix').then(r => r.json()).then(function(data) { renderCfMatrixData(data, c); })
|
||||
.catch(function() { c.innerHTML = '<div class="p-3 text-center text-slate-400 text-sm">加载失败</div>'; });
|
||||
}
|
||||
|
||||
function renderCfMatrixData(data, container) {
|
||||
var mf = function(v) { return Math.round(Number(v || 0)).toLocaleString("zh-CN"); };
|
||||
var tenants = data.tenants;
|
||||
var sn = tenants.map(function(t) { return t.replace('·无界', ''); });
|
||||
var mL = ['01月','02月','03月','04月','05月','06月','07月','08月','09月','10月','11月','12月'];
|
||||
|
||||
var vBtns = btnBar('cf_matrix');
|
||||
var cards = '';
|
||||
for (var mi = 11; mi >= 0; mi--) {
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
|
||||
if (isAllZero(data, tenants, ['project_cf','dept_cf','bl_cf'], mi)) continue;
|
||||
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
|
||||
th += '</tr>';
|
||||
|
||||
var tb = matrixTbody(data, tenants, mi, ['project_cf','dept_cf','bl_cf'], ['项目现金流','部门现金流','经营现金流'], mf);
|
||||
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-brand-200" style="width:120px;text-align:center;white-space:nowrap">' + mL[mi] + '</div><div class="card p-4 flex-1"><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table></div></div></div>';
|
||||
}
|
||||
container.innerHTML = '<div class="grid gap-2.5">' + vBtns + '<div class="flex flex-col gap-2">' + cards + '</div></div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
// ====== 共用工具 ======
|
||||
|
||||
function btnBar(active) {
|
||||
var btns = [];
|
||||
btns.push('<button onclick="setOverviewView(\'monthly\')" class="finance-tab' + (active === 'monthly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">月报总览</button>');
|
||||
btns.push('<button onclick="setOverviewView(\'quarterly\')" class="finance-tab' + (active === 'quarterly' ? ' active' : '') + '" style="font-size:14px;font-weight:600">季报总览</button>');
|
||||
btns.push('<button onclick="setOverviewView(\'matrix\')" class="finance-tab' + (active === 'matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">损益详细报表</button>');
|
||||
btns.push('<button onclick="setOverviewView(\'cf_matrix\')" class="finance-tab' + (active === 'cf_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">现金流详细报表</button>'); btns.push('<button onclick="setOverviewView(\'project_matrix\')" class="finance-tab' + (active === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
|
||||
return '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
|
||||
}
|
||||
|
||||
function matrixTbody(data, tenants, mi, keys, labels, mf) {
|
||||
var tb = '';
|
||||
for (var ri = 0; ri < keys.length; ri++) {
|
||||
var rowCls = ri === keys.length - 1 ? 'font-semibold' : '';
|
||||
var ts = 0;
|
||||
var vals = [];
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
var v = data.data[tenants[ti]][keys[ri]][mi] || 0;
|
||||
ts += v;
|
||||
vals.push(v);
|
||||
}
|
||||
var tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
var cls = vals[ti] >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(vals[ti]) + '</td>';
|
||||
}
|
||||
rh += '</tr>';
|
||||
tb += rh;
|
||||
}
|
||||
return tb;
|
||||
|
||||
|
||||
function isAllZero(data, tenants, keys, mi) {
|
||||
for (var ti = 0; ti < tenants.length; ti++) {
|
||||
for (var ki = 0; ki < keys.length; ki++) {
|
||||
if ((data.data[tenants[ti]][keys[ki]][mi] || 0) !== 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// ====== 项目经营详情报表 ======
|
||||
|
||||
|
||||
}
|
||||
@@ -53,7 +53,7 @@ window.renderPerformance = () => {
|
||||
totalScore += score;
|
||||
|
||||
return `<tr class="border-b border-slate-100">
|
||||
<td class="p-3 align-middle text-left font-medium text-slate-700"><i data-lucide="${r.icon}" style="width:16px;height:16px;display:inline-block;vertical-align:-3px;margin-right:6px" class="text-blue-500"></i>${r.label}</td>
|
||||
<td class="p-3 align-middle text-left font-medium text-slate-700"><i data-lucide="${r.icon}" style="width:16px;height:16px;display:inline-block;vertical-align:-3px;margin-right:6px" class="text-brand-500"></i>${r.label}</td>
|
||||
<td class="p-3 align-middle text-center">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<input type="number" step="0.1" min="0" value="${targetWan.toFixed(1)}" class="perf-target form-ctrl form-ctrl-sm w-20 text-center" style="appearance:none;-webkit-appearance:none" data-key="${r.key}" onchange="updatePerfScore('${r.key}','targetWan',this.value)">
|
||||
@@ -66,7 +66,7 @@ window.renderPerformance = () => {
|
||||
<input type="number" step="0.1" min="0" max="100" value="${weight}" class="perf-weight form-ctrl form-ctrl-sm w-16 text-center" style="appearance:none;-webkit-appearance:none" data-key="${r.key}" onchange="updatePerfScore('${r.key}','weight',this.value)">
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3 align-middle text-center font-semibold text-blue-700 text-sm">${score.toFixed(1)}</td>
|
||||
<td class="p-3 align-middle text-center font-semibold text-brand-700 text-sm">${score.toFixed(1)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -99,7 +99,7 @@ window.renderPerformance = () => {
|
||||
<td class="p-3 text-center text-slate-500">—</td>
|
||||
<td class="p-3 text-center text-slate-500">—</td>
|
||||
<td class="p-3 text-center text-slate-700">100</td>
|
||||
<td class="p-3 text-center text-blue-700 text-base">${totalScore.toFixed(1)}</td>
|
||||
<td class="p-3 text-center text-brand-700 text-base">${totalScore.toFixed(1)}</td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -76,7 +76,7 @@ function renderPlan() {
|
||||
if (isRevView) {
|
||||
const rev = b.rev || 0;
|
||||
const gross = b.gross || 0;
|
||||
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-blue-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
|
||||
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle"><span class="${rev ? 'text-brand-700 font-medium' : 'text-slate-300'}">${rev ? money(rev) : '—'}</span><br><span class="text-xs ${gross ? 'text-green-600' : 'text-slate-300'}">${gross ? money(gross) : '—'}</span></td>`;
|
||||
} else {
|
||||
const payment = b.payment || 0;
|
||||
const cost = b.cost || 0;
|
||||
@@ -87,7 +87,7 @@ function renderPlan() {
|
||||
if (isRevView) {
|
||||
const totalRev = pf.total_rev || 0;
|
||||
const totalGross = pf.total_gross || 0;
|
||||
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-blue-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
|
||||
return `<td class="px-2 py-px text-center whitespace-nowrap align-middle font-semibold"><span class="${totalRev ? 'text-brand-700' : 'text-slate-300'}">${totalRev ? money(totalRev) : '—'}</span><br><span class="text-xs ${totalGross ? 'text-green-600' : 'text-slate-300'}">${totalGross ? money(totalGross) : '—'}</span></td>`;
|
||||
} else {
|
||||
let totalPayment = 0, totalCost = 0;
|
||||
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
|
||||
@@ -95,34 +95,37 @@ function renderPlan() {
|
||||
}
|
||||
})();
|
||||
const sm = pf.sign_month || "";
|
||||
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||
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(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||
return `<tr class="border-b ${state._pmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="planShowCtx(event, ${pf.id})" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.weight || "20%")}</td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.project_type || "待签约")}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||
};
|
||||
|
||||
const now2 = new Date();
|
||||
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
|
||||
if (!state.planMonth) state.planMonth = defaultMonth2;
|
||||
if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3);
|
||||
if (!state.planYear) state.planYear = now2.getFullYear();
|
||||
if (!state.planView) state.planView = 'annual';
|
||||
const qLabels = ['Q1','Q2','Q3','Q4'];
|
||||
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
||||
const nowD = new Date();
|
||||
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
|
||||
const activeMonths = qMonths[activeQ];
|
||||
const yearOpts = [2024,2025,2026,2027];
|
||||
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setPlanView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">年份:</span><select onchange="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><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-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">月度:</span>${activeMonths.map(m => { const ms = `${state.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-blue-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"><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 finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
||||
|
||||
const isOverviewMode = state.tenant === "总览";
|
||||
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">经营月报</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用计划</button>`}</div>`;
|
||||
const isOverviewMode = false;
|
||||
const planFilterTabs = `<div class="finance-tabs" style="padding:0 0 0 0;border-bottom:1px solid #e2e8f0;margin-bottom:0;display:flex;gap:4px"><button onclick="switchPlanFilter('overview')" class="finance-tab${state.planFilter==='overview'?' active':''}" style="font-size:14px;font-weight:600;padding:8px 16px;margin-right:0">经营月报</button><button onclick="switchPlanFilter('quarterlyOverview')" class="finance-tab${state.planFilter==='quarterlyOverview'?' active':''}" style="font-size:14px;font-weight:600">经营季报</button>${isOverviewMode ? '' : `<button onclick="switchPlanFilter('projects')" class="finance-tab${state.planFilter==='projects'?' active':''}" style="font-size:14px;font-weight:600">项目管理 (${pfs.length})</button><button onclick="switchPlanFilter('expense')" class="finance-tab${state.planFilter==='expense'?' active':''}" style="font-size:14px;font-weight:600">平台费用</button>`}</div>`;
|
||||
|
||||
document.querySelector("#plan").innerHTML = `<div class="grid gap-2">
|
||||
${planFilterTabs}
|
||||
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#dbeafe,#bfdbfe);border-color:#3b82f6">
|
||||
<i data-lucide="lightbulb" class="text-blue-600 flex-shrink-0" style="width:18px;height:18px"></i>
|
||||
<span class="text-sm text-blue-900"><strong>计划提醒:</strong>计划也是预算,只有真正能够列出计划的预算,才有靠谱的落地可能性</span>
|
||||
${isOverviewMode ? '' : `<div class="flex items-center gap-3 px-4 py-2 rounded-lg border" style="background:linear-gradient(135deg,#fef9c3,#fde047);border-color:#eab308">
|
||||
<i data-lucide="lightbulb" class="text-yellow-700 flex-shrink-0" style="width:18px;height:18px"></i>
|
||||
<span class="text-sm text-yellow-900"><strong>预算:</strong>OPC 对每一个项目确收,毛利,回款,成本,支付,现金流,按月做出规划,回头对比已发生,看预算的准确率</span>
|
||||
</div>`}
|
||||
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4") : ''}
|
||||
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
|
||||
${planFilterTabs}
|
||||
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? '' : (state.planFilter === 'expense' ? '' : '')}
|
||||
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? '' : ''}
|
||||
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-600 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
|
||||
<div class="finance-tabs">
|
||||
<button class="finance-tab active" data-tab="info" onclick="switchPlanTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
|
||||
<button class="finance-tab" data-tab="revpay" onclick="switchPlanTab('revpay')"><i data-lucide="dollar-sign" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>确收与回款</button>
|
||||
@@ -142,7 +145,7 @@ function renderPlan() {
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
|
||||
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
|
||||
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions(new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'))}</select></label>
|
||||
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
@@ -154,7 +157,7 @@ function renderPlan() {
|
||||
<label class="block"><span class="fin-label">联系电话</span><input name="contact_phone" class="form-ctrl" placeholder="请输入联系电话"></label>
|
||||
<label class="block"><span class="fin-label">其他</span><input name="other_info" class="form-ctrl" placeholder="备注信息"></label></div><div class="grid grid-cols-3 gap-3"><label class="block"><span class="fin-label">客户名称</span><input name="client_name" class="form-ctrl" placeholder="客户名称"></label><label class="block"><span class="fin-label">项目类型</span><select name="project_type" class="form-ctrl bg-white"><option>待签约</option><option>已签约</option></select></label><label class="block"><span class="fin-label">加权</span><select name="weight" class="form-ctrl bg-white"><option>20%</option><option>40%</option><option>60%</option><option>80%</option><option>100%</option></select></label>
|
||||
</div>
|
||||
<div class="block"><span class="fin-label">业务类型</span><div class="flex flex-wrap gap-2 mt-1" id="plan_businessTypeChecks">${fmTypes.map(t => `<label class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-slate-200 cursor-pointer hover:border-blue-300 text-sm bt-chip" onclick="planToggleBtChip(this)"><input type="checkbox" name="business_type[]" value="${t}" class="hidden"><span>${t}</span></label>`).join("")}</div></div>
|
||||
<div class="block"><span class="fin-label">业务类型</span><div class="flex flex-wrap gap-2 mt-1" id="plan_businessTypeChecks">${fmTypes.map(t => `<label class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-slate-200 cursor-pointer hover:border-brand-300 text-sm bt-chip" onclick="planToggleBtChip(this)"><input type="checkbox" name="business_type[]" value="${t}" class="hidden"><span>${t}</span></label>`).join("")}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="plan_financeTabExec" class="hidden">
|
||||
@@ -170,9 +173,9 @@ function renderPlan() {
|
||||
</div>
|
||||
<div id="plan_financeTabRevpay" class="hidden">
|
||||
<div class="grid grid-cols-3 gap-3 mb-4" id="plan_revpaySummary">
|
||||
<div class="bg-blue-50 rounded-lg p-3 text-center border border-blue-100">
|
||||
<p class="text-xs text-blue-600 font-medium">总确收</p>
|
||||
<p class="text-lg font-bold text-blue-700" id="plan_revpayTotalRev">¥0</p>
|
||||
<div class="bg-brand-50 rounded-lg p-3 text-center border border-brand-100">
|
||||
<p class="text-xs text-brand-600 font-medium">总确收</p>
|
||||
<p class="text-lg font-bold text-brand-700" id="plan_revpayTotalRev">¥0</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-3 text-center border border-green-100">
|
||||
<p class="text-xs text-green-600 font-medium">总毛利</p>
|
||||
@@ -183,7 +186,7 @@ function renderPlan() {
|
||||
<p class="text-lg font-bold text-amber-700" id="plan_revpayTotalPayment">¥0</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_revpayTable">
|
||||
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="plan_revpayTable">
|
||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-right font-medium text-slate-500">确收</th><th class="p-2.5 text-right font-medium text-slate-500">毛利</th><th class="p-2.5 text-right font-medium text-slate-500">回款</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||
<tbody id="plan_revpayTbody"></tbody>
|
||||
</table>
|
||||
@@ -200,14 +203,14 @@ function renderPlan() {
|
||||
<p class="text-lg font-bold text-purple-700" id="plan_costTotalPaid">¥0</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_costTable">
|
||||
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="plan_costTable">
|
||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:140px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">费用类型</th><th class="p-2.5 text-right font-medium text-slate-500">成本</th><th class="p-2.5 text-right font-medium text-slate-500">已付</th><th class="p-2.5 text-left font-medium text-slate-500">备注</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||
<tbody id="plan_costTbody"></tbody>
|
||||
</table>
|
||||
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="planAddCostRow()"><i data-lucide="plus"></i>添加月份</button>
|
||||
</div>
|
||||
<div id="plan_financeTabTasks" class="hidden">
|
||||
<table class="w-full text-sm" style="line-height:0.95 border border-slate-200 rounded-lg overflow-hidden" id="plan_taskTable">
|
||||
<table class="w-full text-sm" style="line-height:1.37 border border-slate-200 rounded-lg overflow-hidden" id="plan_taskTable">
|
||||
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">任务类型</th><th class="p-2.5 text-right font-medium text-slate-500">任务数量</th><th class="p-2.5 text-right font-medium text-slate-500">已执行</th><th class="p-2.5 text-right font-medium text-slate-500">差额</th><th class="p-2.5 text-right font-medium text-slate-500">单价</th><th class="p-2.5 text-right font-medium text-slate-500">执行金额</th><th class="p-2.5 text-right font-medium text-slate-500">未执行金额</th><th class="p-2.5 w-8"></th></tr></thead>
|
||||
<tbody id="plan_taskTbody"></tbody>
|
||||
</table>
|
||||
@@ -251,23 +254,16 @@ function renderPlan() {
|
||||
return `<div id="planExpenseModule"></div>`;
|
||||
}
|
||||
const isUnsigned = false;
|
||||
const METRIC_CARDS = [
|
||||
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
|
||||
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
|
||||
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
|
||||
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
|
||||
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
|
||||
{ label: '已付', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
|
||||
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
|
||||
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
||||
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
|
||||
{ label: '付款率', hideUnsigned: true, value: ctx => ctx.sumCost && ctx.sumPaid ? Math.round(ctx.sumPaid / ctx.sumCost * 100) + '%' : '—', color: 'text-slate-500' },
|
||||
].filter(c => true);
|
||||
|
||||
|
||||
// 列排序
|
||||
if (!state.planSort) state.planSort = { key: null, asc: true };
|
||||
// 列排序(默认按确收金额从高到低)
|
||||
if (!state.planSort) state.planSort = { key: 'sig|rev', asc: false };
|
||||
const sortData = (data, key) => {
|
||||
if (!key || !state.planSort.key) return data;
|
||||
if (!key) return data;
|
||||
if (!state.planSort.key) {
|
||||
// 默认按确收金额从高到低
|
||||
return data.slice().sort(function(a, b) { return (parseFloat(b.rev) || 0) - (parseFloat(a.rev) || 0); });
|
||||
}
|
||||
var asc = state.planSort.asc ? 1 : -1;
|
||||
return data.slice().sort(function(a, b) {
|
||||
var va = a[key], vb = b[key];
|
||||
@@ -285,14 +281,13 @@ function renderPlan() {
|
||||
var canSort = sortKey && noSortKeys.indexOf(c.key) < 0;
|
||||
var arrow = canSort ? (sortKey === c.key ? (state.planSort.asc ? ' ↑' : ' ↓') : '') : '';
|
||||
var clickable = canSort ? ' cursor-pointer select-none' : '';
|
||||
h += '<th class="p-2 text-center font-semibold align-middle' + clickable + '"' + (canSort ? ' onclick="setPlanSort(\'' + sortKey + '|' + c.key + '\')"' : '') + '>' + c.label + arrow + '</th>';
|
||||
h += '<th class="px-2 py-px text-center font-semibold align-middle' + clickable + '"' + (canSort ? ' onclick="setPlanSort(\'' + sortKey + '|' + c.key + '\')"' : '') + '>' + c.label + arrow + '</th>';
|
||||
});
|
||||
h += '</tr></thead>';
|
||||
return h;
|
||||
};
|
||||
const SIGNED_COLS = [
|
||||
{ key: '_expand', label: '' },
|
||||
{ key: 'client_name', label: '客户' },
|
||||
{ key: 'customer_name', label: '项目名称' },
|
||||
{ key: 'weight', label: '加权' },
|
||||
{ key: 'project_type', label: '项目类型' },
|
||||
@@ -301,14 +296,14 @@ function renderPlan() {
|
||||
{ key: 'gross', label: '毛利' },
|
||||
{ key: 'gross_rate', label: '毛利率' },
|
||||
{ key: 'payment', label: '回款' },
|
||||
{ key: 'pending', label: '应收' },
|
||||
{ key: 'pay_rate', label: '回款率' },
|
||||
{ key: 'cost', label: '成本' },
|
||||
{ key: 'paid', label: '付款' },
|
||||
{ key: 'paid_rate', label: '付款率' },
|
||||
{ key: 'paid', label: '支出' },
|
||||
{ key: 'payable', label: '应付' },
|
||||
{ key: 'cashflow', label: '现金流' },
|
||||
];
|
||||
const UNSIGNED_COLS = [
|
||||
{ key: 'client_name', label: '客户' },
|
||||
{ key: 'customer_name', label: '项目名称' },
|
||||
{ key: 'weight', label: '加权' },
|
||||
{ key: 'project_type', label: '项目类型' },
|
||||
@@ -320,58 +315,95 @@ function renderPlan() {
|
||||
];
|
||||
|
||||
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
||||
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt };
|
||||
const cards = METRIC_CARDS.map(c => {
|
||||
const v = c.value(ctx);
|
||||
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
|
||||
});
|
||||
const receivable = sumRev - sumPay;
|
||||
const payable = sumCost - sumPaid;
|
||||
const cashflow = sumPay - sumPaid;
|
||||
const netAsset = receivable - payable;
|
||||
const m = moneyWan;
|
||||
const rows_data = [
|
||||
['签约金额', m(signTotal), '∑签约金额'],
|
||||
['确收金额', m(sumRev), '∑确收金额'],
|
||||
['毛利', m(sumGross), '∑毛利'],
|
||||
['回款', m(sumPay), '∑回款金额'],
|
||||
['支出', m(sumPaid), '∑支出金额'],
|
||||
['现金流', m(cashflow), '回款 − 支出'],
|
||||
['应收', m(receivable), '确收 − 回款'],
|
||||
['应付', m(payable), '成本 − 支出'],
|
||||
['往来余额', m(netAsset), '应收 − 应付'],
|
||||
];
|
||||
const cardHtml = (r, i) => '<div class="card px-2 py-1.5 text-center flex-1" style="background:' + (['var(--brand-50)','var(--brand-50)','var(--brand-50)','#fef3c7','#fef3c7','#fef3c7','#fef9c3','#fef9c3','#fef9c3'][i] || '#fff') + '"><div class="text-xs text-slate-600">' + r[0] + '</div><div class="text-sm font-semibold text-slate-800">' + r[1] + '</div></div>';
|
||||
const fs = '<div class="flex gap-1 items-stretch py-2">' + cardHtml(rows_data[0],0) + cardHtml(rows_data[1],1) + cardHtml(rows_data[2],2) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[3],3) + cardHtml(rows_data[4],4) + cardHtml(rows_data[5],5) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[6],6) + cardHtml(rows_data[7],7) + cardHtml(rows_data[8],8) + '</div>';
|
||||
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
||||
const theadCols = isUnsigned ? 9 : 14;
|
||||
const tbody = rows.length ? `<tbody style="line-height:0.95">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
|
||||
|
||||
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
|
||||
const theadCols = isUnsigned ? 8 : 15;
|
||||
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-700">${emptyText}</td></tr>`;
|
||||
const subtitle = isUnsigned
|
||||
? '合同 → 毛利 → 成本 → 项目利润'
|
||||
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流';
|
||||
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
|
||||
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
||||
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流';
|
||||
return `${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
|
||||
card(fs, "px-4 py-2") +
|
||||
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-600 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
|
||||
};
|
||||
|
||||
const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>';
|
||||
|
||||
// 状态过滤函数
|
||||
const filterByStatus = (items) => {
|
||||
var sf = state.planStatusFilter || 'all';
|
||||
if (sf === 'all') return items;
|
||||
return items.filter(d => {
|
||||
if (sf === 'unsigned') return (d.pf.project_type || '待签约') === '待签约';
|
||||
if (sf === 'signed') return (d.pf.project_type || '待签约') === '已签约';
|
||||
if (sf === 'paymentonly') {
|
||||
return (!d.rev && !d.gross && !d.payment) && (d.cost || d.paid);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const tdRow = (pf, rev, payment, cost, paid, gross) => {
|
||||
const cashflow = payment - paid;
|
||||
const payRVal = rev && payment ? Math.round(payment / rev * 100) : null;
|
||||
const payRCls = payRVal === null ? '' : payRVal < 30 ? 'text-red-600' : payRVal < 80 ? 'text-amber-600' : 'text-green-600';
|
||||
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
|
||||
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
||||
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
const payable = cost - paid;
|
||||
const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-400';
|
||||
const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>';
|
||||
const pending = rev - payment;
|
||||
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700';
|
||||
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
|
||||
var budgetArr = [];
|
||||
var expenseArr = [];
|
||||
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
|
||||
try { expenseArr = JSON.parse(pf.expense_data || "[]"); } catch(e) {}
|
||||
var detailRows = budgetArr.filter(function(b) {
|
||||
var exp = expenseArr.find(function(e) { return e.month === (b.month || ''); }) || {};
|
||||
// 合并 budget 和 expense 的所有月份
|
||||
var allMonths = {};
|
||||
budgetArr.forEach(function(b) { allMonths[b.month || ''] = true; });
|
||||
expenseArr.forEach(function(e) { allMonths[e.month || ''] = true; });
|
||||
var detailRows = Object.keys(allMonths).filter(function(m) {
|
||||
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
|
||||
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
|
||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||
return bRev || bGross || bPay || eCost || ePaid;
|
||||
}).map(function(b) {
|
||||
var m = b.month || '';
|
||||
var exp = expenseArr.find(function(e) { return e.month === m; }) || {};
|
||||
}).sort().map(function(m) {
|
||||
var b = budgetArr.find(function(x) { return x.month === m; }) || {};
|
||||
var exp = expenseArr.find(function(x) { return x.month === m; }) || {};
|
||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
||||
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||
var bCf = bPay - ePaid;
|
||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td></tr>';
|
||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-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('');
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="p-2 text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||
return `<tr class="border-b ${state._pmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="planShowCtx(event, ${pf.id})" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-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 align-middle text-sm font-semibold ${payableCls}">${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 font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-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 profit = gross;
|
||||
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="p-2 text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
||||
return `<tr class="border-b ${state._pmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="planShowCtx(event, ${pf.id})" onclick="planOpenPfEditModal(${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" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center font-semibold align-middle ${pfCls}">${pfVal}</td></tr>`;
|
||||
};
|
||||
|
||||
if (state.planView === 'monthly') {
|
||||
@@ -407,7 +439,7 @@ function renderPlan() {
|
||||
});
|
||||
});
|
||||
var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
|
||||
dataItems = sortData(dataItems, sortK);
|
||||
dataItems = filterByStatus(sortData(dataItems, sortK));
|
||||
const rows = [];
|
||||
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
|
||||
dataItems.forEach(d => {
|
||||
@@ -462,7 +494,7 @@ function renderPlan() {
|
||||
});
|
||||
});
|
||||
var sortK = state.planSort && state.planSort.key ? state.planSort.key.split('|')[1] : null;
|
||||
dataItems = sortData(dataItems, sortK);
|
||||
dataItems = filterByStatus(sortData(dataItems, sortK));
|
||||
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
|
||||
const rows = [];
|
||||
dataItems.forEach(d => {
|
||||
@@ -503,7 +535,7 @@ function renderPlan() {
|
||||
});
|
||||
var sortK = null;
|
||||
if (state.planSort && state.planSort.key && state.planSort.key.startsWith('sig|')) sortK = state.planSort.key.split('|')[1];
|
||||
dataItems = sortData(dataItems, sortK);
|
||||
dataItems = filterByStatus(sortData(dataItems, sortK));
|
||||
let sumRev = 0, sumPay = 0, sumCost = 0, sumPaid = 0, sumGross = 0;
|
||||
const rows = [];
|
||||
dataItems.forEach(d => {
|
||||
@@ -534,7 +566,7 @@ window.openPlanModal = () => {
|
||||
modal.classList.remove("hidden");
|
||||
};
|
||||
|
||||
window.planAddRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_note = '') => {
|
||||
window.planAddRevpayRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), rev = '', gross = '', payment = '', rev_note = '') => {
|
||||
const tbody = document.querySelector("#plan_revpayTbody");
|
||||
if (!tbody) return;
|
||||
const row = document.createElement("tr");
|
||||
@@ -548,7 +580,7 @@ window.planAddRevpayRow = (month = '', rev = '', gross = '', payment = '', rev_n
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
};
|
||||
|
||||
window.planAddCostRow = (month = '', expense_type = '', cost = '', paid = '', exp_note = '') => {
|
||||
window.planAddCostRow = (month = new Date().getFullYear() + '-' + String(new Date().getMonth() + 1).padStart(2, '0'), expense_type = '', cost = '', paid = '', exp_note = '') => {
|
||||
const tbody = document.querySelector("#plan_costTbody");
|
||||
if (!tbody) return;
|
||||
const row = document.createElement("tr");
|
||||
@@ -613,13 +645,12 @@ window.planInitCostTable = (expenseData) => {
|
||||
// ---------- 任务管理 tab ----------
|
||||
|
||||
function planTaskMonthOptions(selected) {
|
||||
const now = new Date();
|
||||
const opts = [];
|
||||
for (let i = -2; i <= 12; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||
const v = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
|
||||
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
||||
}
|
||||
for (let y = 2020; y <= 2027; y++)
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const v = y + "-" + String(m).padStart(2, "0");
|
||||
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
||||
}
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
@@ -631,7 +662,7 @@ window.planAddTaskRow = (taskMonth = '', taskType = '', taskCount = '', executed
|
||||
const isPreset = TASK_TYPES.includes(taskType);
|
||||
const typeCell = isPreset || !taskType
|
||||
? `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="planOnTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option ${t === taskType ? 'selected' : ''}>${t}</option>`).join("")}<option value="__custom__" ${!isPreset && taskType ? 'selected' : ''}>自定义...</option></select>`
|
||||
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-700 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||
row.innerHTML = `<td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full">${planTaskMonthOptions(taskMonth || defaultMonth)}</select></td>
|
||||
<td class="flex items-center">${typeCell}</td>
|
||||
<td><input name="task_count[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${taskCount}" oninput="planUpdateTaskDiff(this)"></td>
|
||||
@@ -676,9 +707,9 @@ function planUpdateRowCalc(row) {
|
||||
window.planToggleBtChip = (chip) => {
|
||||
const cb = chip.querySelector('input');
|
||||
cb.checked = !cb.checked;
|
||||
chip.classList.toggle('bg-blue-50', cb.checked);
|
||||
chip.classList.toggle('border-blue-400', cb.checked);
|
||||
chip.classList.toggle('text-blue-600', cb.checked);
|
||||
chip.classList.toggle('bg-brand-50', cb.checked);
|
||||
chip.classList.toggle('border-brand-400', cb.checked);
|
||||
chip.classList.toggle('text-brand-600', cb.checked);
|
||||
};
|
||||
|
||||
window.planInitTaskTable = (taskData) => {
|
||||
@@ -693,7 +724,7 @@ window.planOnTaskTypeChange = (sel) => {
|
||||
if (sel.value !== '__custom__') return;
|
||||
const td = sel.parentElement;
|
||||
const oldVal = sel.value;
|
||||
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-700 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
td.querySelector('input').focus();
|
||||
};
|
||||
@@ -725,11 +756,11 @@ window.planEditSignMonth = (event, pfId) => {
|
||||
try {
|
||||
await api(`/api/planFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
|
||||
pf.sign_month = newValue;
|
||||
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-blue-600" onclick="planEditSignMonth(event, ${pfId})">${newValue || '—'}</span>`;
|
||||
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-brand-600" onclick="planEditSignMonth(event, ${pfId})">${newValue || '—'}</span>`;
|
||||
} catch (e) { toast("修改失败:" + e.message, "error"); }
|
||||
});
|
||||
select.addEventListener("blur", () => {
|
||||
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-blue-600" onclick="planEditSignMonth(event, ${pfId})">${currentValue || '—'}</span>`;
|
||||
td.innerHTML = `<span class="pf-sm-text cursor-pointer hover:text-brand-600" onclick="planEditSignMonth(event, ${pfId})">${currentValue || '—'}</span>`;
|
||||
});
|
||||
td.innerHTML = "";
|
||||
td.appendChild(select);
|
||||
@@ -756,8 +787,8 @@ async function planLoadFollowups(pfId) {
|
||||
try {
|
||||
const fups = await api(`/api/followups/project_finance/${pfId}`);
|
||||
list.innerHTML = fups.length
|
||||
? fups.map(f => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" type="button" onclick="planDeleteFollowup(event, ${f.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")
|
||||
: '<p class="text-sm text-slate-400 py-4 text-center">暂无跟进记录</p>';
|
||||
? fups.map(f => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-brand-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" type="button" onclick="planDeleteFollowup(event, ${f.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")
|
||||
: '<p class="text-sm text-slate-700 py-4 text-center">暂无跟进记录</p>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
list.querySelectorAll(".rich-content").forEach(el => {
|
||||
const html = el.dataset.html;
|
||||
@@ -829,9 +860,9 @@ window.planOpenPfEditModal = (pfId) => {
|
||||
cb.checked = btValues.includes(cb.value);
|
||||
const chip = cb.closest('.bt-chip');
|
||||
if (chip) {
|
||||
chip.classList.toggle('bg-blue-50', cb.checked);
|
||||
chip.classList.toggle('border-blue-400', cb.checked);
|
||||
chip.classList.toggle('text-blue-600', cb.checked);
|
||||
chip.classList.toggle('bg-brand-50', cb.checked);
|
||||
chip.classList.toggle('border-brand-400', cb.checked);
|
||||
chip.classList.toggle('text-brand-600', cb.checked);
|
||||
}
|
||||
});
|
||||
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
|
||||
@@ -978,7 +1009,8 @@ window.createPlanFinance = async (event) => {
|
||||
document.querySelector("#planModalTitle").textContent = "新增项目财务";
|
||||
closePlanModal();
|
||||
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderPlan();
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
} catch (error) {
|
||||
toast("刷新失败:" + error.message, "error");
|
||||
}
|
||||
@@ -994,7 +1026,8 @@ window.deletePlanItem = async () => {
|
||||
await api(`/api/planFinances/${pfId}`, { method: "DELETE" });
|
||||
closePlanModal();
|
||||
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderPlan();
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
toast("已删除", "success");
|
||||
} catch (error) {
|
||||
toast("删除失败:" + error.message, "error");
|
||||
@@ -1088,8 +1121,8 @@ function renderPlanOverview() {
|
||||
for (var dmi = 0; dmi < 12; dmi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[dmi] + '</th>'; }
|
||||
deptThead += '</tr>';
|
||||
var deptRows = [
|
||||
{ label: '毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
|
||||
{ label: '平台费用', vals: expMonthly, annual: expAnnual },
|
||||
{ label: '项目毛利', vals: fm.map(function(x) { return x ? x.gross : 0; }), annual: grossAnnual },
|
||||
{ label: '部门费用', vals: expMonthly, annual: expAnnual },
|
||||
{ label: '利润', vals: fm.map(function(x, i) { return (x ? x.gross : 0) - expMonthly[i]; }), annual: grossAnnual - expAnnual }
|
||||
];
|
||||
var deptTbody = '';
|
||||
@@ -1105,7 +1138,7 @@ function renderPlanOverview() {
|
||||
}
|
||||
deptTbody += rowHtml + '</tr>';
|
||||
}
|
||||
var deptCard = card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4');
|
||||
var deptCard = 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');
|
||||
|
||||
|
||||
// 业务线现金流卡片
|
||||
@@ -1123,8 +1156,8 @@ function renderPlanOverview() {
|
||||
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||||
var cfRows = [
|
||||
{ label: '项目现金流', vals: cfMonthly, annual: cfAnnual },
|
||||
{ label: '平台费用(已付)', vals: expPaidMonthly, annual: expPaidAnnual },
|
||||
{ label: '业务线现金流', vals: blCfMonthly, annual: blCfAnnual }
|
||||
{ label: '部门现金流', vals: expPaidMonthly, annual: expPaidAnnual },
|
||||
{ label: '经营现金流', vals: blCfMonthly, annual: blCfAnnual }
|
||||
];
|
||||
var cfTbody = '';
|
||||
for (var cfri = 0; cfri < 3; cfri++) {
|
||||
@@ -1140,16 +1173,16 @@ function renderPlanOverview() {
|
||||
}
|
||||
cfTbody += cfRowHtml + '</tr>';
|
||||
}
|
||||
var cfCard = card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
||||
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">' +
|
||||
deptCard +
|
||||
cfCard +
|
||||
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 已付</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
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">' +
|
||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="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-400">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-400">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">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">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">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
|
||||
'</div></div>';
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
@@ -1193,9 +1226,9 @@ function renderPlanQuarterlyOverview() {
|
||||
}
|
||||
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
|
||||
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
|
||||
thead += '</tr>';
|
||||
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
|
||||
var tbody = '';
|
||||
@@ -1225,11 +1258,11 @@ function renderPlanQuarterlyOverview() {
|
||||
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
|
||||
var grossAnnual = annualSums2.gross;
|
||||
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>'; }
|
||||
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>';
|
||||
var deptRows = [
|
||||
{ label: '毛利', vals: grossQ, annual: grossAnnual },
|
||||
{ label: '平台费用', vals: expQ, annual: expAnnual },
|
||||
{ label: '项目毛利', vals: grossQ, annual: grossAnnual },
|
||||
{ label: '部门费用', vals: expQ, annual: expAnnual },
|
||||
{ label: '利润', vals: grossQ.map(function(v, i) { return v - expQ[i]; }), annual: grossAnnual - expAnnual }
|
||||
];
|
||||
var deptTbody = '';
|
||||
@@ -1242,6 +1275,7 @@ function renderPlanQuarterlyOverview() {
|
||||
for (var qi = 0; qi < 4; qi++) {
|
||||
var cellCls = dri === 2 ? (dr.vals[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[qi]) + '</td>';
|
||||
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(dr.vals[qi], dr.vals[qi - 1]) + '</td>';
|
||||
}
|
||||
deptTbody += rowHtml + '</tr>';
|
||||
}
|
||||
@@ -1260,8 +1294,8 @@ function renderPlanQuarterlyOverview() {
|
||||
var blCfAnnual = cfAnnual - expPaidAnnual;
|
||||
var cfRows = [
|
||||
{ label: '项目现金流', vals: cfQ, annual: cfAnnual },
|
||||
{ label: '平台费用(已付)', vals: expPaidQ, annual: expPaidAnnual },
|
||||
{ label: '业务线现金流', vals: blCfQ, annual: blCfAnnual }
|
||||
{ label: '部门现金流', vals: expPaidQ, annual: expPaidAnnual },
|
||||
{ label: '经营现金流', vals: blCfQ, annual: blCfAnnual }
|
||||
];
|
||||
var cfTbody = '';
|
||||
for (var cfri = 0; cfri < 3; cfri++) {
|
||||
@@ -1273,14 +1307,15 @@ function renderPlanQuarterlyOverview() {
|
||||
for (var qi = 0; qi < 4; qi++) {
|
||||
var cellCls = cfri === 2 ? (blCfQ[qi] >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(cfdr.vals[qi]) + '</td>';
|
||||
if (qi > 0) rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap">' + qoq(cfdr.vals[qi], cfdr.vals[qi - 1]) + '</td>';
|
||||
}
|
||||
cfTbody += rowHtml + '</tr>';
|
||||
}
|
||||
|
||||
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
|
||||
card('<h3 class="text-sm font-bold text-slate-700">业务线经营情况</h3><p class="text-xs text-slate-400 mb-3">毛利 - 平台费用 = 利润</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-sm font-bold text-slate-700">业务线现金流</h3><p class="text-xs text-slate-400 mb-3">项目现金流 - 平台费用(已付)= 业务线现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-sm font-bold text-slate-700">业务(项目)财务季度概览</h3><p class="text-xs text-slate-400 mb-3">合同 → 确收 → 毛利 → 成本 → 支出 → 回款 → 项目现金流</p><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
card('<h3 class="text-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>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||
'</div>';
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
@@ -1299,7 +1334,7 @@ function renderPlanCharts(data) {
|
||||
var c2 = document.querySelector("#planChartRev");
|
||||
if (c2 && window.Chart) {
|
||||
if (state.planChart2) state.planChart2.destroy();
|
||||
state.planChart2 = new Chart(c2, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "确收", data: data.map(function(x) { return x.revenue || 0; }), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, { label: "毛利", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }] } }));
|
||||
state.planChart2 = new Chart(c2, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "确收", data: data.map(function(x) { return x.revenue || 0; }), borderColor: "var(--brand-600)", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 }, { label: "毛利", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 }] } }));
|
||||
}
|
||||
var c3 = document.querySelector("#planChartCash");
|
||||
if (c3 && window.Chart) {
|
||||
@@ -1313,16 +1348,69 @@ function planInlineSelect(name, pfId, field, currentVal, options) {
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
opts += '<option value="' + options[i] + '"' + (options[i] === currentVal ? ' selected' : '') + '>' + options[i] + '</option>';
|
||||
}
|
||||
return '<select onchange="planInlineUpdate(' + pfId + ', \'' + field + '\'' + ', this.value)" class="text-sm bg-transparent border-0 cursor-pointer text-center" style="appearance:none;-webkit-appearance:none;vertical-align:middle;padding-right:14px;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2210%22 height=%2210%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%23999%22 stroke-width=%222%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right center">' + opts + '</select>';
|
||||
return '<select onchange="planInlineUpdate(' + pfId + ', \'' + field + '\'' + ', this.value)" class="text-sm bg-transparent border-0 cursor-pointer text-center" style="appearance:none;-webkit-appearance:none;vertical-align:middle;height:20px;line-height:1;padding:0 14px 0 0;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2210%22 height=%2210%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%23999%22 stroke-width=%222%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right center">' + opts + '</select>';
|
||||
}
|
||||
|
||||
window.planInlineUpdate = async (pfId, field, value) => {
|
||||
try {
|
||||
await api("/api/planFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data: { [field]: value } }) });
|
||||
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
renderPlan();
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
toast("已更新", "success");
|
||||
} catch (error) {
|
||||
toast("更新失败:" + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// ====== 筛选器控制函数 ======
|
||||
window.switchPlanFilter = (f) => { state.planFilter = f; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanView = (v) => { state.planView = v; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanYear = (y) => { state.planYear = parseInt(y); localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanQuarter = (q) => { state.planView = 'quarterly'; state.planQuarter = q; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanMonth = (m) => { state.planView = 'monthly'; state.planMonth = m; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
window.setPlanStatusFilter = (f) => { state.planStatusFilter = f; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan(); };
|
||||
|
||||
|
||||
// ---- 项目标记上下文菜单 ----
|
||||
if (!state._pmMarks) state._pmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_pm_marks')) || { red: {} }; } catch(e) { return { red: {} }; } })();
|
||||
window.planShowCtx = function(e, pfId) {
|
||||
e.preventDefault();
|
||||
var menu = document.getElementById('planCtxMenu');
|
||||
if (!menu) {
|
||||
menu = document.createElement('div');
|
||||
menu.id = 'planCtxMenu';
|
||||
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>' +
|
||||
'<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>';
|
||||
menu.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
var id = menu.getAttribute('data-pfid');
|
||||
menu.style.display = 'none';
|
||||
if (state._pmMarks.red[id]) { delete state._pmMarks.red[id]; } else { state._pmMarks.red[id] = true; }
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
}
|
||||
menu.setAttribute('data-pfid', pfId);
|
||||
menu.style.display = 'block';
|
||||
menu.style.left = e.pageX + 'px';
|
||||
menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px';
|
||||
};
|
||||
document.addEventListener('click', function() { var m = document.getElementById('planCtxMenu'); if (m) m.style.display = 'none'; });
|
||||
|
||||
window.planCtxMenu_act = function(e, type) {
|
||||
e.stopPropagation();
|
||||
var pfId = document.getElementById("planCtxMenu").getAttribute("data-pfid");
|
||||
if (type === "clear") { delete state._pmMarks.red[pfId]; delete state._pmMarks.pin[pfId]; }
|
||||
else { if (state._pmMarks[type][pfId]) { delete state._pmMarks[type][pfId]; } else { state._pmMarks[type][pfId] = true; } }
|
||||
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||
renderPlan();
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ function renderPlanExpense() {
|
||||
var records = state.data.planExpense || [];
|
||||
var money = function(v) { return '¥ ' + (Number(v || 0)).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); };
|
||||
var esc = function(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
||||
var EXPENSE_TYPES = ['通用费用','平台采购','人力成本','研发费用','奖金','其他'];
|
||||
var EXPENSE_TYPES = ['人力成本','研发费用','奖金','平台采购','通用费用','其他'];
|
||||
var STATUS_OPTIONS = ['计入费用','暂不计入'];
|
||||
|
||||
// 排序状态
|
||||
@@ -55,9 +55,9 @@ function renderPlanExpense() {
|
||||
var totalAmount = filtered.reduce(function(s, r) { return s + (r.amount || 0); }, 0);
|
||||
var totalIncurred = filtered.reduce(function(s, r) { return s + (r.incurred_amount || 0); }, 0);
|
||||
|
||||
// 月份下拉选项(去年/今年/明年)
|
||||
// 月份下拉选项(2020-2027)
|
||||
var monthOpts = '';
|
||||
for (var yr = thisYear - 1; yr <= thisYear + 1; yr++)
|
||||
for (var yr = 2020; yr <= 2027; yr++)
|
||||
for (var m = 1; m <= 12; m++) {
|
||||
var mv = yr + '-' + String(m).padStart(2, '0');
|
||||
monthOpts += '<option value="' + mv + '">' + mv + '</option>';
|
||||
@@ -78,12 +78,16 @@ function renderPlanExpense() {
|
||||
dateSelect = '<span class="text-sm text-slate-500 ml-2">季度:</span><select onchange="setPlanExpQuarter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px">' + quarterOpts.replace('value="' + state.planExpQuarter + '"', 'value="' + state.planExpQuarter + '" selected') + '</select>';
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select><span class="text-sm text-slate-500 ml-3">类型:</span><select onchange="setPlanExpFilter(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="全部"' + (typeFilter==='全部'?' selected':'') + '>全部 (' + records.length + ')</option>';
|
||||
// 类型 Tab 按钮
|
||||
var typeTabs = '<div class="finance-tabs" style="display:flex;gap:4px;flex-wrap:wrap"><button onclick="setPlanExpFilter(\'全部\')" class="finance-tab' + (typeFilter==='全部'?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">全部 (' + records.length + ')</button>';
|
||||
EXPENSE_TYPES.forEach(function(t) {
|
||||
toolbar += '<option value="' + t + '"' + (typeFilter===t?' selected':'') + '>' + t + ' (' + records.filter(function(r){return r.expense_type===t}).length + ')</option>';
|
||||
var cnt = records.filter(function(r){return r.expense_type===t}).length;
|
||||
typeTabs += '<button onclick="setPlanExpFilter(\'' + t + '\')" class="finance-tab' + (typeFilter===t?' active':'') + '" style="font-size:13px;font-weight:600;padding:6px 14px">' + t + ' (' + cnt + ')</button>';
|
||||
});
|
||||
toolbar += '</select>' + dateSelect;
|
||||
typeTabs += '</div>';
|
||||
|
||||
// Toolbar(视图+日期选择器)
|
||||
var toolbar = '<span class="text-sm text-slate-500">视图:</span><select onchange="setPlanExpView(this.value)" class="text-sm font-medium py-1 px-2.5 cursor-pointer" style="appearance:none;-webkit-appearance:none;background-color:transparent;border:0;outline:none;background:url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%236b7280%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3E%3Cpolyline points=%226 9 12 15 18 9%22%3E%3C/polyline%3E%3C/svg%3E') no-repeat right 4px center;padding-right:22px;min-height:30px"><option value="total"' + (view==='total'?' selected':'') + '>总视图</option><option value="quarterly"' + (view==='quarterly'?' selected':'') + '>季度视图</option><option value="monthly"' + (view==='monthly'?' selected':'') + '>月度视图</option></select>' + dateSelect;
|
||||
|
||||
// 排序箭头
|
||||
var sortArrow = function(key) {
|
||||
@@ -103,12 +107,13 @@ function renderPlanExpense() {
|
||||
tableRows = '<tr><td colspan="6" class="p-6 text-center text-slate-400">' + emptyMsg + '</td></tr>';
|
||||
}
|
||||
|
||||
// 模态框 monthOpts for form (with current value preselected)
|
||||
// 模态框 monthOpts for form (2020-2027)
|
||||
var curMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||
var modalMonthOpts = '<option value="">请选择</option>';
|
||||
for (var yr2 = thisYear - 1; yr2 <= thisYear + 1; yr2++)
|
||||
for (var yr2 = 2020; yr2 <= 2027; yr2++)
|
||||
for (var m2 = 1; m2 <= 12; m2++) {
|
||||
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
|
||||
modalMonthOpts += '<option value="' + mv2 + '">' + mv2 + '</option>';
|
||||
modalMonthOpts += '<option value="' + mv2 + '"' + (mv2 === curMonth ? ' selected' : '') + '>' + mv2 + '</option>';
|
||||
}
|
||||
var statusOpts = STATUS_OPTIONS.map(function(s) { return '<option value="' + s + '">' + s + '</option>'; }).join('');
|
||||
|
||||
@@ -119,12 +124,12 @@ function renderPlanExpense() {
|
||||
var target = document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseModule") || document.querySelector("#planExpenseContent");
|
||||
if (!target) return;
|
||||
target.innerHTML = '<div class="grid gap-4">' +
|
||||
'<div class="card p-3"><div class="flex justify-between items-center"><div class="flex items-center gap-2">' + toolbar + '</div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
|
||||
'<div class="grid grid-cols-3 gap-3">' +
|
||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">费用记录</span><strong class="mt-2 block text-xl">' + filtered.length + '</strong></div>' +
|
||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">总费用</span><strong class="mt-2 block text-xl">' + money(totalAmount) + '</strong></div>' +
|
||||
'<div class="metric-card p-3"><span class="flex items-center gap-2 text-sm text-slate-500">现金支出</span><strong class="mt-2 block text-xl">' + money(totalIncurred) + '</strong></div>' +
|
||||
'<div class="grid grid-cols-3 gap-1.5 mb-1.5">' +
|
||||
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">费用记录</span> <strong class="text-base text-slate-700">' + filtered.length + '</strong></div>' +
|
||||
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">总费用</span> <strong class="text-base text-rose-600">' + money(totalAmount) + '</strong></div>' +
|
||||
'<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">现金支出</span> <strong class="text-base text-purple-600">' + money(totalIncurred) + '</strong></div>' +
|
||||
'</div>' +
|
||||
'<div class="card p-3"><div class="flex justify-between items-center flex-wrap gap-2"><div class="flex items-center gap-2">' + toolbar + '<div class="flex items-center gap-1">' + typeTabs + '</div></div><button class="btn btn-primary btn-sm" onclick="openPlanExpModal()">新增费用</button></div></div>' +
|
||||
modal +
|
||||
'<div class="card p-4"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_month\')">月份' + sortArrow('expense_month') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'expense_type\')">费用类型' + sortArrow('expense_type') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'status\')">状态' + sortArrow('status') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'amount\')">金额' + sortArrow('amount') + '</th><th class="p-2 text-center font-semibold cursor-pointer select-none" onclick="togglePlanExpSort(\'incurred_amount\')">现金支出' + sortArrow('incurred_amount') + '</th><th class="p-2 text-center font-semibold">费用说明</th></tr></thead><tbody>' + tableRows + '</tbody></table></div></div>' +
|
||||
'</div>';
|
||||
|
||||
@@ -104,11 +104,11 @@ window.editProductDate = (event, id) => {
|
||||
try {
|
||||
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { launch_date: newValue } }) });
|
||||
product.launch_date = newValue;
|
||||
td.innerHTML = `<span class="cursor-pointer hover:text-blue-600" onclick="editProductDate(event, ${id})">${newValue || '—'}</span>`;
|
||||
td.innerHTML = `<span class="cursor-pointer hover:text-brand-600" onclick="editProductDate(event, ${id})">${newValue || '—'}</span>`;
|
||||
} catch (e) { toast("修改失败:" + e.message, "error"); }
|
||||
});
|
||||
input.addEventListener("blur", () => {
|
||||
td.innerHTML = `<span class="cursor-pointer hover:text-blue-600" onclick="editProductDate(event, ${id})">${currentValue || '—'}</span>`;
|
||||
td.innerHTML = `<span class="cursor-pointer hover:text-brand-600" onclick="editProductDate(event, ${id})">${currentValue || '—'}</span>`;
|
||||
});
|
||||
td.innerHTML = "";
|
||||
td.appendChild(input);
|
||||
@@ -181,12 +181,12 @@ function sortItems(items) {
|
||||
function renderProducts() {
|
||||
const rawItems = state.data.products || [];
|
||||
const items = sortItems(rawItems);
|
||||
const priorityColor = { P0: "bg-red-100 text-red-700", P1: "bg-orange-100 text-orange-700", P2: "bg-blue-100 text-blue-700", P3: "bg-slate-100 text-slate-600" };
|
||||
const priorityColor = { P0: "bg-red-100 text-red-700", P1: "bg-orange-100 text-orange-700", P2: "bg-brand-100 text-brand-700", P3: "bg-slate-100 text-slate-600" };
|
||||
const sortIcon = (f) => {
|
||||
if (productSort.field !== f) return '<i data-lucide="chevrons-up-down" style="width:12px;height:12px;opacity:0.3"></i>';
|
||||
return productSort.dir > 0 ? '<i data-lucide="chevron-up" style="width:12px;height:12px"></i>' : '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
|
||||
};
|
||||
const sortTh = (f, label, extra='') => `<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap cursor-pointer hover:text-blue-600 select-none" onclick="sortProducts('${f}')"><span class="inline-flex items-center gap-1">${label}${sortIcon(f)}</span>${extra}</th>`;
|
||||
const sortTh = (f, label, extra='') => `<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap cursor-pointer hover:text-brand-600 select-none" onclick="sortProducts('${f}')"><span class="inline-flex items-center gap-1">${label}${sortIcon(f)}</span>${extra}</th>`;
|
||||
const tab = state.productTab || 'version';
|
||||
const tabs = `<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0"><button onclick="switchProductTab('version')" class="finance-tab${tab==='version'?' active':''}" style="font-size:14px;font-weight:600">版本迭代</button><button onclick="switchProductTab('ops')" class="finance-tab${tab==='ops'?' active':''}" style="font-size:14px;font-weight:600">产品运营</button></div>`;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// projects.js — 重点工作与台账(项目管理 + 任务管理)
|
||||
|
||||
function applyUserTenants() {
|
||||
fetch("/api/auth/me").then(r => r.json()).then(data => {
|
||||
return fetch("/api/auth/me").then(r => r.json()).then(data => {
|
||||
if (!data.logged_in) { window.location.href = "/login"; return; }
|
||||
const user = data.user;
|
||||
const avatar = document.querySelector("#userAvatar");
|
||||
@@ -15,6 +15,13 @@ function applyUserTenants() {
|
||||
});
|
||||
// 缓存可用工作台列表,供下拉菜单使用
|
||||
state.allowedTenants = data.tenants || [];
|
||||
// 如果当前工作台不在权限范围内,选择第一个
|
||||
if (state.allowedTenants.length > 0 && state.allowedTenants.indexOf(state.tenant) < 0) {
|
||||
state.tenant = state.allowedTenants[0];
|
||||
localStorage.setItem("opc-active-tenant", state.tenant);
|
||||
var label0 = state.tenant.replace("·无界", "");
|
||||
document.querySelector("#workspaceTitle").textContent = label0 + " OPC 工作台";
|
||||
}
|
||||
updateTenantLabel();
|
||||
});
|
||||
}
|
||||
@@ -25,7 +32,7 @@ window.toggleTenantMenu = (event) => {
|
||||
if (menu) { menu.remove(); return; }
|
||||
const btn = event.currentTarget;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||
menu = document.createElement("div");
|
||||
menu.id = "tenantMenu";
|
||||
menu.className = "fixed bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-[9999]";
|
||||
@@ -36,7 +43,7 @@ window.toggleTenantMenu = (event) => {
|
||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">切换工作台</p>
|
||||
</div>
|
||||
${tenants.map(t => `
|
||||
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 transition-colors flex items-center justify-between gap-2 ${t === state.tenant ? 'text-blue-600 font-medium' : 'text-slate-700'}" onclick="switchTenantFromMenu('${t.replace(/'/g, "\\'")}')">
|
||||
<button class="w-full text-left px-4 py-2 text-sm hover:bg-slate-50 transition-colors flex items-center justify-between gap-2 ${t === state.tenant ? 'text-brand-600 font-medium' : 'text-slate-700'}" onclick="switchTenantFromMenu('${t.replace(/'/g, "\\'")}')">
|
||||
<span>${esc(t)}</span>
|
||||
${t === state.tenant ? '<i data-lucide="check" style="width:14px;height:14px"></i>' : ''}
|
||||
</button>
|
||||
@@ -56,13 +63,14 @@ window.switchTenantFromMenu = (tenant) => {
|
||||
};
|
||||
|
||||
function updateTenantLabel() {
|
||||
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||
var tenantIcons = {
|
||||
'学术·无界': 'graduation-cap',
|
||||
'MCN·无界': 'video',
|
||||
'科普·无界': 'flask-conical',
|
||||
'医患·无界': 'stethoscope',
|
||||
'科研·无界': 'microscope',
|
||||
'测试·无界': 'flask-round',
|
||||
'总览': 'layout-dashboard'
|
||||
};
|
||||
const container = document.querySelector("#tenantList");
|
||||
@@ -70,7 +78,7 @@ function updateTenantLabel() {
|
||||
var currentShort = (state.tenant || '').replace("·无界", "");
|
||||
var currentIcon = tenantIcons[state.tenant] || 'circle';
|
||||
container.innerHTML = '<div class="flex flex-col items-center cursor-pointer hover:bg-slate-800 rounded-lg py-2 px-1 w-14 transition-colors" onclick="toggleTenantMenu(event)" title="切换工作台">' +
|
||||
'<i data-lucide="' + currentIcon + '" style="width:20px;height:20px;color:#60a5fa"></i>' +
|
||||
'<i data-lucide="' + currentIcon + '" style="width:20px;height:20px;color:var(--brand-400)"></i>' +
|
||||
'<span class="text-[10px] mt-1 max-w-[56px] truncate text-center text-slate-300">' + esc(currentShort) + '</span>' +
|
||||
'<i data-lucide="chevron-down" style="width:12px;height:12px;color:#64748b;margin-top:2px"></i>' +
|
||||
'</div>';
|
||||
@@ -84,13 +92,14 @@ window.toggleTenantMenu = (event) => {
|
||||
if (menu) { menu.remove(); return; }
|
||||
const btn = event.currentTarget;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||
const tenants = (state.allowedTenants || []).slice().sort(function(a, b) { var order = ['总览','学术·无界','MCN·无界','科普·无界','医患·无界','科研·无界','测试·无界']; var ai = order.indexOf(a); var bi = order.indexOf(b); if (ai < 0) ai = 99; if (bi < 0) bi = 99; return ai - bi; });
|
||||
var tenantIcons = {
|
||||
'学术·无界': 'graduation-cap',
|
||||
'MCN·无界': 'video',
|
||||
'科普·无界': 'flask-conical',
|
||||
'医患·无界': 'stethoscope',
|
||||
'科研·无界': 'microscope',
|
||||
'测试·无界': 'flask-round',
|
||||
'总览': 'layout-dashboard'
|
||||
};
|
||||
menu = document.createElement("div");
|
||||
@@ -103,10 +112,10 @@ window.toggleTenantMenu = (event) => {
|
||||
const shortName = t.replace("·无界", "");
|
||||
const isActive = t === state.tenant;
|
||||
var icon = tenantIcons[t] || 'circle';
|
||||
return '<button class="w-full text-left px-4 py-2.5 text-sm hover:bg-slate-50 transition-colors flex items-center gap-3 ' + (isActive ? 'text-blue-600 font-medium bg-blue-50' : 'text-slate-700') + '" onclick="switchTenantFromMenu(\'' + t.replace(/'/g, "\\'") + '\')">' +
|
||||
'<i data-lucide="' + icon + '" style="width:16px;height:16px;color:' + (isActive ? '#2563eb' : '#94a3b8') + '"></i>' +
|
||||
return '<button class="w-full text-left px-4 py-2.5 text-sm hover:bg-slate-50 transition-colors flex items-center gap-3 ' + (isActive ? 'text-brand-600 font-medium bg-brand-50' : 'text-slate-700') + '" onclick="switchTenantFromMenu(\'' + t.replace(/'/g, "\\'") + '\')">' +
|
||||
'<i data-lucide="' + icon + '" style="width:16px;height:16px;color:' + (isActive ? 'var(--brand-600)' : '#94a3b8') + '"></i>' +
|
||||
'<span class="flex-1">' + esc(shortName) + '</span>' +
|
||||
(isActive ? '<i data-lucide="check" style="width:14px;height:14px;color:#2563eb"></i>' : '') +
|
||||
(isActive ? '<i data-lucide="check" style="width:14px;height:14px;color:var(--brand-600)"></i>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
document.body.appendChild(menu);
|
||||
|
||||
@@ -53,7 +53,7 @@ function renderProposals() {
|
||||
<button class="finance-tab${!isStandard ? ' active' : ''}" style="font-size:14px;font-weight:600" onclick="switchProposalTab('other')">其他资料</button>
|
||||
</div>
|
||||
<div class="flex justify-end">${!isStandard ? `<button class="btn btn-primary btn-sm" onclick="openProposalModal()"><i data-lucide="plus"></i>新增方案</button>` : ''}</div>
|
||||
${isStandard ? `<div class="flex items-start gap-2 rounded-lg bg-blue-50 border border-blue-100 px-4 py-3 text-sm text-blue-700"><i data-lucide="info" style="width:16px;height:16px;flex-shrink:0;margin-top:1px"></i><span>这是每一条 OPC 线,必须要梳理清楚的 7 份资料,项目不可以删除,只可以更新附件,请大家将最新的材料上传</span></div>` : `<div class="flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-100 px-4 py-3 text-sm text-emerald-700"><i data-lucide="lightbulb" style="width:16px;height:16px;flex-shrink:0;margin-top:1px"></i><span>在这里新建,并且上传您希望与团队其他成员共享的资料</span></div>`}
|
||||
${isStandard ? `<div class="flex items-start gap-2 rounded-lg bg-brand-50 border border-brand-100 px-4 py-3 text-sm text-brand-700"><i data-lucide="info" style="width:16px;height:16px;flex-shrink:0;margin-top:1px"></i><span>这是每一条 OPC 线,必须要梳理清楚的 7 份资料,项目不可以删除,只可以更新附件,请大家将最新的材料上传</span></div>` : `<div class="flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-100 px-4 py-3 text-sm text-emerald-700"><i data-lucide="lightbulb" style="width:16px;height:16px;flex-shrink:0;margin-top:1px"></i><span>在这里新建,并且上传您希望与团队其他成员共享的资料</span></div>`}
|
||||
${isStandard ? renderStandardTable(standardItems) : renderOtherTable(otherItems)}
|
||||
</div>
|
||||
<div id="proposalModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeProposalModal()">
|
||||
@@ -126,7 +126,7 @@ window.openStandardProposalDrawer = (id) => {
|
||||
</section>
|
||||
${followupTarget ? `<section>
|
||||
<h3 class="drawer-section-title">活动 / 跟进</h3>
|
||||
<div class="grid gap-2">${(item.followups || []).map((f) => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" onclick="deleteFollowup(event, ${f.id}, 'proposals', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
|
||||
<div class="grid gap-2">${(item.followups || []).map((f) => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-brand-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" onclick="deleteFollowup(event, ${f.id}, 'proposals', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
|
||||
<form class="comment-box mt-3" onsubmit="submitStandardComment(event, ${item.id})">
|
||||
<div class="squire-toolbar">
|
||||
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
|
||||
|
||||
@@ -3,19 +3,22 @@
|
||||
const state = {
|
||||
active: "home",
|
||||
data: null,
|
||||
tenant: "科普·无界",
|
||||
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
|
||||
opFilter: "all",
|
||||
finFilter: "projects",
|
||||
finFilter: "overview",
|
||||
selectedProject: null,
|
||||
taskQuery: "",
|
||||
taskView: localStorage.getItem("opc-task-view") || "detail",
|
||||
finView: "overview",
|
||||
finSort: null, // { key: 'col', asc: true }
|
||||
planFilter: "projects",
|
||||
planFilter: "overview",
|
||||
planYear: new Date().getFullYear(),
|
||||
finYear: new Date().getFullYear(),
|
||||
planStatusFilter: "all",
|
||||
planView: "overview",
|
||||
planSort: null,
|
||||
overviewView: "monthly", // 总览视图: monthly | quarterly
|
||||
overviewFilter: "plan", // 总览筛选: plan | finance
|
||||
proposalTab: "standard",
|
||||
chart: null,
|
||||
chart2: null,
|
||||
@@ -29,7 +32,7 @@ const state = {
|
||||
expenseQuarter: "",
|
||||
};
|
||||
|
||||
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")} 元`;
|
||||
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
|
||||
const text = (value) => value === undefined || value === null || value === "" ? "—" : esc(value);
|
||||
|
||||
function escapeHtml(str) { return String(str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
||||
@@ -58,9 +61,8 @@ function toast(message, type = "info", duration = 3000) {
|
||||
window.toast = toast;
|
||||
|
||||
function monthOptions(selected = '') {
|
||||
const now = new Date();
|
||||
const startYear = now.getFullYear() - 1;
|
||||
const endYear = now.getFullYear() + 1;
|
||||
const startYear = 2020;
|
||||
const endYear = 2027;
|
||||
let options = selected ? '' : '<option value="">选择月份</option>';
|
||||
for (let y = startYear; y <= endYear; y++) {
|
||||
for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) {
|
||||
@@ -118,12 +120,14 @@ function renderTable(headers, rows, rowClicks) {
|
||||
}
|
||||
|
||||
var _loadingSteps = [
|
||||
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return state.tenant !== '总览'; } },
|
||||
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return true; } },
|
||||
{ fn: 'renderOverviewPlan', label: '正在加载预算总览', skip: function() { return state.tenant !== '总览'; } },
|
||||
{ fn: 'renderOverviewFinance', label: '正在加载发生总览', skip: function() { return state.tenant !== '总览'; } },
|
||||
{ fn: 'renderProjects', label: '正在加载业务机会列表' },
|
||||
{ fn: 'renderProposals', label: '正在加载业务方案列表' },
|
||||
{ fn: 'renderProducts', label: '正在加载产品迭代列表' },
|
||||
{ fn: 'renderPerformance', label: '正在加载绩效管理' },
|
||||
{ fn: 'renderFinance', label: '正在加载执行模块(表格+总览+图表)' },
|
||||
{ fn: 'renderFinance', label: '正在加载已发生模块(表格+总览+图表)' },
|
||||
{ fn: 'renderPlan', label: '正在加载预算模块' },
|
||||
];
|
||||
|
||||
@@ -131,7 +135,7 @@ function showLoadingOverlay(steps) {
|
||||
var existing = document.getElementById('_loadingOverlay');
|
||||
if (existing) existing.remove();
|
||||
var stepsHtml = steps.map(function(s, i) {
|
||||
return '<div id="_loadingStep_' + i + '" class="flex items-center gap-3 py-1.5 text-sm ' + (i === 0 ? 'text-blue-600 font-medium' : 'text-slate-400') + '">' +
|
||||
return '<div id="_loadingStep_' + i + '" class="flex items-center gap-3 py-1.5 text-sm ' + (i === 0 ? 'text-brand-600 font-medium' : 'text-slate-400') + '">' +
|
||||
'<span class="w-4 h-4 flex items-center justify-center">' + (i === 0 ? '<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>' : '<span class="w-2 h-2 rounded-full bg-slate-300"></span>') + '</span>' +
|
||||
'<span>' + s.label + '</span>' +
|
||||
'</div>';
|
||||
@@ -143,7 +147,7 @@ function showLoadingOverlay(steps) {
|
||||
'<h3 class="text-base font-bold text-slate-800 mb-1">正在加载工作台</h3>' +
|
||||
'<p class="text-xs text-slate-400 mb-4">共 ' + steps.length + ' 个模块,请稍候…</p>' +
|
||||
'<div id="_loadingSteps">' + stepsHtml + '</div>' +
|
||||
'<div class="mt-4 h-1 bg-slate-100 rounded-full overflow-hidden"><div id="_loadingProgress" class="h-full bg-blue-600 transition-all duration-300" style="width:0%"></div></div>' +
|
||||
'<div class="mt-4 h-1 bg-slate-100 rounded-full overflow-hidden"><div id="_loadingProgress" class="h-full bg-brand-600 transition-all duration-300" style="width:0%"></div></div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
@@ -156,7 +160,7 @@ function updateLoadingStep(idx, total) {
|
||||
}
|
||||
var nextEl = document.getElementById('_loadingStep_' + (idx + 1));
|
||||
if (nextEl) {
|
||||
nextEl.className = 'flex items-center gap-3 py-1.5 text-sm text-blue-600 font-medium';
|
||||
nextEl.className = 'flex items-center gap-3 py-1.5 text-sm text-brand-600 font-medium';
|
||||
nextEl.querySelector('span:first-child').innerHTML = '<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
var progress = document.getElementById('_loadingProgress');
|
||||
@@ -188,7 +192,7 @@ async function load() {
|
||||
// 按需加载当前 tab 的数据(必须在 ensureStandardProposals 之前,否则会被 return 跳过)
|
||||
await ensureTabData(state.active);
|
||||
// 首次加载时确保标准资料库 7 项已初始化(仅首次,避免每次切换工作台都触发)
|
||||
if (!_proposalsInitialized && typeof ensureStandardProposals === "function") {
|
||||
if (!_proposalsInitialized && typeof ensureStandardProposals === "function" && state.tenant !== '总览') {
|
||||
_proposalsInitialized = true;
|
||||
try {
|
||||
const proposalsData = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant));
|
||||
@@ -231,7 +235,7 @@ var _tabLoaded = {};
|
||||
var _proposalsInitialized = false;
|
||||
|
||||
async function ensureTabData(tab) {
|
||||
if (tab === 'home' || tab === 'performance') return;
|
||||
if (tab === 'home' || tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return;
|
||||
var resource = _tabResourceMap[tab];
|
||||
if (!resource) return;
|
||||
var dataKey = resource;
|
||||
@@ -261,7 +265,7 @@ function switchTab(tab) {
|
||||
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
|
||||
|
||||
// 更新顶部标题
|
||||
const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核' };
|
||||
const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' };
|
||||
const titleEl = document.querySelector('#workspaceTitle');
|
||||
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
|
||||
|
||||
@@ -277,9 +281,9 @@ function updateSidebarTabs() {
|
||||
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
|
||||
const tab = btn.dataset.tab;
|
||||
if (isOverview) {
|
||||
btn.style.display = (tab === "home" || tab === "plan" || tab === "finance") ? "" : "none";
|
||||
btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "" : "none";
|
||||
} else {
|
||||
btn.style.display = tab === "home" ? "none" : "";
|
||||
btn.style.display = (tab === "overview_plan" || tab === "overview_finance" || tab === "home") ? "none" : "";
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -300,6 +304,8 @@ function renderActive() {
|
||||
if (!state.data) return;
|
||||
const tab = state.active;
|
||||
if (tab === "home") renderHome();
|
||||
else if (tab === "overview_plan") renderOverviewPlan();
|
||||
else if (tab === "overview_finance") renderOverviewFinance();
|
||||
else if (tab === "projects") renderProjects();
|
||||
else if (tab === "proposals") renderProposals();
|
||||
else if (tab === "products") renderProducts();
|
||||
@@ -333,8 +339,16 @@ window.setFinView = (view) => {
|
||||
state.finView = view;
|
||||
renderFinance();
|
||||
};
|
||||
window.setFinQuarter = (q) => { state.finQuarter = q; state.finView = 'quarterly'; renderFinance(); };
|
||||
window.setFinMonth = (month) => { state.finMonth = month; state.finView = 'monthly'; renderFinance(); };
|
||||
window.setFinQuarter = (q) => {
|
||||
if (state.finView === 'quarterly' && state.finQuarter === q) { state.finView = 'overview'; }
|
||||
else { state.finQuarter = q; state.finView = 'quarterly'; }
|
||||
renderFinance();
|
||||
};
|
||||
window.setFinMonth = (month) => {
|
||||
if (state.finView === 'monthly' && state.finMonth === month) { state.finView = 'overview'; }
|
||||
else { state.finMonth = month; state.finView = 'monthly'; }
|
||||
renderFinance();
|
||||
};
|
||||
window.setFinYear = (year) => { state.finYear = parseInt(year); renderFinance(); };
|
||||
window.switchFinFilter = (filter) => {
|
||||
state.finFilter = filter;
|
||||
@@ -352,9 +366,18 @@ window.setPlanView = (view) => {
|
||||
state.planView = view;
|
||||
renderPlan();
|
||||
};
|
||||
window.setPlanQuarter = (q) => { state.planQuarter = q; state.planView = 'quarterly'; renderPlan(); };
|
||||
window.setPlanMonth = (month) => { state.planMonth = month; state.planView = 'monthly'; renderPlan(); };
|
||||
window.setPlanQuarter = (q) => {
|
||||
if (state.planView === 'quarterly' && state.planQuarter === q) { state.planView = 'overview'; }
|
||||
else { state.planQuarter = q; state.planView = 'quarterly'; }
|
||||
renderPlan();
|
||||
};
|
||||
window.setPlanMonth = (month) => {
|
||||
if (state.planView === 'monthly' && state.planMonth === month) { state.planView = 'overview'; }
|
||||
else { state.planMonth = month; state.planView = 'monthly'; }
|
||||
renderPlan();
|
||||
};
|
||||
window.setPlanYear = (year) => { state.planYear = parseInt(year); renderPlan(); };
|
||||
window.setPlanStatusFilter = (filter) => { state.planStatusFilter = filter; renderPlan(); };
|
||||
window.switchPlanFilter = (filter) => {
|
||||
state.planFilter = filter;
|
||||
state.planSort = null;
|
||||
@@ -370,11 +393,22 @@ window.switchTenant = async (tenant) => {
|
||||
state.tenant = tenant;
|
||||
state.selectedProject = null;
|
||||
localStorage.setItem("opc-active-tenant", tenant);
|
||||
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
|
||||
document.querySelector("#workspaceTitle").textContent = tenant === "总览" ? "总览 OPC 工作台" : tenant.replace("·无界", "") + " OPC 工作台";
|
||||
if (typeof updateTenantLabel === "function") updateTenantLabel();
|
||||
state.planFilter = 'overview';
|
||||
state.finFilter = 'overview';
|
||||
updateSidebarTabs();
|
||||
if (tenant === "总览") { state.planFilter = 'overview'; state.finFilter = 'overview'; switchTab("home"); }
|
||||
else if (state.active === 'home') { state.active = 'plan'; localStorage.setItem("opc-active-tab", "plan"); document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan")); document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan")); }
|
||||
if (tenant === "总览") {
|
||||
state.active = "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(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "overview_plan"));
|
||||
} else if (state.active === 'home' || state.active === 'overview_plan' || state.active === 'overview_finance') {
|
||||
state.active = 'plan';
|
||||
localStorage.setItem("opc-active-tab", "plan");
|
||||
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan"));
|
||||
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan"));
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,44 @@
|
||||
:root {
|
||||
/* ── 品牌色(天机阁标准 #4A6CF7)── */
|
||||
--brand-50: #f0f3ff;
|
||||
--brand-100: #dbe3fe;
|
||||
--brand-200: #b9c7fd;
|
||||
--brand-300: #93a9fc;
|
||||
--brand-400: #748bf9;
|
||||
--brand-500: #5670f6;
|
||||
--brand-600: #4A6CF7;
|
||||
--brand-700: #3b53c8;
|
||||
--brand-800: #2d3d9d;
|
||||
--brand-900: #1f2a72;
|
||||
--brand-950: #141b4d;
|
||||
|
||||
/* ── 语义色 ── */
|
||||
--success: #059669;
|
||||
--success-bg: #ecfdf5;
|
||||
--warning: #b45309;
|
||||
--warning-bg: #fffbeb;
|
||||
--danger: #dc2626;
|
||||
--danger-bg: #fef2f2;
|
||||
--info: var(--brand-800);
|
||||
--info-bg: var(--brand-50);
|
||||
|
||||
/* ── 圆角 ── */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
|
||||
/* ── 阴影 ── */
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-drawer: -18px 0 45px rgba(15, 23, 42, 0.14);
|
||||
--shadow-overlay: 0 20px 60px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
/* 预留暗色主题(本次不实现,单独排期) */
|
||||
:root[data-theme="dark"] {
|
||||
/* TODO: 暗色品牌色变体,后续排期 */
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 1400px;
|
||||
overflow-x: auto;
|
||||
@@ -29,7 +70,7 @@ body {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.workspace-nav-item.active {
|
||||
color: #60a5fa;
|
||||
color: var(--brand-400);
|
||||
background: rgba(96,165,250,0.15);
|
||||
}
|
||||
|
||||
@@ -38,7 +79,7 @@ body {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 4px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
transition: all 0.15s ease;
|
||||
@@ -52,11 +93,11 @@ body {
|
||||
|
||||
.sidebar-tab.active {
|
||||
background: #1e293b;
|
||||
color: #60a5fa;
|
||||
color: var(--brand-400);
|
||||
}
|
||||
|
||||
.sidebar-tab.active i {
|
||||
color: #60a5fa;
|
||||
color: var(--brand-400);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -91,8 +132,8 @@ body {
|
||||
.finance-tab:hover { color: #1e293b; }
|
||||
|
||||
.finance-tab.active {
|
||||
color: #1d4ed8;
|
||||
border-bottom-color: #1d4ed8;
|
||||
color: var(--brand-700);
|
||||
border-bottom-color: var(--brand-700);
|
||||
}
|
||||
|
||||
/* 详情弹窗固定高度,tab 切换不抖动 */
|
||||
@@ -126,7 +167,7 @@ body {
|
||||
.project-board {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -151,7 +192,7 @@ body {
|
||||
gap: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -209,9 +250,9 @@ body {
|
||||
}
|
||||
|
||||
.project-tree-node.active {
|
||||
background: #eff6ff;
|
||||
border-left-color: #3b82f6;
|
||||
color: #1d4ed8;
|
||||
background: var(--brand-50);
|
||||
border-left-color: var(--brand-500);
|
||||
color: var(--brand-700);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -243,7 +284,7 @@ body {
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
padding: 4px 0;
|
||||
min-width: 140px;
|
||||
@@ -383,12 +424,12 @@ body {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.status-未开始 { background: #f1f5f9; color: #64748b; }
|
||||
.status-进行中 { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-进行中 { background: var(--brand-100); color: var(--brand-700); }
|
||||
.status-验收中 { background: #fef3c7; color: #92400e; }
|
||||
.status-已结束 { background: #dcfce7; color: #166534; }
|
||||
/* 产品版本状态 */
|
||||
.status-规划中 { background: #f1f5f9; color: #64748b; }
|
||||
.status-开发中 { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-开发中 { background: var(--brand-100); color: var(--brand-700); }
|
||||
.status-测试中 { background: #fef3c7; color: #92400e; }
|
||||
.status-已上线 { background: #dcfce7; color: #166534; }
|
||||
.status-已取消 { background: #f1f5f9; color: #94a3b8; }
|
||||
@@ -425,7 +466,7 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-check:hover {
|
||||
color: #3b82f6;
|
||||
color: var(--brand-500);
|
||||
}
|
||||
|
||||
.task-grip {
|
||||
@@ -539,7 +580,7 @@ body {
|
||||
}
|
||||
.upload-task-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
background: var(--brand-500);
|
||||
border-radius: 2px;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
@@ -591,20 +632,20 @@ body {
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
border-color: #93c5fd;
|
||||
border-color: var(--brand-300);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -616,15 +657,15 @@ body {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.badge-red { background: #fef2f2; color: #b91c1c; }
|
||||
.badge-amber { background: #fffbeb; color: #b45309; }
|
||||
.badge-green { background: #ecfdf5; color: #047857; }
|
||||
.badge-blue { background: #eff6ff; color: #1d4ed8; }
|
||||
.badge-red { background: var(--danger-bg); color: #b91c1c; }
|
||||
.badge-amber { background: var(--warning-bg); color: #b45309; }
|
||||
.badge-green { background: var(--success-bg); color: #047857; }
|
||||
.badge-blue { background: var(--brand-50); color: var(--brand-700); }
|
||||
.badge-slate { background: #f1f5f9; color: #475569; }
|
||||
|
||||
.btn {
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -640,8 +681,8 @@ body {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.btn-primary { background: #1d4ed8; color: white; }
|
||||
.btn-primary:hover { background: #1e40af; }
|
||||
.btn-primary { background: var(--brand-700); color: white; }
|
||||
.btn-primary:hover { background: var(--brand-800); }
|
||||
.btn-ghost { background: white; border: 1px solid #e2e8f0; color: #334155; }
|
||||
.btn-ghost:hover { background: #f8fafc; }
|
||||
|
||||
@@ -650,7 +691,7 @@ input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
@@ -671,8 +712,8 @@ select {
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
|
||||
border-color: var(--brand-600);
|
||||
box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.08);
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder { color: #cbd5e1; }
|
||||
@@ -688,7 +729,7 @@ textarea { min-height: 80px; height: auto; resize: vertical; }
|
||||
height: 38px;
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
@@ -697,8 +738,8 @@ textarea { min-height: 80px; height: auto; resize: vertical; }
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.form-ctrl:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
|
||||
border-color: var(--brand-600);
|
||||
box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.08);
|
||||
}
|
||||
.form-ctrl::placeholder { color: #cbd5e1; }
|
||||
.form-ctrl:disabled { background: #f8fafc; cursor: not-allowed; }
|
||||
@@ -726,7 +767,7 @@ textarea { min-height: 80px; height: auto; resize: vertical; }
|
||||
background: #f8fafc;
|
||||
}
|
||||
.prod-date-input:focus {
|
||||
border-color: #3b82f6;
|
||||
border-color: var(--brand-500);
|
||||
background: white;
|
||||
outline: none;
|
||||
}
|
||||
@@ -777,14 +818,14 @@ td {
|
||||
|
||||
.drawer-panel {
|
||||
background: white;
|
||||
box-shadow: -18px 0 45px rgba(15, 23, 42, 0.14);
|
||||
box-shadow: var(--shadow-drawer);
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
width: 720px;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
color: #1d4ed8;
|
||||
color: var(--brand-700);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
@@ -842,9 +883,9 @@ td {
|
||||
|
||||
.activity-icon {
|
||||
align-items: center;
|
||||
background: #dbeafe;
|
||||
background: var(--brand-100);
|
||||
border-radius: 999px;
|
||||
color: #1d4ed8;
|
||||
color: var(--brand-700);
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
height: 24px;
|
||||
@@ -881,14 +922,14 @@ td {
|
||||
|
||||
.comment-box {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.comment-box:focus-within {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
|
||||
border-color: var(--brand-300);
|
||||
box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.08);
|
||||
}
|
||||
|
||||
/* Squire editor */
|
||||
@@ -1041,7 +1082,7 @@ td {
|
||||
font-weight: 600;
|
||||
}
|
||||
.activity-item .rich-content a {
|
||||
color: #1d4ed8;
|
||||
color: var(--brand-700);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.activity-item .rich-content ul,
|
||||
@@ -1076,8 +1117,8 @@ td {
|
||||
padding-top: 48px; overflow-y: auto;
|
||||
}
|
||||
.task-panel {
|
||||
background: #fff; border-radius: 12px; width: 1000px; max-width: 96vw;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.12); margin-bottom: 48px;
|
||||
background: #fff; border-radius: var(--radius-lg); width: 1000px; max-width: 96vw;
|
||||
box-shadow: var(--shadow-overlay); margin-bottom: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.task-header {
|
||||
@@ -1087,7 +1128,7 @@ td {
|
||||
.task-drawer .task-title { color: #1e293b; font-size: 15px; font-weight: 600; }
|
||||
.task-close {
|
||||
color: #94a3b8; background: none; border: none; cursor: pointer;
|
||||
padding: 4px; border-radius: 6px; display: flex;
|
||||
padding: 4px; border-radius: var(--radius-sm); display: flex;
|
||||
}
|
||||
.task-close:hover { color: #475569; background: #f1f5f9; }
|
||||
.task-body-wrap, .task-page-wrap { position: relative; overflow: hidden; }
|
||||
@@ -1106,7 +1147,7 @@ td {
|
||||
.task-drawer-title { font-size: 15px; font-weight: 600; color: #1e293b; }
|
||||
.task-drawer-form { padding: 16px 20px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
|
||||
.task-group {
|
||||
background: #fff; border-radius: 8px;
|
||||
background: #fff; border-radius: var(--radius-md);
|
||||
border: 1px solid #e2e8f0; overflow: hidden;
|
||||
}
|
||||
.task-group-hd {
|
||||
@@ -1142,7 +1183,7 @@ td {
|
||||
.task-col-badge { color: #64748b; font-size: 12px; white-space: nowrap; width: 90px; text-align: right; }
|
||||
.task-none { color: #94a3b8; font-size: 13px; padding: 12px 14px; text-align: center; border-top: 1px solid #f1f5f9; }
|
||||
.task-form {
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: var(--radius-md);
|
||||
padding: 14px; margin-bottom: 16px;
|
||||
}
|
||||
.task-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 4px; }
|
||||
@@ -1179,14 +1220,14 @@ td {
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px; border-radius: 8px;
|
||||
padding: 10px 16px; border-radius: var(--radius-md);
|
||||
font-size: 13px; font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||
animation: toastIn 0.2s ease-out; max-width: 380px;
|
||||
}
|
||||
.toast.toast-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.toast.toast-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.toast.toast-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
|
||||
.toast.toast-success { background: var(--success-bg); color: #166534; border: 1px solid #bbf7d0; }
|
||||
.toast.toast-error { background: var(--danger-bg); color: #991b1b; border: 1px solid #fecaca; }
|
||||
.toast.toast-info { background: var(--brand-50); color: var(--brand-800); border: 1px solid var(--brand-200); }
|
||||
.toast.fade-out { animation: toastOut 0.25s ease-in forwards; }
|
||||
@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes toastOut { to { opacity: 0; transform: translateX(20px); } }
|
||||
@@ -1216,4 +1257,4 @@ td {
|
||||
|
||||
/* 项目树拖拽 */
|
||||
.project-tree-node.dragging { opacity: 0.4; }
|
||||
.project-tree-node.drag-over { border-top: 2px solid #2563eb; }
|
||||
.project-tree-node.drag-over { border-top: 2px solid var(--brand-600); }
|
||||
|
||||
@@ -11,10 +11,32 @@
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8'
|
||||
50: '#f0f3ff',
|
||||
100: '#dbe3fe',
|
||||
200: '#b9c7fd',
|
||||
300: '#93a9fc',
|
||||
400: '#748bf9',
|
||||
500: '#5670f6',
|
||||
600: '#4A6CF7',
|
||||
700: '#3b53c8',
|
||||
800: '#2d3d9d',
|
||||
900: '#1f2a72',
|
||||
950: '#141b4d'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"PingFang SC"', '"Microsoft YaHei"', 'sans-serif']
|
||||
},
|
||||
borderRadius: {
|
||||
sm: '6px',
|
||||
md: '8px',
|
||||
lg: '12px',
|
||||
xl: '16px'
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
drawer: '-18px 0 45px rgba(15, 23, 42, 0.14)',
|
||||
overlay: '0 20px 60px rgba(0,0,0,0.12)'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +52,7 @@
|
||||
<!-- 左侧工作台切换栏 -->
|
||||
<aside class="w-[72px] bg-slate-900 flex flex-col items-center py-6 gap-1 shrink-0 sticky top-0 h-screen overflow-y-auto" id="workspaceSidebar">
|
||||
<div class="flex flex-col items-center mb-2 cursor-pointer" onclick="document.getElementById('userAvatar').click()">
|
||||
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-white font-bold text-sm hover:ring-2 hover:ring-blue-400 transition-all" id="userAvatar" title=""></div>
|
||||
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-white font-bold text-sm hover:ring-2 hover:ring-brand-400 transition-all" id="userAvatar" title=""></div>
|
||||
<span class="text-[10px] text-slate-400 mt-1.5 max-w-[64px] truncate text-center" id="userDisplayName" title=""></span>
|
||||
</div>
|
||||
<!-- 分隔线 -->
|
||||
@@ -49,9 +71,17 @@
|
||||
<i data-lucide="calendar-range" style="width:20px;height:20px"></i>
|
||||
<span class="text-[10px] mt-1">预算</span>
|
||||
</div>
|
||||
<div class="sidebar-tab" data-tab="finance" onclick="switchTab('finance')" title="执行">
|
||||
<div class="sidebar-tab" data-tab="finance" onclick="switchTab('finance')" title="已发生">
|
||||
<i data-lucide="folder-open-dot" style="width:20px;height:20px"></i>
|
||||
<span class="text-[10px] mt-1">执行</span>
|
||||
<span class="text-[10px] mt-1">已发生</span>
|
||||
</div>
|
||||
<div class="sidebar-tab" data-tab="overview_plan" onclick="switchTab('overview_plan')" title="预算总览">
|
||||
<i data-lucide="bar-chart-horizontal" style="width:20px;height:20px"></i>
|
||||
<span class="text-[10px] mt-1">预算总览</span>
|
||||
</div>
|
||||
<div class="sidebar-tab" data-tab="overview_finance" onclick="switchTab('overview_finance')" title="发生总览">
|
||||
<i data-lucide="pie-chart" style="width:20px;height:20px"></i>
|
||||
<span class="text-[10px] mt-1">发生总览</span>
|
||||
</div>
|
||||
<div class="sidebar-tab" data-tab="projects" onclick="switchTab('projects')" title="Focus">
|
||||
<i data-lucide="file-text" style="width:20px;height:20px"></i>
|
||||
@@ -76,23 +106,25 @@
|
||||
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
<div class="flex-1">
|
||||
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.6</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">v1.2.0.41</span></p>
|
||||
<div class="flex items-center gap-4 mt-1">
|
||||
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
|
||||
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100 rounded-full px-4 py-1.5">
|
||||
<i data-lucide="sparkles" class="text-blue-500" style="width:14px;height:14px"></i>
|
||||
<span class="text-xs text-blue-700 font-medium tracking-wide">真看病,看真病,用学术会议,科普,科研,患者管理,帮助医生与患者提升临床治疗</span>
|
||||
<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">
|
||||
<i data-lucide="sparkles" class="text-brand-500" style="width:14px;height:14px"></i>
|
||||
<span class="text-xs text-brand-700 font-medium tracking-wide">真看病,看真病,用学术会议,科普,科研,患者管理,帮助医生与患者提升临床治疗</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="document.getElementById('opcLetterModal').classList.remove('hidden')" class="ml-auto flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full bg-blue-50 border border-blue-200 hover:bg-blue-100 transition-colors" title="OPC 来信">
|
||||
<i data-lucide="mail" class="text-blue-500" style="width:18px;height:18px"></i>
|
||||
<button onclick="document.getElementById('opcLetterModal').classList.remove('hidden')" class="ml-auto flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full bg-brand-50 border border-brand-200 hover:bg-brand-100 transition-colors" title="OPC 来信">
|
||||
<i data-lucide="mail" class="text-brand-500" style="width:18px;height:18px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-8 py-6">
|
||||
<section id="home" class="panel active"></section>
|
||||
<section id="overview_plan" class="panel"></section>
|
||||
<section id="overview_finance" class="panel"></section>
|
||||
<section id="projects" class="panel"></section>
|
||||
<section id="proposals" class="panel"></section>
|
||||
<section id="products" class="panel"></section>
|
||||
@@ -132,6 +164,8 @@
|
||||
<script src="{{ url_for('static', filename='modules/expense.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/plan.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/plan_expense.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/overview_plan.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/overview_finance.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/drawer.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='modules/admin.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
|
||||
Reference in New Issue
Block a user