Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b5c40626 | ||
|
|
ec457e9d40 | ||
|
|
3930451ce8 | ||
|
|
39513c0a7b | ||
|
|
e2a421cde7 | ||
|
|
d2d1b0f99e | ||
|
|
aa3ec75179 | ||
|
|
f619ed34a7 | ||
|
|
4ad19d438f | ||
|
|
89810942cd | ||
|
|
30b1a50490 | ||
|
|
6b3198d36f | ||
|
|
01718e9acc | ||
|
|
8e688efb62 | ||
|
|
87f0abbbd8 | ||
|
|
7c9ee77992 | ||
|
|
c2ed20caeb | ||
|
|
2340c69e26 | ||
|
|
a3f269046d | ||
|
|
ec062fb2ec | ||
|
|
c4af452115 | ||
|
|
8c318fb3ad | ||
|
|
9c2beee51e | ||
|
|
6a582c65b8 | ||
|
|
3fff80425b | ||
|
|
02abe41793 | ||
|
|
f30d40fe70 | ||
|
|
eb300731a3 | ||
|
|
67bc00314c | ||
|
|
d34e52eff4 | ||
|
|
8e38f50c99 | ||
|
|
3a40e78c3e | ||
|
|
a1a4f41d79 | ||
|
|
d4889d56ec | ||
|
|
35706f123d | ||
|
|
e4d1cd59f3 | ||
|
|
1673603b02 | ||
|
|
db93eb8d31 | ||
|
|
94ad7aed55 | ||
|
|
043938670a | ||
|
|
9862d9fe56 | ||
|
|
150e627811 | ||
|
|
295bf5a81c | ||
|
|
e8a26059d5 | ||
|
|
543d620384 | ||
|
|
975034c589 | ||
|
|
71e90bd55f | ||
|
|
3042791a90 | ||
|
|
a8243acc5a | ||
|
|
cb0e3fdac2 | ||
|
|
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' \
|
--exclude='.gitea' \
|
||||||
"${CLONE_DIR}/" "${RELEASE_DIR}/"
|
"${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 ==="
|
echo "=== 3. Link shared resources ==="
|
||||||
mkdir -p "${RELEASE_DIR}/data"
|
mkdir -p "${RELEASE_DIR}/data"
|
||||||
# .env from shared dir (not in git)
|
# .env from shared dir (not in git)
|
||||||
|
|||||||
@@ -53,3 +53,4 @@ curl http://127.0.0.1:5177/api/health
|
|||||||
- 产品:慰心斋产品路线图中的 5 个产品版本
|
- 产品:慰心斋产品路线图中的 5 个产品版本
|
||||||
- 财务:首版财务样例和原财务 manager 合并方向
|
- 财务:首版财务样例和原财务 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():
|
def db():
|
||||||
return mysql.connector.connect(
|
return mysql.connector.connect(
|
||||||
host=os.environ.get("DB_HOST", "127.0.0.1"),
|
host=os.environ.get("DB_HOST", "127.0.0.1"),
|
||||||
port=int(os.environ.get("DB_PORT", "3306")),
|
port=int(os.environ.get("DB_PORT", "3306")),
|
||||||
user=os.environ.get("DB_USER", "opc"),
|
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"),
|
database=os.environ.get("DB_NAME", "opc"),
|
||||||
charset="utf8mb4",
|
charset="utf8mb4",
|
||||||
collation="utf8mb4_unicode_ci",
|
collation="utf8mb4_unicode_ci",
|
||||||
@@ -55,10 +65,44 @@ def now():
|
|||||||
return datetime.utcnow().isoformat()
|
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=()):
|
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 = conn.cursor(dictionary=True)
|
||||||
cur.execute(sql.replace("?", "%s"), args)
|
cur.execute(converted, args)
|
||||||
return cur
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,28 @@ app = Flask(
|
|||||||
template_folder=str(ROOT / "templates"),
|
template_folder=str(ROOT / "templates"),
|
||||||
static_folder=str(ROOT / "static"),
|
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.config["TEMPLATES_AUTO_RELOAD"] = True
|
||||||
|
|
||||||
|
# 自动读取 git tag 作为版本号,保持左上角与发布 tag 一致
|
||||||
|
import subprocess as _sp
|
||||||
|
def _git_version():
|
||||||
|
try:
|
||||||
|
tags = _sp.check_output(
|
||||||
|
["git", "tag", "--sort=-v:refname"],
|
||||||
|
cwd=_backend_dir, stderr=_sp.DEVNULL,
|
||||||
|
).decode().strip().split("\n")
|
||||||
|
return tags[0] if tags and tags[0] else "dev"
|
||||||
|
except Exception:
|
||||||
|
return "dev"
|
||||||
|
app.jinja_env.globals["app_version"] = _git_version()
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
# 模块级执行迁移(保留 gunicorn --preload 行为:导入即触发)
|
# 模块级执行迁移(保留 gunicorn --preload 行为:导入即触发)
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ def attach_common(conn, resource, items):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def monthly_finance(conn, tenant="科普·无界"):
|
def monthly_finance(conn, tenant="科普·无界", year=None):
|
||||||
_year = date.today().year
|
_year = year or date.today().year
|
||||||
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
|
||||||
pfs = rows(conn,
|
pfs = rows(conn,
|
||||||
"SELECT sign_amount, sign_month, budget_data, expense_data FROM project_finances WHERE tenant=?",
|
"SELECT sign_amount, sign_month, budget_data, expense_data FROM project_finances WHERE tenant=?",
|
||||||
|
|||||||
@@ -489,6 +489,75 @@ def bootstrap():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/finance/yearly")
|
||||||
|
@login_required
|
||||||
|
def finance_yearly():
|
||||||
|
"""按年份返回经营月报/季报所需的 12 月财务数据,支持历史年份切换。year=0 表示所有年份"""
|
||||||
|
_year = request.args.get("year", type=int)
|
||||||
|
if _year is None:
|
||||||
|
_year = date.today().year
|
||||||
|
tenant = request.args.get("tenant", session.get("tenants", ["科普·无界"])[0])
|
||||||
|
allowed = session.get("tenants", [])
|
||||||
|
allowed = [t if t != "总工作台" else "科普·无界" for t in allowed]
|
||||||
|
allowed = [t if t != "学会·无界" else "学术·无界" for t in allowed]
|
||||||
|
if tenant not in allowed:
|
||||||
|
tenant = allowed[0] if allowed else ""
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
if _year == 0:
|
||||||
|
# 所有年份:查出所有有数据的年份,合并 12 个月
|
||||||
|
yr_rows = rows(conn,
|
||||||
|
"SELECT DISTINCT SUBSTRING(sign_month,1,4) AS y FROM project_finances WHERE tenant=? AND sign_month IS NOT NULL AND sign_month!='' ORDER BY y",
|
||||||
|
[tenant])
|
||||||
|
all_years = [int(r["y"]) for r in yr_rows if r["y"] and r["y"].isdigit()]
|
||||||
|
if not all_years:
|
||||||
|
all_years = [date.today().year]
|
||||||
|
merged = [{"month": f"所有-{m+1:02d}", "revenue": 0, "gross": 0, "payment": 0, "cost": 0, "paid": 0, "sign": 0}
|
||||||
|
for m in range(12)]
|
||||||
|
for y in all_years:
|
||||||
|
fm = monthly_finance(conn, tenant, y)
|
||||||
|
for i, m in enumerate(fm):
|
||||||
|
for k in ("revenue", "gross", "payment", "cost", "paid", "sign"):
|
||||||
|
merged[i][k] = round(merged[i][k] + (m[k] or 0), 2)
|
||||||
|
return jsonify({"year": 0, "financeMonthly": merged})
|
||||||
|
return jsonify({"year": _year, "financeMonthly": monthly_finance(conn, tenant, _year)})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/finance/copy-to-plan/<int:source_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def copy_finance_to_plan(source_id):
|
||||||
|
"""将已发生模块的项目复制到预算模块"""
|
||||||
|
table, cols = TABLES["planFinances"]
|
||||||
|
conn = db()
|
||||||
|
try:
|
||||||
|
src = one(conn, "SELECT * FROM project_finances WHERE id=?", [source_id])
|
||||||
|
if not src:
|
||||||
|
return jsonify({"error": "源项目不存在"}), 404
|
||||||
|
# 构建目标数据:customer_name 前加"副本-"
|
||||||
|
data = {}
|
||||||
|
for col in cols:
|
||||||
|
if col == "customer_name":
|
||||||
|
data[col] = "副本-" + (src.get("customer_name") or "")
|
||||||
|
elif col == "project_type":
|
||||||
|
data[col] = "待签约"
|
||||||
|
elif col == "weight":
|
||||||
|
data[col] = "20%"
|
||||||
|
elif col == "created_at" or col == "updated_at":
|
||||||
|
data[col] = ""
|
||||||
|
else:
|
||||||
|
data[col] = src.get(col, "")
|
||||||
|
values = [data.get(col, "") for col in cols]
|
||||||
|
cur = _exec(conn,
|
||||||
|
f"INSERT INTO {table} ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})",
|
||||||
|
values)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True, "id": cur.lastrowid})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------- 通用 CRUD ----------
|
# ---------- 通用 CRUD ----------
|
||||||
|
|
||||||
@bp.route("/api/<resource>/list", methods=["GET"])
|
@bp.route("/api/<resource>/list", methods=["GET"])
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ applyUserTenants().then(() => {
|
|||||||
return load();
|
return load();
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
const savedTab = localStorage.getItem("opc-active-tab");
|
const savedTab = localStorage.getItem("opc-active-tab");
|
||||||
if (savedTab && savedTab !== "home") {
|
const validTabs = ["plan", "finance", "projects", "proposals", "products", "performance", "overview_plan", "overview_finance"];
|
||||||
|
if (savedTab && validTabs.includes(savedTab) && document.getElementById(savedTab)) {
|
||||||
switchTab(savedTab);
|
switchTab(savedTab);
|
||||||
|
} else {
|
||||||
|
switchTab(state.active);
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${esc(error.message)}</section>`;
|
document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${esc(error.message)}</section>`;
|
||||||
|
|||||||
@@ -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 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 text-slate-700">${esc(u.display_name)}</td>
|
||||||
<td class="py-3 px-2">
|
<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>
|
||||||
<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-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">
|
<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>` : ""}
|
${resource === "proposals" ? `<section><h3 class="drawer-section-title">附件</h3>${fileGroup("proposal", item.id, "", "附件", item.files || [])}</section>` : ""}
|
||||||
${followupTarget ? `<section>
|
${followupTarget ? `<section>
|
||||||
<h3 class="drawer-section-title">活动 / 跟进</h3>
|
<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}')">
|
<form class="comment-box mt-3" onsubmit="submitComment(event,'${followupTarget}',${item.id},'${resource}')">
|
||||||
<div class="squire-toolbar">
|
<div class="squire-toolbar">
|
||||||
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function renderFinance() {
|
|||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
const rev = b.rev || 0;
|
const rev = b.rev || 0;
|
||||||
const gross = b.gross || 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 {
|
} else {
|
||||||
const payment = b.payment || 0;
|
const payment = b.payment || 0;
|
||||||
const cost = b.cost || 0;
|
const cost = b.cost || 0;
|
||||||
@@ -89,7 +89,7 @@ function renderFinance() {
|
|||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
const totalRev = pf.total_rev || 0;
|
const totalRev = pf.total_rev || 0;
|
||||||
const totalGross = pf.total_gross || 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 {
|
} else {
|
||||||
let totalPayment = 0, totalCost = 0;
|
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) {}
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
|
||||||
@@ -97,21 +97,29 @@ function renderFinance() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const sm = pf.sign_month || "";
|
const sm = pf.sign_month || "";
|
||||||
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-brand-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
return `<tr class="border-b ${fmMarkBg(pf.id) || 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const now2 = new Date();
|
const now2 = new Date();
|
||||||
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
|
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
|
||||||
if (!state.finMonth) state.finMonth = defaultMonth2;
|
if (!state.finMonth) state.finMonth = defaultMonth2;
|
||||||
if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3);
|
if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3);
|
||||||
|
if (state.finYear == null) state.finYear = now2.getFullYear();
|
||||||
|
if (state.finMonthlyYear === undefined) state.finMonthlyYear = state.finYear;
|
||||||
|
if (!['annual','quarterly','monthly'].includes(state.finView)) state.finView = 'annual';
|
||||||
|
if (!['overview','quarterlyOverview','projects','expense'].includes(state.finFilter)) state.finFilter = 'projects';
|
||||||
|
if (state.finView === 'monthly' && state.finMonth) {
|
||||||
|
const monthSuffix = state.finMonth.slice(-2);
|
||||||
|
state.finMonth = state.finYear + '-' + monthSuffix;
|
||||||
|
}
|
||||||
const qLabels = ['Q1','Q2','Q3','Q4'];
|
const qLabels = ['Q1','Q2','Q3','Q4'];
|
||||||
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
||||||
const nowD = new Date();
|
const nowD = new Date();
|
||||||
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
|
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
|
||||||
const activeMonths = qMonths[activeQ];
|
const activeMonths = qMonths[activeQ];
|
||||||
const yearOpts = [2024,2025,2026,2027];
|
const yearOpts = [2020,2021,2022,2023,2024,2025,2026,2027];
|
||||||
const finHeaderBase = `<div class="flex items-center gap-2"><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 pr-6 rounded text-sm font-medium border border-slate-200 bg-white">${`<option value="0" ${state.finYear===0?'selected':''}>所有年份</option>`}${yearOpts.map(y => `<option value="${y}" ${state.finYear===y?'selected':''}>${y}年</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><button onclick="setFinView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='annual'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全年</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">月度:</span>${activeMonths.map(m => { const ms = `${state.finYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
||||||
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
|
||||||
|
|
||||||
const isOverviewMode = false;
|
const isOverviewMode = false;
|
||||||
@@ -156,7 +164,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="contact_phone" class="form-ctrl" placeholder="请输入联系电话"></label>
|
||||||
<label class="block"><span class="fin-label">其他</span><input name="other_info" class="form-ctrl" placeholder="备注信息"></label>
|
<label class="block"><span class="fin-label">其他</span><input name="other_info" class="form-ctrl" placeholder="备注信息"></label>
|
||||||
</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-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>
|
</div>
|
||||||
<div id="financeTabExec" class="hidden">
|
<div id="financeTabExec" class="hidden">
|
||||||
@@ -172,9 +180,9 @@ function renderFinance() {
|
|||||||
</div>
|
</div>
|
||||||
<div id="financeTabRevpay" class="hidden">
|
<div id="financeTabRevpay" class="hidden">
|
||||||
<div class="grid grid-cols-3 gap-3 mb-4" id="revpaySummary">
|
<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">
|
<div class="bg-brand-50 rounded-lg p-3 text-center border border-brand-100">
|
||||||
<p class="text-xs text-blue-600 font-medium">总确收</p>
|
<p class="text-xs text-brand-600 font-medium">总确收</p>
|
||||||
<p class="text-lg font-bold text-blue-700" id="revpayTotalRev">¥0</p>
|
<p class="text-lg font-bold text-brand-700" id="revpayTotalRev">¥0</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-green-50 rounded-lg p-3 text-center border border-green-100">
|
<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>
|
<p class="text-xs text-green-600 font-medium">总毛利</p>
|
||||||
@@ -240,13 +248,21 @@ function renderFinance() {
|
|||||||
</div>
|
</div>
|
||||||
</div><div class="flex justify-end gap-3 pt-2 finance-form-actions"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
|
</div><div class="flex justify-end gap-3 pt-2 finance-form-actions"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8">保存</button></div></form></div></div>
|
||||||
${(() => {
|
${(() => {
|
||||||
if (state.finFilter === 'overview') {
|
if (state.finFilter === 'overview' || state.finFilter === 'quarterlyOverview') {
|
||||||
setTimeout(() => renderFinanceOverview(), 10);
|
const isQ = state.finFilter === 'quarterlyOverview';
|
||||||
return `<div id="financeOverview"></div>`;
|
const renderFn = isQ ? renderFinanceQuarterlyOverview : renderFinanceOverview;
|
||||||
}
|
const containerId = isQ ? 'financeQuarterlyOverview' : 'financeOverview';
|
||||||
if (state.finFilter === 'quarterlyOverview') {
|
if (state.finMonthlyYear !== state.finYear) {
|
||||||
setTimeout(() => renderFinanceQuarterlyOverview(), 10);
|
const y = state.finYear;
|
||||||
return `<div id="financeQuarterlyOverview"></div>`;
|
fetch(`/api/finance/yearly?year=${y}&tenant=${encodeURIComponent(state.tenant)}`).then(r => r.json()).then(d => {
|
||||||
|
if (d && d.financeMonthly) { state.data.financeMonthly = d.financeMonthly; state.finMonthlyYear = d.year; }
|
||||||
|
else { state.data.financeMonthly = []; state.finMonthlyYear = y; }
|
||||||
|
renderFn();
|
||||||
|
}).catch(() => { state.finMonthlyYear = y; renderFn(); });
|
||||||
|
} else {
|
||||||
|
setTimeout(renderFn, 10);
|
||||||
|
}
|
||||||
|
return `<div id="${containerId}"></div>`;
|
||||||
}
|
}
|
||||||
if (state.finFilter === 'expense') {
|
if (state.finFilter === 'expense') {
|
||||||
setTimeout(() => renderExpense(), 10);
|
setTimeout(() => renderExpense(), 10);
|
||||||
@@ -283,16 +299,15 @@ function renderFinance() {
|
|||||||
};
|
};
|
||||||
const SIGNED_COLS = [
|
const SIGNED_COLS = [
|
||||||
{ key: '_expand', label: '' },
|
{ key: '_expand', label: '' },
|
||||||
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
{ key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
|
||||||
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
{ key: 'rev', label: '已确收' }, { key: 'gross', label: '毛利' }, { key: 'gross_rate', label: '毛利率' },
|
||||||
{ key: 'payment', label: '回款' }, { key: 'pending', label: '应收' }, { key: 'pay_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: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'payable', label: '应付' }, { key: 'paid_rate', label: '付款率' },
|
||||||
{ key: 'cashflow', label: '现金流' },
|
{ key: 'cashflow', label: '现金流' },
|
||||||
];
|
];
|
||||||
const UNSIGNED_COLS = [
|
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: '成本' },
|
{ key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
|
||||||
,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
|
||||||
@@ -312,10 +327,10 @@ function renderFinance() {
|
|||||||
['应付', m(payable), '成本 − 支出'],
|
['应付', m(payable), '成本 − 支出'],
|
||||||
['往来余额', m(netAsset), '应收 − 应付'],
|
['往来余额', m(netAsset), '应收 − 应付'],
|
||||||
];
|
];
|
||||||
const cardHtml = (r, i) => '<div class="card px-2 py-1.5 text-center flex-1" style="background:' + (['#eff6ff','#eff6ff','#eff6ff','#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 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 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 thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
||||||
const theadCols = isUnsigned ? 6 : 15;
|
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 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
|
const subtitle = isUnsigned
|
||||||
? '合同 → 毛利 → 成本 → 项目利润'
|
? '合同 → 毛利 → 成本 → 项目利润'
|
||||||
@@ -334,13 +349,13 @@ function renderFinance() {
|
|||||||
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
|
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
|
||||||
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
|
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||||
const payable = cost - paid;
|
const payable = cost - paid;
|
||||||
const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-700';
|
const payableCls = payable > 0 ? 'text-green-600' : payable < 0 ? 'text-red-600' : 'text-slate-700';
|
||||||
const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>';
|
const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>';
|
||||||
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||||
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
||||||
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
const pending = rev - payment;
|
const pending = rev - payment;
|
||||||
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700';
|
const pendingCls = pending > 0 ? 'text-green-600' : pending < 0 ? 'text-red-600' : 'text-slate-700';
|
||||||
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
|
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
|
||||||
var budgetArr = [];
|
var budgetArr = [];
|
||||||
var expenseArr = [];
|
var expenseArr = [];
|
||||||
@@ -362,13 +377,13 @@ function renderFinance() {
|
|||||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
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 eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
var bCf = bPay - ePaid;
|
var bCf = bPay - ePaid;
|
||||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-600">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
|
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-600">' + m + '</td><td class="px-2 py-px text-center text-xs text-brand-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm 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="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"></th><th class="px-2 py-1 text-center text-xs text-slate-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
return `<tr class="border-b ${fmMarkBg(pf.id) || 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-brand-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${payableCls}">${payableVal}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="fin_expand_${pf.id}"><td colspan="14"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
|
||||||
};
|
};
|
||||||
// 待签约简版行
|
// 待签约简版行
|
||||||
const tdRowUnsigned = (pf, cost, gross) => {
|
const tdRowUnsigned = (pf, cost, gross) => {
|
||||||
return `<tr class="border-b 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 ${fmMarkBg(pf.id) || 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.finView === 'monthly') {
|
if (state.finView === 'monthly') {
|
||||||
@@ -377,17 +392,26 @@ function renderFinance() {
|
|||||||
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
|
const defaultMonth = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
|
||||||
if (!state.finMonth) state.finMonth = defaultMonth;
|
if (!state.finMonth) state.finMonth = defaultMonth;
|
||||||
const selMonth = state.finMonth;
|
const selMonth = state.finMonth;
|
||||||
|
const isAllMonth = selMonth.startsWith('0-');
|
||||||
|
const monthSuffix = selMonth.slice(-2);
|
||||||
var dataItems = [];
|
var dataItems = [];
|
||||||
allPfs.forEach(pf => {
|
allPfs.forEach(pf => {
|
||||||
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
|
let bd = []; try { bd = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
|
||||||
let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
|
let ed = []; try { ed = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
|
||||||
const b = bd.find(x => (x.month || "") === selMonth) || {};
|
let rev = 0, payment = 0, gross = 0, cost = 0, paid = 0;
|
||||||
const e = ed.find(x => (x.month || "") === selMonth) || {};
|
if (isAllMonth) {
|
||||||
const rev = Math.round(parseFloat(b.rev || 0) || 0);
|
bd.forEach(x => { if ((x.month||"").substring(5) === monthSuffix) { rev += parseFloat(x.rev||0)||0; gross += parseFloat(x.gross||0)||0; payment += parseFloat(x.payment||0)||0; } });
|
||||||
const payment = Math.round(parseFloat(b.payment || 0) || 0);
|
ed.forEach(x => { if ((x.month||"").substring(5) === monthSuffix) { cost += parseFloat(x.cost||0)||0; paid += parseFloat(x.paid||0)||0; } });
|
||||||
const gross = Math.round(parseFloat(b.gross || 0) || 0);
|
rev = Math.round(rev); payment = Math.round(payment); gross = Math.round(gross); cost = Math.round(cost); paid = Math.round(paid);
|
||||||
const cost = Math.round(parseFloat(e.cost || 0) || 0);
|
} else {
|
||||||
const paid = Math.round(parseFloat(e.paid || 0) || 0);
|
const b = bd.find(x => (x.month || "") === selMonth) || {};
|
||||||
|
const e = ed.find(x => (x.month || "") === selMonth) || {};
|
||||||
|
rev = Math.round(parseFloat(b.rev || 0) || 0);
|
||||||
|
payment = Math.round(parseFloat(b.payment || 0) || 0);
|
||||||
|
gross = Math.round(parseFloat(b.gross || 0) || 0);
|
||||||
|
cost = Math.round(parseFloat(e.cost || 0) || 0);
|
||||||
|
paid = Math.round(parseFloat(e.paid || 0) || 0);
|
||||||
|
}
|
||||||
if (!rev && !payment && !cost && !paid && !gross) return;
|
if (!rev && !payment && !cost && !paid && !gross) return;
|
||||||
dataItems.push({
|
dataItems.push({
|
||||||
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
||||||
@@ -407,8 +431,12 @@ function renderFinance() {
|
|||||||
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
|
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
|
||||||
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
|
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
|
||||||
});
|
});
|
||||||
const signTotal = Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0));
|
const signTotal = isAllMonth
|
||||||
const signCnt = allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).length;
|
? Math.round(allPfs.filter(x => { const sm = x.sign_month || ""; return sm.length >= 7 && sm.substring(5) === monthSuffix; }).reduce((a, p) => a + (p.sign_amount || 0), 0))
|
||||||
|
: Math.round(allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).reduce((a, p) => a + (p.sign_amount || 0), 0));
|
||||||
|
const signCnt = isAllMonth
|
||||||
|
? allPfs.filter(x => { const sm = x.sign_month || ""; return sm.length >= 7 && sm.substring(5) === monthSuffix; }).length
|
||||||
|
: allPfs.filter(x => (x.sign_month || "").substring(0, 7) === selMonth).length;
|
||||||
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据');
|
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该月份暂无数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,12 +452,12 @@ function renderFinance() {
|
|||||||
const qRange = qRanges[selQ];
|
const qRange = qRanges[selQ];
|
||||||
const sumBudget = (pf, field) => {
|
const sumBudget = (pf, field) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.finYear) total += parseFloat(b[field] || 0); }); } catch (e) {}
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && (y === state.finYear || state.finYear === 0)) total += parseFloat(b[field] || 0); }); } catch (e) {}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
const sumExpense = (pf, field) => {
|
const sumExpense = (pf, field) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.finYear) total += parseFloat(e[field] || 0); }); } catch (e) {}
|
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && (y === state.finYear || state.finYear === 0)) total += parseFloat(e[field] || 0); }); } catch (e) {}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
var dataItems = [];
|
var dataItems = [];
|
||||||
@@ -458,25 +486,52 @@ function renderFinance() {
|
|||||||
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
|
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
|
||||||
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
|
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
|
||||||
});
|
});
|
||||||
const signTotal = Math.round(allPfs.filter(x => { const m = parseInt((x.sign_month || "0").substring(5)) || 0; return qRange.includes(m); }).reduce((a, p) => a + (p.sign_amount || 0), 0));
|
const signTotal = Math.round(allPfs.filter(x => {
|
||||||
const signCnt = allPfs.filter(x => qRange.includes(parseInt((x.sign_month || "0").substring(5)) || 0)).length;
|
const m = parseInt((x.sign_month || "0").substring(5)) || 0;
|
||||||
|
const qy = parseInt((x.sign_month || "0").substring(0, 4)) || 0;
|
||||||
|
return qRange.includes(m) && (qy === state.finYear || state.finYear === 0);
|
||||||
|
}).reduce((a, p) => a + (p.sign_amount || 0), 0));
|
||||||
|
const signCnt = allPfs.filter(x => {
|
||||||
|
const m = parseInt((x.sign_month || "0").substring(5)) || 0;
|
||||||
|
const qy = parseInt((x.sign_month || "0").substring(0, 4)) || 0;
|
||||||
|
return qRange.includes(m) && (qy === state.finYear || state.finYear === 0);
|
||||||
|
}).length;
|
||||||
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据');
|
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '该季度暂无数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 总视图
|
// 总视图
|
||||||
const calcTotals = (pf) => {
|
const calcTotals = (pf, year) => {
|
||||||
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
|
let budget = []; try { budget = JSON.parse(pf.budget_data || "[]"); } catch (e) {}
|
||||||
let expense = []; try { expense = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
|
let expense = []; try { expense = JSON.parse(pf.expense_data || "[]"); } catch (e) {}
|
||||||
let rev = 0, payment = 0, gross = 0;
|
let rev = 0, payment = 0, gross = 0;
|
||||||
budget.forEach(b => { rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0; });
|
budget.forEach(b => {
|
||||||
|
if (year !== 0) {
|
||||||
|
const by = parseInt((b.month || "").substring(0, 4)) || 0;
|
||||||
|
if (by && by !== year) return;
|
||||||
|
}
|
||||||
|
rev += parseFloat(b.rev || 0) || 0; gross += parseFloat(b.gross || 0) || 0; payment += parseFloat(b.payment || 0) || 0;
|
||||||
|
});
|
||||||
let cost = 0, paid = 0;
|
let cost = 0, paid = 0;
|
||||||
expense.forEach(e => { cost += parseFloat(e.cost || 0) || 0; paid += parseFloat(e.paid || 0) || 0; });
|
expense.forEach(e => {
|
||||||
|
if (year !== 0) {
|
||||||
|
const ey = parseInt((e.month || "").substring(0, 4)) || 0;
|
||||||
|
if (ey && ey !== year) return;
|
||||||
|
}
|
||||||
|
cost += parseFloat(e.cost || 0) || 0; paid += parseFloat(e.paid || 0) || 0;
|
||||||
|
});
|
||||||
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
|
return { rev: Math.round(rev), payment: Math.round(payment), cost: Math.round(cost), paid: Math.round(paid), gross: Math.round(gross) };
|
||||||
};
|
};
|
||||||
const allPfs = pfs;
|
const allPfs = pfs;
|
||||||
var dataItems = allPfs.map(pf => {
|
var dataItems = [];
|
||||||
var t = calcTotals(pf);
|
allPfs.forEach(pf => {
|
||||||
return {
|
var t = calcTotals(pf, state.finYear);
|
||||||
|
if (state.finYear !== 0) {
|
||||||
|
const sy = parseInt((pf.sign_month || "").substring(0, 4)) || 0;
|
||||||
|
const hasFinance = t.rev || t.payment || t.cost || t.paid || t.gross;
|
||||||
|
const hasSign = sy === state.finYear;
|
||||||
|
if (!hasFinance && !hasSign) return;
|
||||||
|
}
|
||||||
|
dataItems.push({
|
||||||
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
pf: pf, customer_name: pf.customer_name || '', sign_amount: pf.sign_amount || 0, sign_month: pf.sign_month || '',
|
||||||
rev: t.rev, gross: t.gross, cost: t.cost,
|
rev: t.rev, gross: t.gross, cost: t.cost,
|
||||||
payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid,
|
payment: t.payment, paid: t.paid, cashflow: t.payment - t.paid,
|
||||||
@@ -484,7 +539,7 @@ function renderFinance() {
|
|||||||
gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
|
gross_rate: t.rev && t.gross ? Math.round(t.gross / t.rev * 100) : 0,
|
||||||
pay_rate: t.rev && t.payment ? Math.round(t.payment / t.rev * 100) : 0,
|
pay_rate: t.rev && t.payment ? Math.round(t.payment / t.rev * 100) : 0,
|
||||||
paid_rate: t.cost && t.paid ? Math.round(t.paid / t.cost * 100) : 0,
|
paid_rate: t.cost && t.paid ? Math.round(t.paid / t.cost * 100) : 0,
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
var sortK = state.finSort && state.finSort.key ? state.finSort.key.split('|')[1] : null;
|
var sortK = state.finSort && state.finSort.key ? state.finSort.key.split('|')[1] : null;
|
||||||
dataItems = sortData(dataItems, sortK);
|
dataItems = sortData(dataItems, sortK);
|
||||||
@@ -494,8 +549,16 @@ function renderFinance() {
|
|||||||
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
|
sumRev += d.rev; sumPay += d.payment; sumCost += d.cost; sumPaid += d.paid; sumGross += d.gross;
|
||||||
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
|
rows.push(isUnsigned ? tdRowUnsigned(d.pf, d.cost, d.gross) : tdRow(d.pf, d.rev, d.payment, d.cost, d.paid, d.gross));
|
||||||
});
|
});
|
||||||
const signTotal = Math.round(allPfs.reduce((a, p) => a + (p.sign_amount || 0), 0));
|
const signTotal = Math.round(allPfs.filter(p => {
|
||||||
const signCnt = allPfs.length;
|
if (state.finYear === 0) return true;
|
||||||
|
const sy = parseInt((p.sign_month || "").substring(0, 4)) || 0;
|
||||||
|
return sy === state.finYear;
|
||||||
|
}).reduce((a, p) => a + (p.sign_amount || 0), 0));
|
||||||
|
const signCnt = allPfs.filter(p => {
|
||||||
|
if (state.finYear === 0) return true;
|
||||||
|
const sy = parseInt((p.sign_month || "").substring(0, 4)) || 0;
|
||||||
|
return sy === state.finYear;
|
||||||
|
}).length;
|
||||||
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据');
|
return renderView(rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, '暂无数据');
|
||||||
})()}
|
})()}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -598,13 +661,12 @@ window.initCostTable = (expenseData) => {
|
|||||||
const TASK_TYPES = ["科普视频", "科普专访", "科普文章", "问卷调研", "病例征集"];
|
const TASK_TYPES = ["科普视频", "科普专访", "科普文章", "问卷调研", "病例征集"];
|
||||||
|
|
||||||
function taskMonthOptions(selected) {
|
function taskMonthOptions(selected) {
|
||||||
const now = new Date();
|
|
||||||
const opts = [];
|
const opts = [];
|
||||||
for (let i = -2; i <= 12; i++) {
|
for (let y = 2020; y <= 2027; y++)
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
for (let m = 1; m <= 12; m++) {
|
||||||
const v = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
|
const v = y + "-" + String(m).padStart(2, "0");
|
||||||
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
||||||
}
|
}
|
||||||
return opts.join("");
|
return opts.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,9 +723,9 @@ function updateRowCalc(row) {
|
|||||||
window.toggleBtChip = (chip) => {
|
window.toggleBtChip = (chip) => {
|
||||||
const cb = chip.querySelector('input');
|
const cb = chip.querySelector('input');
|
||||||
cb.checked = !cb.checked;
|
cb.checked = !cb.checked;
|
||||||
chip.classList.toggle('bg-blue-50', cb.checked);
|
chip.classList.toggle('bg-brand-50', cb.checked);
|
||||||
chip.classList.toggle('border-blue-400', cb.checked);
|
chip.classList.toggle('border-brand-400', cb.checked);
|
||||||
chip.classList.toggle('text-blue-600', cb.checked);
|
chip.classList.toggle('text-brand-600', cb.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.initTaskTable = (taskData) => {
|
window.initTaskTable = (taskData) => {
|
||||||
@@ -710,11 +772,11 @@ window.editPfSignMonth = (event, pfId) => {
|
|||||||
try {
|
try {
|
||||||
await api(`/api/projectFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
|
await api(`/api/projectFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
|
||||||
pf.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"); }
|
} catch (e) { toast("修改失败:" + e.message, "error"); }
|
||||||
});
|
});
|
||||||
select.addEventListener("blur", () => {
|
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.innerHTML = "";
|
||||||
td.appendChild(select);
|
td.appendChild(select);
|
||||||
@@ -741,7 +803,7 @@ async function loadFinFollowups(pfId) {
|
|||||||
try {
|
try {
|
||||||
const fups = await api(`/api/followups/project_finance/${pfId}`);
|
const fups = await api(`/api/followups/project_finance/${pfId}`);
|
||||||
list.innerHTML = fups.length
|
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("")
|
? 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>';
|
: '<p class="text-sm text-slate-700 py-4 text-center">暂无跟进记录</p>';
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
list.querySelectorAll(".rich-content").forEach(el => {
|
list.querySelectorAll(".rich-content").forEach(el => {
|
||||||
@@ -814,9 +876,9 @@ window.openPfEditModal = (pfId) => {
|
|||||||
cb.checked = btValues.includes(cb.value);
|
cb.checked = btValues.includes(cb.value);
|
||||||
const chip = cb.closest('.bt-chip');
|
const chip = cb.closest('.bt-chip');
|
||||||
if (chip) {
|
if (chip) {
|
||||||
chip.classList.toggle('bg-blue-50', cb.checked);
|
chip.classList.toggle('bg-brand-50', cb.checked);
|
||||||
chip.classList.toggle('border-blue-400', cb.checked);
|
chip.classList.toggle('border-brand-400', cb.checked);
|
||||||
chip.classList.toggle('text-blue-600', cb.checked);
|
chip.classList.toggle('text-brand-600', cb.checked);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
|
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
|
||||||
@@ -956,7 +1018,8 @@ window.createFinance = async (event) => {
|
|||||||
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
|
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
|
||||||
closeFinanceModal();
|
closeFinanceModal();
|
||||||
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
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) {
|
} catch (error) {
|
||||||
toast("保存失败:" + error.message, "error");
|
toast("保存失败:" + error.message, "error");
|
||||||
}
|
}
|
||||||
@@ -972,7 +1035,8 @@ window.deleteFinanceItem = async () => {
|
|||||||
await api(`/api/projectFinances/${pfId}`, { method: "DELETE" });
|
await api(`/api/projectFinances/${pfId}`, { method: "DELETE" });
|
||||||
closeFinanceModal();
|
closeFinanceModal();
|
||||||
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
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");
|
toast("已删除", "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("删除失败:" + error.message, "error");
|
toast("删除失败:" + error.message, "error");
|
||||||
@@ -990,7 +1054,9 @@ function renderFinanceOverview() {
|
|||||||
const fm = financeMonthly || [];
|
const fm = financeMonthly || [];
|
||||||
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
|
||||||
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
const LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
||||||
const annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)];
|
const annualSums = { sign:0, revenue:0, gross:0, cost:0, paid:0, payment:0 };
|
||||||
|
fm.forEach(x => { annualSums.sign += x.sign||0; annualSums.revenue += x.revenue||0; annualSums.gross += x.gross||0; annualSums.cost += x.cost||0; annualSums.paid += x.paid||0; annualSums.payment += x.payment||0; });
|
||||||
|
const annualVals = [annualSums.sign, annualSums.revenue, annualSums.gross, annualSums.cost, annualSums.paid, annualSums.payment, annualSums.payment - annualSums.paid];
|
||||||
const fmFields = ['sign','revenue','gross','cost','paid','payment',null];
|
const fmFields = ['sign','revenue','gross','cost','paid','payment',null];
|
||||||
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
|
for (var mi = 0; mi < 12; mi++) { thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + mLbl[mi] + '</th>'; }
|
||||||
@@ -1020,6 +1086,8 @@ function renderFinanceOverview() {
|
|||||||
if ((finExp[ei].status || '') === '暂不计入') continue;
|
if ((finExp[ei].status || '') === '暂不计入') continue;
|
||||||
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
||||||
var mi2 = parseInt(em.substring(5)) - 1;
|
var mi2 = parseInt(em.substring(5)) - 1;
|
||||||
|
var emYear = parseInt(em.substring(0, 4)) || 0;
|
||||||
|
if (state.finYear !== 0 && emYear && emYear !== state.finYear) continue;
|
||||||
if (mi2 >= 0 && mi2 < 12) expMonthly[mi2] += parseFloat(finExp[ei].amount || 0);
|
if (mi2 >= 0 && mi2 < 12) expMonthly[mi2] += parseFloat(finExp[ei].amount || 0);
|
||||||
}
|
}
|
||||||
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
|
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
|
||||||
@@ -1036,11 +1104,12 @@ function renderFinanceOverview() {
|
|||||||
for (var dri = 0; dri < 3; dri++) {
|
for (var dri = 0; dri < 3; dri++) {
|
||||||
var dr = deptRows[dri];
|
var dr = deptRows[dri];
|
||||||
var rowCls = dri === 2 ? 'font-semibold' : '';
|
var rowCls = dri === 2 ? 'font-semibold' : '';
|
||||||
var valCls = dri === 2 ? 'text-green-600' : 'text-slate-700';
|
var valCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
|
||||||
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
|
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
|
||||||
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + valCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + valCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
|
||||||
for (var dmi = 0; dmi < 12; dmi++) {
|
for (var dmi = 0; dmi < 12; dmi++) {
|
||||||
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + valCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
|
var cellCls = dri === 2 ? (dr.vals[dmi] >= 0 ? 'text-green-600' : 'text-red-600') : valCls;
|
||||||
|
rowHtml += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cellCls + '">' + moneyIntL(dr.vals[dmi]) + '</td>';
|
||||||
}
|
}
|
||||||
deptTbody += rowHtml + '</tr>';
|
deptTbody += rowHtml + '</tr>';
|
||||||
}
|
}
|
||||||
@@ -1055,6 +1124,8 @@ function renderFinanceOverview() {
|
|||||||
if ((finExp[epi].status || '') === '暂不计入') continue;
|
if ((finExp[epi].status || '') === '暂不计入') continue;
|
||||||
var epm = (finExp[epi].expense_month || '').substring(0, 7);
|
var epm = (finExp[epi].expense_month || '').substring(0, 7);
|
||||||
var epmi = parseInt(epm.substring(5)) - 1;
|
var epmi = parseInt(epm.substring(5)) - 1;
|
||||||
|
var epmYear = parseInt(epm.substring(0, 4)) || 0;
|
||||||
|
if (state.finYear !== 0 && epmYear && epmYear !== state.finYear) continue;
|
||||||
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(finExp[epi].incurred_amount || 0);
|
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(finExp[epi].incurred_amount || 0);
|
||||||
}
|
}
|
||||||
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
|
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
|
||||||
@@ -1081,14 +1152,15 @@ function renderFinanceOverview() {
|
|||||||
}
|
}
|
||||||
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
||||||
|
|
||||||
document.querySelector("#financeOverview").innerHTML = '<div class="grid gap-2.5">' +
|
const yearSelectOv = `<div class="card px-4 py-2 mb-4"><div class="flex items-center gap-2"><span class="text-sm font-medium text-slate-500">📅 选择年份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.finYear===0?' selected':''}>所有年份</option>${[2020,2021,2022,2023,2024,2025,2026,2027].map(y => '<option value="'+y+'"'+(state.finYear===y?' selected':'')+'>'+y+'年</option>').join('')}</select></div></div>`;
|
||||||
|
document.querySelector("#financeOverview").innerHTML = yearSelectOv + '<div class="grid gap-2.5">' +
|
||||||
deptCard +
|
deptCard +
|
||||||
cfCard +
|
cfCard +
|
||||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
'<div class="grid grid-cols-3 gap-2.5">' +
|
'<div class="grid grid-cols-3 gap-2.5">' +
|
||||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartSign"></canvas></div>', 'p-4') +
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-600">' + (state.finYear===0 ? '所有年份' : state.finYear) + '</span></div><div style="position:relative;height:200px"><canvas id="finChartSign"></canvas></div>', 'p-4') +
|
||||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartRev"></canvas></div>', 'p-4') +
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">' + (state.finYear===0 ? '所有年份' : state.finYear) + '</span></div><div style="position:relative;height:200px"><canvas id="finChartRev"></canvas></div>', 'p-4') +
|
||||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与支出</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与支出</h2><span class="text-xs text-slate-600">' + (state.finYear===0 ? '所有年份' : state.finYear) + '</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -1109,7 +1181,9 @@ function renderFinanceQuarterlyOverview() {
|
|||||||
|
|
||||||
// 财务概览: 7 rows
|
// 财务概览: 7 rows
|
||||||
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
|
||||||
var annualVals = [m.signed_annual||m.signed_amount||0, m.revenue_annual||0, m.gross_annual||0, m.cost_annual||0, m.paid_annual||0, m.payment_annual||0, (m.payment_annual||0) - (m.paid_annual||0)];
|
var annualSums = { sign:0, revenue:0, gross:0, cost:0, paid:0, payment:0 };
|
||||||
|
fm.forEach(x => { annualSums.sign += x.sign||0; annualSums.revenue += x.revenue||0; annualSums.gross += x.gross||0; annualSums.cost += x.cost||0; annualSums.paid += x.paid||0; annualSums.payment += x.payment||0; });
|
||||||
|
var annualVals = [annualSums.sign, annualSums.revenue, annualSums.gross, annualSums.cost, annualSums.paid, annualSums.payment, annualSums.payment - annualSums.paid];
|
||||||
var fmFields = ['sign','revenue','gross','cost','paid','payment',null];
|
var fmFields = ['sign','revenue','gross','cost','paid','payment',null];
|
||||||
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
var qData = [[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0]];
|
||||||
for (var qi = 0; qi < 4; qi++) {
|
for (var qi = 0; qi < 4; qi++) {
|
||||||
@@ -1154,11 +1228,13 @@ function renderFinanceQuarterlyOverview() {
|
|||||||
if ((finExp[ei].status || '') === '暂不计入') continue;
|
if ((finExp[ei].status || '') === '暂不计入') continue;
|
||||||
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
||||||
var emi = parseInt(em.substring(5)) - 1;
|
var emi = parseInt(em.substring(5)) - 1;
|
||||||
|
var emYear = parseInt(em.substring(0, 4)) || 0;
|
||||||
|
if (state.finYear !== 0 && emYear && emYear !== state.finYear) continue;
|
||||||
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expQ[eqi] += parseFloat(finExp[ei].amount || 0); }
|
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expQ[eqi] += parseFloat(finExp[ei].amount || 0); }
|
||||||
}
|
}
|
||||||
var expAnnual = 0; for (var ei = 0; ei < 4; ei++) expAnnual += expQ[ei];
|
var expAnnual = 0; for (var ei = 0; ei < 4; ei++) expAnnual += expQ[ei];
|
||||||
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
|
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
|
||||||
var grossAnnual = m.gross_annual || 0;
|
var grossAnnual = 0; for (var ei = 0; ei < fm.length; ei++) grossAnnual += (fm[ei] ? fm[ei].gross : 0);
|
||||||
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
|
||||||
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>'; }
|
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>'; }
|
||||||
deptThead += '</tr>';
|
deptThead += '</tr>';
|
||||||
@@ -1184,12 +1260,14 @@ function renderFinanceQuarterlyOverview() {
|
|||||||
|
|
||||||
// 业务线现金流: quarterly
|
// 业务线现金流: quarterly
|
||||||
var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]];
|
var cfQ = [qData[5][0], qData[5][1], qData[5][2], qData[5][3]];
|
||||||
var cfAnnual = m.payment_annual || 0;
|
var cfAnnual = 0; for (var ei = 0; ei < fm.length; ei++) cfAnnual += (fm[ei] ? fm[ei].payment : 0);
|
||||||
var expPaidQ = [0,0,0,0];
|
var expPaidQ = [0,0,0,0];
|
||||||
for (var ei = 0; ei < finExp.length; ei++) {
|
for (var ei = 0; ei < finExp.length; ei++) {
|
||||||
if ((finExp[ei].status || '') === '暂不计入') continue;
|
if ((finExp[ei].status || '') === '暂不计入') continue;
|
||||||
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
var em = (finExp[ei].expense_month || '').substring(0, 7);
|
||||||
var emi = parseInt(em.substring(5)) - 1;
|
var emi = parseInt(em.substring(5)) - 1;
|
||||||
|
var emYear = parseInt(em.substring(0, 4)) || 0;
|
||||||
|
if (state.finYear !== 0 && emYear && emYear !== state.finYear) continue;
|
||||||
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expPaidQ[eqi] += parseFloat(finExp[ei].incurred_amount || 0); }
|
if (emi >= 0 && emi < 12) { var eqi = Math.floor(emi / 3); expPaidQ[eqi] += parseFloat(finExp[ei].incurred_amount || 0); }
|
||||||
}
|
}
|
||||||
var expPaidAnnual = 0; for (var ei = 0; ei < 4; ei++) expPaidAnnual += expPaidQ[ei];
|
var expPaidAnnual = 0; for (var ei = 0; ei < 4; ei++) expPaidAnnual += expPaidQ[ei];
|
||||||
@@ -1215,7 +1293,8 @@ function renderFinanceQuarterlyOverview() {
|
|||||||
cfTbody += rowHtml + '</tr>';
|
cfTbody += rowHtml + '</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#financeQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
|
const yearSelectQ = `<div class="card px-4 py-2 mb-4"><div class="flex items-center gap-2"><span class="text-sm font-medium text-slate-500">📅 选择年份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.finYear===0?' selected':''}>所有年份</option>${[2020,2021,2022,2023,2024,2025,2026,2027].map(y => '<option value="'+y+'"'+(state.finYear===y?' selected':'')+'>'+y+'年</option>').join('')}</select></div></div>`;
|
||||||
|
document.querySelector("#financeQuarterlyOverview").innerHTML = yearSelectQ + '<div class="grid gap-2.5">' +
|
||||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
||||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
||||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
@@ -1237,7 +1316,7 @@ function renderOverviewCharts(data) {
|
|||||||
var c2 = document.querySelector("#finChartRev");
|
var c2 = document.querySelector("#finChartRev");
|
||||||
if (c2 && window.Chart) {
|
if (c2 && window.Chart) {
|
||||||
if (state.finChart2) state.finChart2.destroy();
|
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");
|
var c3 = document.querySelector("#finChartCash");
|
||||||
if (c3 && window.Chart) {
|
if (c3 && window.Chart) {
|
||||||
@@ -1252,8 +1331,81 @@ function renderOverviewCharts(data) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ====== 筛选器控制函数 ======
|
// ====== 筛选器控制函数 ======
|
||||||
window.switchFinFilter = (f) => { state.finFilter = f; renderFinance(); };
|
window.switchFinFilter = (f) => { state.finFilter = f; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||||
window.setFinView = (v) => { state.finView = v; renderFinance(); };
|
renderFinance(); };
|
||||||
window.setFinYear = (y) => { state.finYear = parseInt(y); renderFinance(); };
|
window.setFinView = (v) => { state.finView = v; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||||
window.setFinQuarter = (q) => { state.finView = 'quarterly'; state.finQuarter = q; renderFinance(); };
|
renderFinance(); };
|
||||||
window.setFinMonth = (m) => { state.finView = 'monthly'; state.finMonth = m; 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')) || { color: {} }; } catch(e) { return { color: {} }; } })();
|
||||||
|
if (!state._fmMarks.color) state._fmMarks.color = {};
|
||||||
|
|
||||||
|
const FM_COLORS = [
|
||||||
|
{ key: 'red', label: '标红', hex: '#ef4444', bg: 'bg-red-50' },
|
||||||
|
{ key: 'yellow', label: '标黄', hex: '#eab308', bg: 'bg-yellow-50' },
|
||||||
|
{ key: 'blue', label: '标蓝', hex: '#3b82f6', bg: 'bg-blue-50' },
|
||||||
|
{ key: 'green', label: '标绿', hex: '#22c55e', bg: 'bg-green-50' },
|
||||||
|
{ key: 'purple', label: '标紫', hex: '#a855f7', bg: 'bg-purple-50' },
|
||||||
|
];
|
||||||
|
const fmMarkBg = function(id) {
|
||||||
|
var c = (state._fmMarks.color||{})[id];
|
||||||
|
return c ? { red:'bg-red-50',yellow:'bg-yellow-50',blue:'bg-blue-50',green:'bg-green-50',purple:'bg-purple-50'}[c] || '' : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.finShowCtx = function(e, pfId) {
|
||||||
|
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>' +
|
||||||
|
FM_COLORS.map(function(c) {
|
||||||
|
return '<div data-act="' + c.key + '" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155;display:flex;align-items:center"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + c.hex + ';margin-right:8px;flex-shrink:0"></span>' + c.label + '</div>';
|
||||||
|
}).join('') +
|
||||||
|
'<div style="border-top:1px solid #e2e8f0;margin:4px 0"></div>' +
|
||||||
|
'<div data-act="copy_plan" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155;display:flex;align-items:center"><span style="margin-right:6px;flex-shrink:0">📋</span>复制项目到预算</div>' +
|
||||||
|
'<div style="border-top:1px solid #e2e8f0;margin:2px 0"></div>' +
|
||||||
|
'<div data-act="clear" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#94a3b8">取消标记</div>';
|
||||||
|
menu.addEventListener('click', function(ev) {
|
||||||
|
var target = ev.target.closest('[data-act]');
|
||||||
|
if (!target) return;
|
||||||
|
ev.stopPropagation();
|
||||||
|
var act = target.getAttribute('data-act');
|
||||||
|
var id = menu.getAttribute('data-pfid');
|
||||||
|
menu.style.display = 'none';
|
||||||
|
if (act === 'copy_plan') {
|
||||||
|
if (!confirm('确认将此项目复制到预算模块?\n\n项目名称前将自动添加「副本-」前缀。')) return;
|
||||||
|
fetch('/api/finance/copy-to-plan/' + id, { method: 'POST' }).then(r => r.json()).then(d => {
|
||||||
|
if (d.ok) { alert('复制成功!请切换到预算模块查看。'); renderFinance(); }
|
||||||
|
else { alert('复制失败:' + (d.error || '未知错误')); }
|
||||||
|
}).catch(function() { alert('复制失败,请检查网络连接。'); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (act === 'clear') {
|
||||||
|
delete state._fmMarks.color[id];
|
||||||
|
} else {
|
||||||
|
if (state._fmMarks.color[id] === act) {
|
||||||
|
delete state._fmMarks.color[id];
|
||||||
|
} else {
|
||||||
|
state._fmMarks.color[id] = act;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
|
||||||
|
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 - 280) + 'px';
|
||||||
|
};
|
||||||
|
document.addEventListener('click', function() { var m = document.getElementById('finCtxMenu'); if (m) m.style.display = 'none'; });
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
// home.js — 首页渲染 + 财务趋势图
|
|
||||||
|
|
||||||
function renderHome() {
|
|
||||||
const { summary, financeMonthly } = state.data;
|
|
||||||
const m = summary.metrics;
|
|
||||||
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
|
|
||||||
// 回款提醒
|
|
||||||
const yrRev = m.revenue_annual || 0, yrPay = m.payment_annual || 0, yrUnpaid = yrRev - yrPay;
|
|
||||||
const yrPayRate = yrRev > 0 ? Math.round(yrPay / yrRev * 100) : 0;
|
|
||||||
const yrUnpaidWan = Math.round(Math.abs(yrUnpaid) / 10000);
|
|
||||||
const payRateCls = yrPayRate < 30 ? 'text-red-600' : yrPayRate < 80 ? 'text-amber-600' : 'text-green-600';
|
|
||||||
const unpaidLabel = yrUnpaid >= 0
|
|
||||||
? '你还有 <strong class=\"text-red-600\">' + yrUnpaidWan + '万</strong> 未回款'
|
|
||||||
: '超额回款 <strong class=\"text-green-600\">' + yrUnpaidWan + '万</strong>';
|
|
||||||
const payReminder = yrRev > 0 ? '<div class=\"flex items-center gap-3 px-4 py-3 rounded-lg border\" style=\"background:linear-gradient(135deg,#fef3c7,#fde68a);border-color:#f59e0b\">' +
|
|
||||||
'<i data-lucide=\"bell-ring\" class=\"text-amber-600 flex-shrink-0\" style=\"width:18px;height:18px\"></i>' +
|
|
||||||
'<span class=\"text-sm text-amber-900\"><strong>回款提醒:</strong>本年度确收 <strong>' + moneyInt(yrRev) + '</strong>,回款 <strong>' + moneyInt(yrPay) + '</strong>,' + unpaidLabel + ',当前回款率:<strong class=\"' + payRateCls + '\">' + yrPayRate + '%</strong></span>' +
|
|
||||||
'</div>' : '';
|
|
||||||
const rows1 = [
|
|
||||||
["年度累计", moneyInt(m.signed_annual || m.signed_amount)],
|
|
||||||
["本季度累计", moneyInt(m.signed_q2 || 0)],
|
|
||||||
["本月累计", moneyInt(m.signed_month || 0)],
|
|
||||||
["上本季度累计", moneyInt(m.signed_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.signed_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const rows2 = [
|
|
||||||
["年度累计", moneyInt(m.revenue_annual)],
|
|
||||||
["本季度累计", moneyInt(m.revenue_q2)],
|
|
||||||
["本月累计", moneyInt(m.monthly_revenue)],
|
|
||||||
["上本季度累计", moneyInt(m.revenue_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.revenue_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const rows3 = [
|
|
||||||
["年度累计", moneyInt(m.gross_annual)],
|
|
||||||
["本季度累计", moneyInt(m.gross_q2)],
|
|
||||||
["本月累计", moneyInt(m.monthly_net_profit)],
|
|
||||||
["上本季度累计", moneyInt(m.gross_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.gross_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const rows4 = [
|
|
||||||
["年度累计", moneyInt(m.payment_annual || 0)],
|
|
||||||
["本季度累计", moneyInt(m.payment_q2 || 0)],
|
|
||||||
["本月累计", moneyInt(m.payment_month || 0)],
|
|
||||||
["上本季度累计", moneyInt(m.payment_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.payment_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const rows5 = [
|
|
||||||
["年度累计", moneyInt(m.cost_annual || 0)],
|
|
||||||
["本季度累计", moneyInt(m.cost_q2 || 0)],
|
|
||||||
["本月累计", moneyInt(m.cost_month || 0)],
|
|
||||||
["上本季度累计", moneyInt(m.cost_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.cost_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const rows7 = [
|
|
||||||
["年度累计", moneyInt(m.paid_annual || 0)],
|
|
||||||
["本季度累计", moneyInt(m.paid_q2 || 0)],
|
|
||||||
["本月累计", moneyInt(m.paid_month || 0)],
|
|
||||||
["上本季度累计", moneyInt(m.paid_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.paid_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const rowsProjExpense = [
|
|
||||||
["年度累计", moneyInt(m.proj_expense_annual || 0)],
|
|
||||||
["本季度累计", moneyInt(m.proj_expense_q2 || 0)],
|
|
||||||
["本月累计", moneyInt(m.proj_expense_month || 0)],
|
|
||||||
["上本季度累计", moneyInt(m.proj_expense_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.proj_expense_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const rows8 = [
|
|
||||||
["年度累计", moneyInt(m.cashflow_annual || 0)],
|
|
||||||
["本季度累计", moneyInt(m.cashflow_q2 || 0)],
|
|
||||||
["本月累计", moneyInt(m.cashflow_month || 0)],
|
|
||||||
["上本季度累计", moneyInt(m.cashflow_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.cashflow_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const rows9 = [
|
|
||||||
["年度累计", moneyInt(m.profit_annual || 0)],
|
|
||||||
["本季度累计", moneyInt(m.profit_q2 || 0)],
|
|
||||||
["本月累计", moneyInt(m.profit_month || 0)],
|
|
||||||
["上本季度累计", moneyInt(m.profit_prev_q || 0)],
|
|
||||||
["上月累计", moneyInt(m.profit_prev_month || 0)],
|
|
||||||
];
|
|
||||||
const qoq = (cur, prev) => {
|
|
||||||
if (!prev) return '<span class="text-slate-300">—</span>';
|
|
||||||
const pct = Math.round((cur - prev) / prev * 100);
|
|
||||||
const cls = pct >= 0 ? 'text-green-600' : 'text-red-600';
|
|
||||||
const sign = pct >= 0 ? '+' : '';
|
|
||||||
return '<span class="' + cls + '">' + sign + pct + '%</span>';
|
|
||||||
};
|
|
||||||
const COLORS = ['text-slate-700','text-blue-600','text-green-600','text-rose-600','text-indigo-600','text-amber-600','text-purple-600','text-cyan-600'];
|
|
||||||
const LABELS = ['合同金额','确收金额','确收毛利','成本','项目利润','回款金额','已付','现金流'];
|
|
||||||
const Q_FIELDS = [m.signed_q2||0, m.revenue_q2, m.gross_q2, m.cost_q2||0, m.profit_q2||0, m.payment_q2||0, m.paid_q2||0, m.cashflow_q2||0];
|
|
||||||
const Q_PREV = [m.signed_prev_q||0, m.revenue_prev_q||0, m.gross_prev_q||0, m.cost_prev_q||0, m.profit_prev_q||0, m.payment_prev_q||0, m.paid_prev_q||0, m.cashflow_prev_q||0];
|
|
||||||
const M_FIELDS = [m.signed_month||0, m.monthly_revenue, m.monthly_net_profit, m.cost_month||0, m.profit_month||0, m.payment_month||0, m.paid_month||0, m.cashflow_month||0];
|
|
||||||
const M_PREV = [m.signed_prev_month||0, m.revenue_prev_month||0, m.gross_prev_month||0, m.cost_prev_month||0, m.profit_prev_month||0, m.payment_prev_month||0, m.paid_prev_month||0, m.cashflow_prev_month||0];
|
|
||||||
const ROWS = [rows1, rows2, rows3, rows5, rows9, rows4, rows7, rows8];
|
|
||||||
// 现金流和项目利润的原始值数组,用于正负着色
|
|
||||||
const CF_RAW = [m.cashflow_annual||0, m.cashflow_q2||0, m.cashflow_month||0, m.cashflow_prev_q||0, m.cashflow_prev_month||0];
|
|
||||||
const PF_RAW = [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0];
|
|
||||||
var valCls = function(ri) { return (ri === 4 || ri === 7) ? '' : 'text-slate-800'; };
|
|
||||||
var tdVal = function(ri, col, val) {
|
|
||||||
if (ri === 4) return '<td class="py-2 text-right font-semibold ' + (PF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
|
|
||||||
if (ri === 7) return '<td class="py-2 text-right font-semibold ' + (CF_RAW[col] >= 0 ? 'text-green-600' : 'text-red-600') + '">' + val + '</td>';
|
|
||||||
return '<td class="py-2 text-right font-semibold text-slate-800">' + val + '</td>';
|
|
||||||
};
|
|
||||||
var thead = '<tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500">指标</th><th class="py-2 text-right font-semibold text-slate-700">年度累计</th><th class="py-2 text-right font-semibold text-slate-700">上季度累计</th><th class="py-2 text-right font-semibold text-slate-700">上月累计</th><th class="py-2 text-right font-semibold text-slate-700">本季度累计</th><th class="py-2 text-right font-semibold text-slate-400">季环比</th><th class="py-2 text-right font-semibold text-slate-700">本月累计</th><th class="py-2 text-right font-semibold text-slate-400">月环比</th></tr>';
|
|
||||||
var tbody = '';
|
|
||||||
for (var ri = 0; ri < 8; ri++) {
|
|
||||||
var vals = ROWS[ri];
|
|
||||||
tbody += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + LABELS[ri] + '</td>' +
|
|
||||||
tdVal(ri, 0, vals[0][1]) +
|
|
||||||
tdVal(ri, 3, vals[3][1]) +
|
|
||||||
tdVal(ri, 4, vals[4][1]) +
|
|
||||||
tdVal(ri, 1, vals[1][1]) +
|
|
||||||
'<td class="py-2 text-right">' + qoq(Q_FIELDS[ri], Q_PREV[ri]) + '</td>' +
|
|
||||||
tdVal(ri, 2, vals[2][1]) +
|
|
||||||
'<td class="py-2 text-right">' + qoq(M_FIELDS[ri], M_PREV[ri]) + '</td></tr>';
|
|
||||||
}
|
|
||||||
document.querySelector("#home").innerHTML = `
|
|
||||||
<div class="grid gap-5">
|
|
||||||
${`<div class="grid grid-cols-4 gap-3">
|
|
||||||
${[
|
|
||||||
["项目管理", m.total_projects, "finance"],
|
|
||||||
["重点工作与台账", m.total_proposals, "projects"],
|
|
||||||
["业务方案", m.total_products, "proposals"],
|
|
||||||
["产品迭代", m.upcoming_products, "products"],
|
|
||||||
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
|
|
||||||
</div>`}
|
|
||||||
${payReminder}
|
|
||||||
${(() => {
|
|
||||||
var d = [
|
|
||||||
['项目毛利', [m.profit_annual||0, m.profit_q2||0, m.profit_month||0, m.profit_prev_q||0, m.profit_prev_month||0],
|
|
||||||
[m.profit_q2||0,m.profit_prev_q||0], [m.profit_month||0,m.profit_prev_month||0]],
|
|
||||||
['部门费用', [m.expense_annual||0, m.expense_q2||0, (m.expense_month||0), m.expense_prev_q||0, m.expense_prev_month||0],
|
|
||||||
[m.expense_q2||0,m.expense_prev_q||0], [(m.expense_month||0),m.expense_prev_month||0]],
|
|
||||||
['部门净利', [
|
|
||||||
(m.profit_annual||0)-(m.expense_annual||0), (m.profit_q2||0)-(m.expense_q2||0), (m.profit_month||0)-(m.expense_month||0),
|
|
||||||
(m.profit_prev_q||0)-(m.expense_prev_q||0), (m.profit_prev_month||0)-(m.expense_prev_month||0)
|
|
||||||
], [
|
|
||||||
(m.profit_q2||0)-(m.expense_q2||0), (m.profit_prev_q||0)-(m.expense_prev_q||0)
|
|
||||||
], [
|
|
||||||
(m.profit_month||0)-(m.expense_month||0), (m.profit_prev_month||0)-(m.expense_prev_month||0)
|
|
||||||
]],
|
|
||||||
['项目现金流', [m.cashflow_annual||0, m.cashflow_q2||0, m.cashflow_month||0, m.cashflow_prev_q||0, m.cashflow_prev_month||0],
|
|
||||||
[m.cashflow_q2||0,m.cashflow_prev_q||0], [m.cashflow_month||0,m.cashflow_prev_month||0]],
|
|
||||||
['部门费用流出', [m.expense_paid_annual||0, m.expense_paid_q2||0, m.expense_paid_month||0, m.expense_paid_prev_q||0, m.expense_paid_prev_month||0],
|
|
||||||
[m.expense_paid_q2||0,m.expense_paid_prev_q||0], [m.expense_paid_month||0,m.expense_paid_prev_month||0]],
|
|
||||||
['部门现金流', [m.dept_cf_annual||0, m.dept_cf_q2||0, m.dept_cf_month||0, m.dept_cf_prev_q||0, m.dept_cf_prev_month||0],
|
|
||||||
[m.dept_cf_q2||0,m.dept_cf_prev_q||0], [m.dept_cf_month||0,m.dept_cf_prev_month||0]]
|
|
||||||
];
|
|
||||||
var buildDeptTable = function(title, dataRows) {
|
|
||||||
var h = '';
|
|
||||||
for (var di = 0; di < dataRows.length; di++) {
|
|
||||||
var r = dataRows[di];
|
|
||||||
var rawNet = r[1][0];
|
|
||||||
var isNet = r[0] === '部门净利' || r[0] === '部门现金流';
|
|
||||||
var isCF = r[0] === '项目现金流';
|
|
||||||
var cls = isNet ? (rawNet >= 0 ? 'text-green-600' : 'text-red-600') : isCF ? (rawNet >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-800';
|
|
||||||
h += '<tr class="border-b border-slate-100 last:border-0"><td class="py-2 text-left font-semibold text-slate-700">' + r[0] + '</td>' +
|
|
||||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][0]) + '</td>' +
|
|
||||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][3]) + '</td>' +
|
|
||||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][4]) + '</td>' +
|
|
||||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][1]) + '</td>' +
|
|
||||||
'<td class="py-2 text-right">' + qoq(r[2][0], r[2][1]) + '</td>' +
|
|
||||||
'<td class="py-2 text-right font-semibold ' + cls + '">' + moneyInt(r[1][2]) + '</td>' +
|
|
||||||
'<td class="py-2 text-right">' + qoq(r[3][0], r[3][1]) + '</td></tr>';
|
|
||||||
}
|
|
||||||
return card('<h3 class="text-sm font-bold text-slate-700 mb-3">' + title + '</h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="border-b border-slate-200"><th class="py-2 text-left text-slate-500">指标</th><th class="py-2 text-right font-semibold text-slate-700">年度累计</th><th class="py-2 text-right font-semibold text-slate-700">上季度累计</th><th class="py-2 text-right font-semibold text-slate-700">上月累计</th><th class="py-2 text-right font-semibold text-slate-700">本季度累计</th><th class="py-2 text-right font-semibold text-slate-400">季环比</th><th class="py-2 text-right font-semibold text-slate-700">本月累计</th><th class="py-2 text-right font-semibold text-slate-400">月环比</th></tr></thead><tbody>' + h + '</tbody></table></div>', 'p-4');
|
|
||||||
};
|
|
||||||
return buildDeptTable('部门经营情况', d.slice(0, 3)) + buildDeptTable('部门现金流', d.slice(3, 6));
|
|
||||||
})()}
|
|
||||||
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex items-start justify-between rounded-md bg-slate-50 px-3 py-2 text-sm group"><span class="break-words">${r.content}</span><div class="flex items-center gap-2 flex-shrink-0 ml-2"><span class="text-xs text-slate-400">${r.followed_at}</span><button class="btn btn-ghost btn-sm text-red-400 opacity-0 group-hover:opacity-100 p-0 w-5 h-5" onclick="event.preventDefault();deleteActivity(${r.id})" title="删除动态"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></div></div>`).join("")}</div>`, "p-5")}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chartOptions(yCallback) {
|
|
||||||
return {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } },
|
|
||||||
scales: {
|
|
||||||
x: { ticks: { font: { size: 10 } }, grid: { display: false } },
|
|
||||||
y: { ticks: { font: { size: 11 }, callback: yCallback } },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v;
|
|
||||||
const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月");
|
|
||||||
|
|
||||||
function renderCharts(data) {
|
|
||||||
const labels = monthLabels(data);
|
|
||||||
const baseOpts = chartOptions(moneyTick);
|
|
||||||
|
|
||||||
// 图1:月度签约
|
|
||||||
const c1 = document.querySelector("#chartSign");
|
|
||||||
if (c1 && window.Chart) {
|
|
||||||
if (state.chart) state.chart.destroy();
|
|
||||||
state.chart = new Chart(c1, {
|
|
||||||
type: "line",
|
|
||||||
data: { labels, datasets: [
|
|
||||||
{ label: "签约金额", data: data.map((x) => x.sign || 0), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 },
|
|
||||||
]},
|
|
||||||
options: baseOpts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图2:月度确收与毛利
|
|
||||||
const c2 = document.querySelector("#chartRev");
|
|
||||||
if (c2 && window.Chart) {
|
|
||||||
if (state.chart2) state.chart2.destroy();
|
|
||||||
state.chart2 = new Chart(c2, {
|
|
||||||
type: "line",
|
|
||||||
data: { labels, datasets: [
|
|
||||||
{ label: "确收", data: data.map((x) => x.revenue || 0), borderColor: "#2563eb", backgroundColor: "rgba(37,99,235,0.06)", fill: true, tension: 0.3 },
|
|
||||||
{ label: "毛利", data: data.map((x) => x.gross || 0), borderColor: "#059669", backgroundColor: "rgba(5,150,105,0.06)", fill: true, tension: 0.3 },
|
|
||||||
]},
|
|
||||||
options: baseOpts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图3:月度回款与已付
|
|
||||||
const c3 = document.querySelector("#chartCash");
|
|
||||||
if (c3 && window.Chart) {
|
|
||||||
if (state.chart3) state.chart3.destroy();
|
|
||||||
state.chart3 = new Chart(c3, {
|
|
||||||
type: "line",
|
|
||||||
data: { labels, datasets: [
|
|
||||||
{ label: "回款", data: data.map((x) => x.payment || 0), borderColor: "#d97706", backgroundColor: "rgba(217,119,6,0.06)", fill: true, tension: 0.3 },
|
|
||||||
{ label: "已付", data: data.map((x) => x.cost || 0), borderColor: "#7c3aed", backgroundColor: "rgba(124,58,237,0.06)", fill: true, tension: 0.3 },
|
|
||||||
]},
|
|
||||||
options: baseOpts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图4:月度项目利润
|
|
||||||
const c4 = document.querySelector("#chartProfit");
|
|
||||||
if (c4 && window.Chart) {
|
|
||||||
if (state.chart4) state.chart4.destroy();
|
|
||||||
state.chart4 = new Chart(c4, {
|
|
||||||
type: "line",
|
|
||||||
data: { labels, datasets: [
|
|
||||||
{ label: "利润", data: data.map((x) => (x.gross || 0)), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 },
|
|
||||||
]},
|
|
||||||
options: baseOpts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@ function renderFinanceProjectMatrixData(data, container) {
|
|||||||
th += '</tr>';
|
th += '</tr>';
|
||||||
|
|
||||||
var tb = financeProjectMatrixTbody(data, tenants, mi, keys, labels, mf);
|
var tb = financeProjectMatrixTbody(data, tenants, mi, keys, labels, mf);
|
||||||
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-blue-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>';
|
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>';
|
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();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -210,7 +210,7 @@ function renderFinanceDeptMatrixData(data, container) {
|
|||||||
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>'; }
|
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>';
|
th += '</tr>';
|
||||||
var tb = financeMatrixTbody(data, tenants, mi, ['project_gross','dept_expense','profit'], ['项目毛利','部门费用','利润'], mf);
|
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-blue-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>';
|
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>';
|
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();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -239,7 +239,7 @@ function renderFinanceCfMatrixData(data, container) {
|
|||||||
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>'; }
|
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>';
|
th += '</tr>';
|
||||||
var tb = financeMatrixTbody(data, tenants, mi, ['project_cf','dept_cf','bl_cf'], ['项目现金流','部门现金流','经营现金流'], mf);
|
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-blue-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>';
|
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>';
|
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();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function renderProjectMatrixData(data, container) {
|
|||||||
th += '</tr>';
|
th += '</tr>';
|
||||||
|
|
||||||
var tb = projectMatrixTbody(data, tenants, mi, keys, labels, mf);
|
var tb = projectMatrixTbody(data, tenants, mi, keys, labels, mf);
|
||||||
cards += '<div class="flex items-center gap-3"><div class="text-5xl font-bold text-blue-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>';
|
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>';
|
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();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -213,7 +213,7 @@ function renderDeptMatrixData(data, container) {
|
|||||||
th += '</tr>';
|
th += '</tr>';
|
||||||
|
|
||||||
var tb = matrixTbody(data, tenants, mi, ['project_gross','dept_expense','profit'], ['项目毛利','部门费用','利润'], mf);
|
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-blue-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>';
|
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>';
|
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();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -248,7 +248,7 @@ function renderCfMatrixData(data, container) {
|
|||||||
th += '</tr>';
|
th += '</tr>';
|
||||||
|
|
||||||
var tb = matrixTbody(data, tenants, mi, ['project_cf','dept_cf','bl_cf'], ['项目现金流','部门现金流','经营现金流'], mf);
|
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-blue-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>';
|
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>';
|
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();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ window.renderPerformance = () => {
|
|||||||
totalScore += score;
|
totalScore += score;
|
||||||
|
|
||||||
return `<tr class="border-b border-slate-100">
|
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">
|
<td class="p-3 align-middle text-center">
|
||||||
<div class="inline-flex items-center gap-1">
|
<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)">
|
<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)">
|
<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>
|
</div>
|
||||||
</td>
|
</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>`;
|
</tr>`;
|
||||||
}).join('');
|
}).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-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-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>
|
</tr></tfoot>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function renderPlan() {
|
|||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
const rev = b.rev || 0;
|
const rev = b.rev || 0;
|
||||||
const gross = b.gross || 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 {
|
} else {
|
||||||
const payment = b.payment || 0;
|
const payment = b.payment || 0;
|
||||||
const cost = b.cost || 0;
|
const cost = b.cost || 0;
|
||||||
@@ -87,7 +87,7 @@ function renderPlan() {
|
|||||||
if (isRevView) {
|
if (isRevView) {
|
||||||
const totalRev = pf.total_rev || 0;
|
const totalRev = pf.total_rev || 0;
|
||||||
const totalGross = pf.total_gross || 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 {
|
} else {
|
||||||
let totalPayment = 0, totalCost = 0;
|
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) {}
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => { totalPayment += parseFloat(b.payment||0)||0; totalCost += parseFloat(b.cost||0)||0; }); } catch (e) {}
|
||||||
@@ -95,21 +95,23 @@ function renderPlan() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const sm = pf.sign_month || "";
|
const sm = pf.sign_month || "";
|
||||||
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
|
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-brand-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); 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 truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td><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>`;
|
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 now2 = new Date();
|
||||||
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
|
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
|
||||||
if (!state.planMonth) state.planMonth = defaultMonth2;
|
if (!state.planMonth) state.planMonth = defaultMonth2;
|
||||||
if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3);
|
if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3);
|
||||||
|
if (state.planYear == null) state.planYear = now2.getFullYear();
|
||||||
|
if (!['annual','quarterly','monthly'].includes(state.planView)) state.planView = 'annual';
|
||||||
const qLabels = ['Q1','Q2','Q3','Q4'];
|
const qLabels = ['Q1','Q2','Q3','Q4'];
|
||||||
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
|
||||||
const nowD = new Date();
|
const nowD = new Date();
|
||||||
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
|
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
|
||||||
const activeMonths = qMonths[activeQ];
|
const activeMonths = qMonths[activeQ];
|
||||||
const yearOpts = [2024,2025,2026,2027];
|
const yearOpts = [2020,2021,2022,2023,2024,2025,2026,2027];
|
||||||
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setPlanStatusFilter('all')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planStatusFilter==='all'?'bg-blue-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-blue-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-blue-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-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"><span class="text-sm text-slate-500">年份:</span><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.planYear===0?' selected':''}>所有年份</option>${yearOpts.map(y => `<option value="${y}" ${state.planYear===y?'selected':''}>${y}年</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><button onclick="setPlanView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='annual'?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">全年</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setPlanQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='quarterly'&&activeQ===i?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">月度:</span>${activeMonths.map(m => { const ms = `${state.planYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setPlanMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='monthly'&&state.planMonth===ms?'bg-brand-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
|
||||||
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
|
||||||
|
|
||||||
const isOverviewMode = false;
|
const isOverviewMode = false;
|
||||||
@@ -155,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="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>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<div id="plan_financeTabExec" class="hidden">
|
<div id="plan_financeTabExec" class="hidden">
|
||||||
@@ -171,9 +173,9 @@ function renderPlan() {
|
|||||||
</div>
|
</div>
|
||||||
<div id="plan_financeTabRevpay" class="hidden">
|
<div id="plan_financeTabRevpay" class="hidden">
|
||||||
<div class="grid grid-cols-3 gap-3 mb-4" id="plan_revpaySummary">
|
<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">
|
<div class="bg-brand-50 rounded-lg p-3 text-center border border-brand-100">
|
||||||
<p class="text-xs text-blue-600 font-medium">总确收</p>
|
<p class="text-xs text-brand-600 font-medium">总确收</p>
|
||||||
<p class="text-lg font-bold text-blue-700" id="plan_revpayTotalRev">¥0</p>
|
<p class="text-lg font-bold text-brand-700" id="plan_revpayTotalRev">¥0</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-green-50 rounded-lg p-3 text-center border border-green-100">
|
<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>
|
<p class="text-xs text-green-600 font-medium">总毛利</p>
|
||||||
@@ -286,7 +288,6 @@ function renderPlan() {
|
|||||||
};
|
};
|
||||||
const SIGNED_COLS = [
|
const SIGNED_COLS = [
|
||||||
{ key: '_expand', label: '' },
|
{ key: '_expand', label: '' },
|
||||||
{ key: 'client_name', label: '客户' },
|
|
||||||
{ key: 'customer_name', label: '项目名称' },
|
{ key: 'customer_name', label: '项目名称' },
|
||||||
{ key: 'weight', label: '加权' },
|
{ key: 'weight', label: '加权' },
|
||||||
{ key: 'project_type', label: '项目类型' },
|
{ key: 'project_type', label: '项目类型' },
|
||||||
@@ -297,19 +298,19 @@ function renderPlan() {
|
|||||||
{ key: 'payment', label: '回款' },
|
{ key: 'payment', label: '回款' },
|
||||||
{ key: 'pending', label: '应收' },
|
{ key: 'pending', label: '应收' },
|
||||||
{ key: 'pay_rate', label: '回款率' },
|
{ key: 'pay_rate', label: '回款率' },
|
||||||
{ key: 'cost', label: '当期成本' },
|
{ key: 'cost', label: '成本' },
|
||||||
{ key: 'paid', label: '当期流出' },
|
{ key: 'paid', label: '支出' },
|
||||||
|
{ key: 'payable', label: '应付' },
|
||||||
{ key: 'cashflow', label: '现金流' },
|
{ key: 'cashflow', label: '现金流' },
|
||||||
];
|
];
|
||||||
const UNSIGNED_COLS = [
|
const UNSIGNED_COLS = [
|
||||||
{ key: 'client_name', label: '客户' },
|
|
||||||
{ key: 'customer_name', label: '项目名称' },
|
{ key: 'customer_name', label: '项目名称' },
|
||||||
{ key: 'weight', label: '加权' },
|
{ key: 'weight', label: '加权' },
|
||||||
{ key: 'project_type', label: '项目类型' },
|
{ key: 'project_type', label: '项目类型' },
|
||||||
{ key: 'sign_amount', label: '签约金额' },
|
{ key: 'sign_amount', label: '签约金额' },
|
||||||
{ key: 'sign_month', label: '签约月份' },
|
{ key: 'sign_month', label: '签约月份' },
|
||||||
{ key: 'gross', label: '毛利' },
|
{ key: 'gross', label: '毛利' },
|
||||||
{ key: 'cost', label: '当期成本' },
|
{ key: 'cost', label: '成本' },
|
||||||
{ key: 'profit', label: '项目利润' },
|
{ key: 'profit', label: '项目利润' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -330,10 +331,10 @@ function renderPlan() {
|
|||||||
['应付', m(payable), '成本 − 支出'],
|
['应付', m(payable), '成本 − 支出'],
|
||||||
['往来余额', m(netAsset), '应收 − 应付'],
|
['往来余额', m(netAsset), '应收 − 应付'],
|
||||||
];
|
];
|
||||||
const cardHtml = (r, i) => '<div class="card px-2 py-1.5 text-center flex-1" style="background:' + (['#eff6ff','#eff6ff','#eff6ff','#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 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 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 thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
|
||||||
const theadCols = isUnsigned ? 9 : 15;
|
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 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
|
const subtitle = isUnsigned
|
||||||
? '合同 → 毛利 → 成本 → 项目利润'
|
? '合同 → 毛利 → 成本 → 项目利润'
|
||||||
@@ -367,8 +368,11 @@ function renderPlan() {
|
|||||||
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
|
||||||
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
|
||||||
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
|
const payable = cost - paid;
|
||||||
|
const payableCls = payable > 0 ? 'text-green-600' : payable < 0 ? 'text-red-600' : 'text-slate-400';
|
||||||
|
const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>';
|
||||||
const pending = rev - payment;
|
const pending = rev - payment;
|
||||||
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700';
|
const pendingCls = pending > 0 ? 'text-green-600' : pending < 0 ? 'text-red-600' : 'text-slate-700';
|
||||||
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
|
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
|
||||||
var budgetArr = [];
|
var budgetArr = [];
|
||||||
var expenseArr = [];
|
var expenseArr = [];
|
||||||
@@ -390,16 +394,16 @@ function renderPlan() {
|
|||||||
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
|
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 eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
|
||||||
var bCf = bPay - ePaid;
|
var bCf = bPay - ePaid;
|
||||||
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-600">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
|
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('');
|
}).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 truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" 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 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 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>`;
|
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 tdRowUnsigned = (pf, cost, gross) => {
|
||||||
const profit = gross;
|
const profit = gross;
|
||||||
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
|
||||||
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
|
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 truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" 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>`;
|
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') {
|
if (state.planView === 'monthly') {
|
||||||
@@ -459,12 +463,12 @@ function renderPlan() {
|
|||||||
const qRange = qRanges[selQ];
|
const qRange = qRanges[selQ];
|
||||||
const sumBudget = (pf, field) => {
|
const sumBudget = (pf, field) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.planYear) total += parseFloat(b[field] || 0); }); } catch (e) {}
|
try { JSON.parse(pf.budget_data || "[]").forEach(b => { const m = parseInt((b.month || "").substring(5)) || 0; const y = parseInt((b.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && (y === state.planYear || state.planYear === 0)) total += parseFloat(b[field] || 0); }); } catch (e) {}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
const sumExpense = (pf, field) => {
|
const sumExpense = (pf, field) => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && y === state.planYear) total += parseFloat(e[field] || 0); }); } catch (e) {}
|
try { JSON.parse(pf.expense_data || "[]").forEach(e => { const m = parseInt((e.month || "").substring(5)) || 0; const y = parseInt((e.month || "").substring(0, 4)) || 0; if (qRange.includes(m) && (y === state.planYear || state.planYear === 0)) total += parseFloat(e[field] || 0); }); } catch (e) {}
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
let dataItems = [];
|
let dataItems = [];
|
||||||
@@ -641,13 +645,12 @@ window.planInitCostTable = (expenseData) => {
|
|||||||
// ---------- 任务管理 tab ----------
|
// ---------- 任务管理 tab ----------
|
||||||
|
|
||||||
function planTaskMonthOptions(selected) {
|
function planTaskMonthOptions(selected) {
|
||||||
const now = new Date();
|
|
||||||
const opts = [];
|
const opts = [];
|
||||||
for (let i = -2; i <= 12; i++) {
|
for (let y = 2020; y <= 2027; y++)
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
for (let m = 1; m <= 12; m++) {
|
||||||
const v = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
|
const v = y + "-" + String(m).padStart(2, "0");
|
||||||
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
|
||||||
}
|
}
|
||||||
return opts.join("");
|
return opts.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,9 +707,9 @@ function planUpdateRowCalc(row) {
|
|||||||
window.planToggleBtChip = (chip) => {
|
window.planToggleBtChip = (chip) => {
|
||||||
const cb = chip.querySelector('input');
|
const cb = chip.querySelector('input');
|
||||||
cb.checked = !cb.checked;
|
cb.checked = !cb.checked;
|
||||||
chip.classList.toggle('bg-blue-50', cb.checked);
|
chip.classList.toggle('bg-brand-50', cb.checked);
|
||||||
chip.classList.toggle('border-blue-400', cb.checked);
|
chip.classList.toggle('border-brand-400', cb.checked);
|
||||||
chip.classList.toggle('text-blue-600', cb.checked);
|
chip.classList.toggle('text-brand-600', cb.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.planInitTaskTable = (taskData) => {
|
window.planInitTaskTable = (taskData) => {
|
||||||
@@ -753,11 +756,11 @@ window.planEditSignMonth = (event, pfId) => {
|
|||||||
try {
|
try {
|
||||||
await api(`/api/planFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
|
await api(`/api/planFinances/${pfId}`, { method: "PUT", body: JSON.stringify({ data: { sign_month: newValue } }) });
|
||||||
pf.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"); }
|
} catch (e) { toast("修改失败:" + e.message, "error"); }
|
||||||
});
|
});
|
||||||
select.addEventListener("blur", () => {
|
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.innerHTML = "";
|
||||||
td.appendChild(select);
|
td.appendChild(select);
|
||||||
@@ -784,7 +787,7 @@ async function planLoadFollowups(pfId) {
|
|||||||
try {
|
try {
|
||||||
const fups = await api(`/api/followups/project_finance/${pfId}`);
|
const fups = await api(`/api/followups/project_finance/${pfId}`);
|
||||||
list.innerHTML = fups.length
|
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("")
|
? 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>';
|
: '<p class="text-sm text-slate-700 py-4 text-center">暂无跟进记录</p>';
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
list.querySelectorAll(".rich-content").forEach(el => {
|
list.querySelectorAll(".rich-content").forEach(el => {
|
||||||
@@ -857,9 +860,9 @@ window.planOpenPfEditModal = (pfId) => {
|
|||||||
cb.checked = btValues.includes(cb.value);
|
cb.checked = btValues.includes(cb.value);
|
||||||
const chip = cb.closest('.bt-chip');
|
const chip = cb.closest('.bt-chip');
|
||||||
if (chip) {
|
if (chip) {
|
||||||
chip.classList.toggle('bg-blue-50', cb.checked);
|
chip.classList.toggle('bg-brand-50', cb.checked);
|
||||||
chip.classList.toggle('border-blue-400', cb.checked);
|
chip.classList.toggle('border-brand-400', cb.checked);
|
||||||
chip.classList.toggle('text-blue-600', cb.checked);
|
chip.classList.toggle('text-brand-600', cb.checked);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
|
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
|
||||||
@@ -1006,7 +1009,8 @@ window.createPlanFinance = async (event) => {
|
|||||||
document.querySelector("#planModalTitle").textContent = "新增项目财务";
|
document.querySelector("#planModalTitle").textContent = "新增项目财务";
|
||||||
closePlanModal();
|
closePlanModal();
|
||||||
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
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) {
|
} catch (error) {
|
||||||
toast("刷新失败:" + error.message, "error");
|
toast("刷新失败:" + error.message, "error");
|
||||||
}
|
}
|
||||||
@@ -1022,7 +1026,8 @@ window.deletePlanItem = async () => {
|
|||||||
await api(`/api/planFinances/${pfId}`, { method: "DELETE" });
|
await api(`/api/planFinances/${pfId}`, { method: "DELETE" });
|
||||||
closePlanModal();
|
closePlanModal();
|
||||||
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
|
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");
|
toast("已删除", "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("删除失败:" + error.message, "error");
|
toast("删除失败:" + error.message, "error");
|
||||||
@@ -1031,10 +1036,10 @@ window.deletePlanItem = async () => {
|
|||||||
|
|
||||||
|
|
||||||
// 从 planFinances 计算月度数据
|
// 从 planFinances 计算月度数据
|
||||||
function computePlanMonthly(planFinances) {
|
function computePlanMonthly(planFinances, year) {
|
||||||
var pfs = planFinances || [];
|
var pfs = planFinances || [];
|
||||||
var months = [];
|
var months = [];
|
||||||
var year = new Date().getFullYear();
|
year = year || new Date().getFullYear();
|
||||||
for (var mi = 1; mi <= 12; mi++) { months.push(year + '-' + String(mi).padStart(2, '0')); }
|
for (var mi = 1; mi <= 12; mi++) { months.push(year + '-' + String(mi).padStart(2, '0')); }
|
||||||
var data = [];
|
var data = [];
|
||||||
for (var i = 0; i < 12; i++) {
|
for (var i = 0; i < 12; i++) {
|
||||||
@@ -1057,7 +1062,7 @@ function computePlanMonthly(planFinances) {
|
|||||||
// 总览渲染
|
// 总览渲染
|
||||||
function renderPlanOverview() {
|
function renderPlanOverview() {
|
||||||
const planFinances = state.data.planFinances || [];
|
const planFinances = state.data.planFinances || [];
|
||||||
const fm = computePlanMonthly(planFinances);
|
const fm = computePlanMonthly(planFinances, state.planYear);
|
||||||
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
||||||
const moneyWan = (v) => {
|
const moneyWan = (v) => {
|
||||||
const n = Number(v || 0);
|
const n = Number(v || 0);
|
||||||
@@ -1108,6 +1113,8 @@ function renderPlanOverview() {
|
|||||||
for (var ei = 0; ei < planExp.length; ei++) {
|
for (var ei = 0; ei < planExp.length; ei++) {
|
||||||
var em = (planExp[ei].expense_month || '').substring(0, 7);
|
var em = (planExp[ei].expense_month || '').substring(0, 7);
|
||||||
var mi = parseInt(em.substring(5)) - 1;
|
var mi = parseInt(em.substring(5)) - 1;
|
||||||
|
var emYear = parseInt(em.substring(0, 4)) || 0;
|
||||||
|
if (state.planYear !== 0 && emYear && emYear !== state.planYear) continue;
|
||||||
if (mi >= 0 && mi < 12) expMonthly[mi] += parseFloat(planExp[ei].amount || 0);
|
if (mi >= 0 && mi < 12) expMonthly[mi] += parseFloat(planExp[ei].amount || 0);
|
||||||
}
|
}
|
||||||
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
|
var expAnnual = 0; for (var ei = 0; ei < 12; ei++) expAnnual += expMonthly[ei];
|
||||||
@@ -1144,6 +1151,8 @@ function renderPlanOverview() {
|
|||||||
for (var epi = 0; epi < planExp.length; epi++) {
|
for (var epi = 0; epi < planExp.length; epi++) {
|
||||||
var epm = (planExp[epi].expense_month || '').substring(0, 7);
|
var epm = (planExp[epi].expense_month || '').substring(0, 7);
|
||||||
var epmi = parseInt(epm.substring(5)) - 1;
|
var epmi = parseInt(epm.substring(5)) - 1;
|
||||||
|
var epmYear = parseInt(epm.substring(0, 4)) || 0;
|
||||||
|
if (state.planYear !== 0 && epmYear && epmYear !== state.planYear) continue;
|
||||||
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(planExp[epi].incurred_amount || 0);
|
if (epmi >= 0 && epmi < 12) expPaidMonthly[epmi] += parseFloat(planExp[epi].incurred_amount || 0);
|
||||||
}
|
}
|
||||||
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
|
var expPaidAnnual = 0; for (var epi = 0; epi < 12; epi++) expPaidAnnual += expPaidMonthly[epi];
|
||||||
@@ -1170,14 +1179,15 @@ function renderPlanOverview() {
|
|||||||
}
|
}
|
||||||
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
|
||||||
|
|
||||||
document.querySelector("#planOverview").innerHTML = '<div class="grid gap-2.5">' +
|
const yearSelectOv = `<div class="card px-4 py-2 mb-4"><div class="flex items-center gap-2"><span class="text-sm font-medium text-slate-500">📅 选择年份:</span><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.planYear===0?' selected':''}>所有年份</option>${[2020,2021,2022,2023,2024,2025,2026,2027].map(y => '<option value="'+y+'"'+(state.planYear===y?' selected':'')+'>'+y+'年</option>').join('')}</select></div></div>`;
|
||||||
|
document.querySelector("#planOverview").innerHTML = yearSelectOv + '<div class="grid gap-2.5">' +
|
||||||
deptCard +
|
deptCard +
|
||||||
cfCard +
|
cfCard +
|
||||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,成本,支出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
'<div class="grid grid-cols-3 gap-2.5">' +
|
'<div class="grid grid-cols-3 gap-2.5">' +
|
||||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') +
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-600">' + (state.planYear===0 ? '所有年份' : state.planYear) + '</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') +
|
||||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">' + (state.planYear===0 ? '所有年份' : state.planYear) + '</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +
|
||||||
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
|
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-600">' + (state.planYear===0 ? '所有年份' : state.planYear) + '</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
@@ -1190,7 +1200,7 @@ function renderPlanOverview() {
|
|||||||
|
|
||||||
function renderPlanQuarterlyOverview() {
|
function renderPlanQuarterlyOverview() {
|
||||||
const planFinances = state.data.planFinances || [];
|
const planFinances = state.data.planFinances || [];
|
||||||
const fm = computePlanMonthly(planFinances);
|
const fm = computePlanMonthly(planFinances, state.planYear);
|
||||||
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
const moneyIntL = (v) => Math.round(Number(v || 0)).toLocaleString("zh-CN");
|
||||||
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
const card = (body, cls) => '<div class="card ' + (cls || 'p-4') + '">' + body + '</div>';
|
||||||
var annualSums2 = { sign: 0, revenue: 0, gross: 0, cost: 0, payment: 0, paid: 0 };
|
var annualSums2 = { sign: 0, revenue: 0, gross: 0, cost: 0, payment: 0, paid: 0 };
|
||||||
@@ -1307,10 +1317,11 @@ function renderPlanQuarterlyOverview() {
|
|||||||
cfTbody += rowHtml + '</tr>';
|
cfTbody += rowHtml + '</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#planQuarterlyOverview").innerHTML = '<div class="grid gap-2.5">' +
|
const yearSelectQ = `<div class="card px-4 py-2 mb-4"><div class="flex items-center gap-2"><span class="text-sm font-medium text-slate-500">📅 选择年份:</span><select onchange="setPlanYear(this.value)" class="px-2 py-0.5 pr-6 rounded text-sm font-medium border border-slate-200 bg-white"><option value="0"${state.planYear===0?' selected':''}>所有年份</option>${[2020,2021,2022,2023,2024,2025,2026,2027].map(y => '<option value="'+y+'"'+(state.planYear===y?' selected':'')+'>'+y+'年</option>').join('')}</select></div></div>`;
|
||||||
|
document.querySelector("#planQuarterlyOverview").innerHTML = yearSelectQ + '<div class="grid gap-2.5">' +
|
||||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门损益情况 <span class="text-sm text-slate-500 font-normal ml-3">部门利润 = 所有项目产生的毛利 - 部门 1 级产生的人力成本,研发成本,差旅和其他支出</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + deptTbody + '</tbody></table></div>', 'p-4') +
|
||||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4') +
|
||||||
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,当期成本,当期流出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
card('<h3 class="text-base font-bold text-slate-700 mb-3">项目经营详情 <span class="text-sm text-slate-500 font-normal ml-3">所有项目产生的签单,确收,毛利,回款,成本,支出,现金流</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + thead + '</thead><tbody>' + tbody + '</tbody></table></div>', 'p-4') +
|
||||||
'</div>';
|
'</div>';
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
@@ -1329,7 +1340,7 @@ function renderPlanCharts(data) {
|
|||||||
var c2 = document.querySelector("#planChartRev");
|
var c2 = document.querySelector("#planChartRev");
|
||||||
if (c2 && window.Chart) {
|
if (c2 && window.Chart) {
|
||||||
if (state.planChart2) state.planChart2.destroy();
|
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");
|
var c3 = document.querySelector("#planChartCash");
|
||||||
if (c3 && window.Chart) {
|
if (c3 && window.Chart) {
|
||||||
@@ -1350,7 +1361,8 @@ window.planInlineUpdate = async (pfId, field, value) => {
|
|||||||
try {
|
try {
|
||||||
await api("/api/planFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data: { [field]: value } }) });
|
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));
|
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");
|
toast("已更新", "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("更新失败:" + error.message, "error");
|
toast("更新失败:" + error.message, "error");
|
||||||
@@ -1358,9 +1370,53 @@ window.planInlineUpdate = async (pfId, field, value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ====== 筛选器控制函数 ======
|
// ====== 筛选器控制函数 ======
|
||||||
window.switchPlanFilter = (f) => { state.planFilter = f; renderPlan(); };
|
window.switchPlanFilter = (f) => { state.planFilter = f; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||||
window.setPlanView = (v) => { state.planView = v; renderPlan(); };
|
renderPlan(); };
|
||||||
window.setPlanYear = (y) => { state.planYear = parseInt(y); renderPlan(); };
|
window.setPlanView = (v) => { state.planView = v; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||||
window.setPlanQuarter = (q) => { state.planView = 'quarterly'; state.planQuarter = q; renderPlan(); };
|
renderPlan(); };
|
||||||
window.setPlanMonth = (m) => { state.planView = 'monthly'; state.planMonth = m; renderPlan(); };
|
window.setPlanYear = (y) => { state.planYear = parseInt(y); localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
|
||||||
window.setPlanStatusFilter = (f) => { state.planStatusFilter = f; renderPlan(); };
|
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();
|
||||||
|
};
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ function renderPlanExpense() {
|
|||||||
var totalAmount = filtered.reduce(function(s, r) { return s + (r.amount || 0); }, 0);
|
var totalAmount = filtered.reduce(function(s, r) { return s + (r.amount || 0); }, 0);
|
||||||
var totalIncurred = filtered.reduce(function(s, r) { return s + (r.incurred_amount || 0); }, 0);
|
var totalIncurred = filtered.reduce(function(s, r) { return s + (r.incurred_amount || 0); }, 0);
|
||||||
|
|
||||||
// 月份下拉选项(去年/今年/明年)
|
// 月份下拉选项(2020-2027)
|
||||||
var monthOpts = '';
|
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++) {
|
for (var m = 1; m <= 12; m++) {
|
||||||
var mv = yr + '-' + String(m).padStart(2, '0');
|
var mv = yr + '-' + String(m).padStart(2, '0');
|
||||||
monthOpts += '<option value="' + mv + '">' + mv + '</option>';
|
monthOpts += '<option value="' + mv + '">' + mv + '</option>';
|
||||||
@@ -107,10 +107,10 @@ function renderPlanExpense() {
|
|||||||
tableRows = '<tr><td colspan="6" class="p-6 text-center text-slate-400">' + emptyMsg + '</td></tr>';
|
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 curMonth = thisYear + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||||
var modalMonthOpts = '<option value="">请选择</option>';
|
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++) {
|
for (var m2 = 1; m2 <= 12; m2++) {
|
||||||
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
|
var mv2 = yr2 + '-' + String(m2).padStart(2, '0');
|
||||||
modalMonthOpts += '<option value="' + mv2 + '"' + (mv2 === curMonth ? ' selected' : '') + '>' + mv2 + '</option>';
|
modalMonthOpts += '<option value="' + mv2 + '"' + (mv2 === curMonth ? ' selected' : '') + '>' + mv2 + '</option>';
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ async function createResource(event, resource) {
|
|||||||
const name = data.project_name || data.target_customer || data.customer_or_project_name || data.product_name || "";
|
const name = data.project_name || data.target_customer || data.customer_or_project_name || data.product_name || "";
|
||||||
if (result.id && name) logActivity(resType, result.id, "创建了" + name);
|
if (result.id && name) logActivity(resType, result.id, "创建了" + name);
|
||||||
form.reset();
|
form.reset();
|
||||||
try { state.data.products = await api("/api/products/list?tenant=" + encodeURIComponent(state.tenant)); renderProducts(); } catch(e) {}
|
if (resource === "operations") {
|
||||||
|
try { state.data.operations = await api("/api/operations/list?tenant=" + encodeURIComponent(state.tenant)); renderProjects(); } catch(e) {}
|
||||||
|
} else if (resource === "proposals") {
|
||||||
|
try { state.data.proposals = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant)); renderProposals(); } catch(e) {}
|
||||||
|
} else {
|
||||||
|
try { state.data.products = await api("/api/products/list?tenant=" + encodeURIComponent(state.tenant)); renderProducts(); } catch(e) {}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("创建失败:" + error.message, "error");
|
toast("创建失败:" + error.message, "error");
|
||||||
}
|
}
|
||||||
@@ -104,11 +110,11 @@ window.editProductDate = (event, id) => {
|
|||||||
try {
|
try {
|
||||||
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { launch_date: newValue } }) });
|
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { launch_date: newValue } }) });
|
||||||
product.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"); }
|
} catch (e) { toast("修改失败:" + e.message, "error"); }
|
||||||
});
|
});
|
||||||
input.addEventListener("blur", () => {
|
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.innerHTML = "";
|
||||||
td.appendChild(input);
|
td.appendChild(input);
|
||||||
@@ -181,12 +187,12 @@ function sortItems(items) {
|
|||||||
function renderProducts() {
|
function renderProducts() {
|
||||||
const rawItems = state.data.products || [];
|
const rawItems = state.data.products || [];
|
||||||
const items = sortItems(rawItems);
|
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) => {
|
const sortIcon = (f) => {
|
||||||
if (productSort.field !== f) return '<i data-lucide="chevrons-up-down" style="width:12px;height:12px;opacity:0.3"></i>';
|
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>';
|
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 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>`;
|
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>`;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ window.toggleTenantMenu = (event) => {
|
|||||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">切换工作台</p>
|
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider">切换工作台</p>
|
||||||
</div>
|
</div>
|
||||||
${tenants.map(t => `
|
${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>
|
<span>${esc(t)}</span>
|
||||||
${t === state.tenant ? '<i data-lucide="check" style="width:14px;height:14px"></i>' : ''}
|
${t === state.tenant ? '<i data-lucide="check" style="width:14px;height:14px"></i>' : ''}
|
||||||
</button>
|
</button>
|
||||||
@@ -78,7 +78,7 @@ function updateTenantLabel() {
|
|||||||
var currentShort = (state.tenant || '').replace("·无界", "");
|
var currentShort = (state.tenant || '').replace("·无界", "");
|
||||||
var currentIcon = tenantIcons[state.tenant] || 'circle';
|
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="切换工作台">' +
|
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>' +
|
'<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>' +
|
'<i data-lucide="chevron-down" style="width:12px;height:12px;color:#64748b;margin-top:2px"></i>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -112,10 +112,10 @@ window.toggleTenantMenu = (event) => {
|
|||||||
const shortName = t.replace("·无界", "");
|
const shortName = t.replace("·无界", "");
|
||||||
const isActive = t === state.tenant;
|
const isActive = t === state.tenant;
|
||||||
var icon = tenantIcons[t] || 'circle';
|
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, "\\'") + '\')">' +
|
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 ? '#2563eb' : '#94a3b8') + '"></i>' +
|
'<i data-lucide="' + icon + '" style="width:16px;height:16px;color:' + (isActive ? 'var(--brand-600)' : '#94a3b8') + '"></i>' +
|
||||||
'<span class="flex-1">' + esc(shortName) + '</span>' +
|
'<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>';
|
'</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
@@ -173,14 +173,6 @@ window.selectProject = (id) => {
|
|||||||
renderProjectTasks(id);
|
renderProjectTasks(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.togglePhase = (phaseId) => {
|
|
||||||
const wrap = document.querySelector(`#${phaseId}`);
|
|
||||||
if (!wrap) return;
|
|
||||||
wrap.classList.toggle("collapsed");
|
|
||||||
const toggle = document.querySelector(`#${phaseId}-toggle`);
|
|
||||||
if (toggle) toggle.style.transform = wrap.classList.contains("collapsed") ? "rotate(-90deg)" : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
window.showProjectContext = (event, id) => {
|
window.showProjectContext = (event, id) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -306,7 +298,7 @@ function renderProjects() {
|
|||||||
pending: tasks.filter(t => t.status === '未开始').length,
|
pending: tasks.filter(t => t.status === '未开始').length,
|
||||||
};
|
};
|
||||||
document.querySelector("#projects").innerHTML = /*html*/`
|
document.querySelector("#projects").innerHTML = /*html*/`
|
||||||
<div class="grid grid-cols-5 gap-3 mb-4">
|
<div id="projectHeadCards" class="grid grid-cols-5 gap-3 mb-4">
|
||||||
${[
|
${[
|
||||||
["项目总数", items.length, "folder"],
|
["项目总数", items.length, "folder"],
|
||||||
["任务总数", taskStats.total, "list-checks"],
|
["任务总数", taskStats.total, "list-checks"],
|
||||||
@@ -317,15 +309,6 @@ function renderProjects() {
|
|||||||
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
|
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
|
||||||
`).join("")}
|
`).join("")}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center mb-3">
|
|
||||||
<div class="flex items-center gap-1" id="taskViewToggle">
|
|
||||||
<button class="btn btn-sm ${state.taskView === 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('compact')" title="标题视图"><i data-lucide="list" style="width:16px;height:16px"></i></button>
|
|
||||||
<button class="btn btn-sm ${state.taskView !== 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('detail')" title="详细视图"><i data-lucide="align-left" style="width:16px;height:16px"></i></button>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="openTaskFormForSelected()">
|
|
||||||
<i data-lucide="plus"></i>新增任务
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="project-board">
|
<div class="project-board">
|
||||||
<div class="project-board-body">
|
<div class="project-board-body">
|
||||||
<div class="project-tree">
|
<div class="project-tree">
|
||||||
@@ -349,6 +332,15 @@ function renderProjects() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-feed">
|
<div class="task-feed">
|
||||||
|
<div class="flex justify-between items-center px-4 py-2 border-b border-slate-100 bg-white" style="flex-shrink:0">
|
||||||
|
<div class="flex items-center gap-1" id="taskViewToggle">
|
||||||
|
<button class="btn btn-sm ${state.taskView === 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('compact')" title="标题视图"><i data-lucide="list" style="width:16px;height:16px"></i></button>
|
||||||
|
<button class="btn btn-sm ${state.taskView !== 'compact' ? 'btn-primary' : 'btn-ghost'} p-1.5" onclick="setTaskView('detail')" title="详细视图"><i data-lucide="align-left" style="width:16px;height:16px"></i></button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openTaskFormForSelected()">
|
||||||
|
<i data-lucide="plus"></i>新增任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
${state.selectedProject ? '<div class="task-feed-body">' + renderTaskListHTML(state.selectedProject) + '</div>' : `
|
${state.selectedProject ? '<div class="task-feed-body">' + renderTaskListHTML(state.selectedProject) + '</div>' : `
|
||||||
<div class="project-empty">
|
<div class="project-empty">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -381,44 +373,33 @@ function renderTaskListHTML(projectId) {
|
|||||||
const project = state.data.operations.find((x) => x.id === projectId);
|
const project = state.data.operations.find((x) => x.id === projectId);
|
||||||
if (!project) return "";
|
if (!project) return "";
|
||||||
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
|
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
|
||||||
const filtered = tasks;
|
|
||||||
const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"];
|
const taskItem = (t) => `<div class="task-item ${t.status === '已结束' ? 'task-done' : ''} ${t.priority === 'P0' ? 'task-p0' : t.priority === 'P1' ? 'task-p1' : ''} ${state.taskView === 'detail' ? 'task-detail' : ''}" data-id="${t.id}" draggable="true" ondragstart="handleTaskDragStart(event, ${t.id})" ondragend="event.currentTarget.classList.remove('dragging')" onclick="openTaskForm(${projectId}, ${t.id})">
|
||||||
const customPhases = [...new Set(filtered.map(t => t.phase).filter(Boolean))];
|
<span class="task-grip"><i data-lucide="grip-vertical"></i></span>
|
||||||
const phaseOrder = [...defaultPhases];
|
<span class="task-status-badge status-${esc(t.status) || '未开始'}" onclick="event.stopPropagation(); cycleTaskStatus(${t.id}, ${projectId})" title="点击切换状态">${esc(t.status) || '未开始'}</span>
|
||||||
customPhases.forEach(p => { if (!phaseOrder.includes(p)) phaseOrder.push(p); });
|
<span class="task-priority-badge priority-${(t.priority || 'P2').toLowerCase()}" onclick="event.stopPropagation(); cycleTaskPriority(${t.id}, ${projectId})" title="点击切换优先级">${esc(t.priority) || 'P2'}</span>
|
||||||
const phases = phaseOrder.filter(p => filterPhaseTasks(filtered, p).length > 0);
|
<div class="task-content">
|
||||||
const phaseTasks = phases.map(p => ({ phase: p, tasks: filterPhaseTasks(filtered, p) }));
|
<span class="task-title">${esc(t.task)}</span>
|
||||||
|
${state.taskView === 'detail' && t.notes ? '<span class="task-desc">' + esc(t.notes) + '</span>' : ""}
|
||||||
|
${state.taskView === 'detail' && t.blockers ? '<span class="task-blocker">\u26a0 ' + esc(t.blockers) + '</span>' : ""}
|
||||||
|
</div>
|
||||||
|
<span class="task-meta">${esc(t.owner) || ''}</span>
|
||||||
|
<span class="task-expand-toggle" onclick="event.stopPropagation();toggleTaskExpand(${t.id})"><i data-lucide="chevron-down" id="tke_icon_${t.id}"></i></span>
|
||||||
|
</div>
|
||||||
|
<div class="task-expand-body hidden" id="tke_body_${t.id}">
|
||||||
|
<div class="flex flex-col gap-3 px-6 py-3 text-sm">
|
||||||
|
<div><span class="text-slate-400 text-xs">时间</span><p class="text-slate-700 mt-1">${esc(t.start_date) || '—'} → ${esc(t.due_date) || '—'}</p></div>
|
||||||
|
<div><span class="text-slate-400 text-xs">任务进展</span><p class="text-slate-700 mt-1">${esc(t.notes) || '—'}</p></div>
|
||||||
|
<div><span class="text-slate-400 text-xs">卡点与备注</span><p class="${t.blockers ? 'text-red-600' : 'text-slate-700'} mt-1">${t.blockers ? '\u26a0 ' + esc(t.blockers) : '—'}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${phaseTasks.map(({ phase, tasks: pt }) => {
|
<div class="task-section-list-wrap">
|
||||||
if (!pt.length) return "";
|
<div class="task-section-list" ondrop="handleTaskDrop(event, ${projectId}, '')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')">
|
||||||
const phaseId = "phase-" + projectId + "-" + phase.replace(/\s/g, "");
|
${tasks.length ? tasks.map(taskItem).join("") : '<div class="task-empty">暂无任务,点击上方按钮创建</div>'}
|
||||||
return `<div class="task-section">
|
</div>
|
||||||
<div class="task-section-hd" onclick="togglePhase('${phaseId}')">
|
</div>
|
||||||
<span class="task-section-toggle" id="${phaseId}-toggle"><i data-lucide="chevron-down"></i></span>
|
|
||||||
<span class="task-section-icon"><i data-lucide="layers"></i></span>
|
|
||||||
<span class="task-section-label">${phase}</span>
|
|
||||||
<span class="task-section-n">${pt.length}</span>
|
|
||||||
</div>
|
|
||||||
<div class="task-section-list-wrap" id="${phaseId}">
|
|
||||||
<div class="task-section-list" data-phase="${phase}" ondrop="handleTaskDrop(event, ${projectId}, '${phase}')" ondragover="event.preventDefault(); event.currentTarget.classList.add('drag-over')" ondragleave="event.currentTarget.classList.remove('drag-over')">
|
|
||||||
${pt.map((t) => `<div class="task-item ${t.status === '已结束' ? 'task-done' : ''} ${t.priority === 'P0' ? 'task-p0' : t.priority === 'P1' ? 'task-p1' : ''} ${state.taskView === 'detail' ? 'task-detail' : ''}" data-id="${t.id}" draggable="true" ondragstart="handleTaskDragStart(event, ${t.id})" ondragend="event.currentTarget.classList.remove('dragging')">
|
|
||||||
<span class="task-grip"><i data-lucide="grip-vertical"></i></span>
|
|
||||||
<span class="task-status-badge status-${esc(t.status) || '未开始'}" onclick="event.stopPropagation(); cycleTaskStatus(${t.id}, ${projectId})" title="点击切换状态">${esc(t.status) || '未开始'}</span>
|
|
||||||
<span class="task-priority-badge priority-${(t.priority || 'P2').toLowerCase()}" onclick="event.stopPropagation(); cycleTaskPriority(${t.id}, ${projectId})" title="点击切换优先级">${esc(t.priority) || 'P2'}</span>
|
|
||||||
<div class="task-content" onclick="openTaskForm(${projectId}, ${t.id})">
|
|
||||||
<span class="task-title">${esc(t.task)}</span>
|
|
||||||
${state.taskView === 'detail' && t.notes ? '<span class="task-desc">' + esc(t.notes) + '</span>' : ""}
|
|
||||||
${state.taskView === 'detail' && t.blockers ? '<span class="task-blocker">\u26a0 ' + esc(t.blockers) + '</span>' : ""}
|
|
||||||
</div>
|
|
||||||
<span class="task-meta">${esc(t.owner) || ''}</span>
|
|
||||||
<span class="task-meta text-slate-400">${esc(t.due_date) || ''}</span>
|
|
||||||
</div>`).join("")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join("")}
|
|
||||||
${filtered.length === 0 ? '<div class="task-empty">暂无任务,点击上方按钮创建</div>' : ''}
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,48 +421,56 @@ window.openTaskFormForSelected = () => {
|
|||||||
|
|
||||||
window.openTaskForm = (projectId, taskId) => {
|
window.openTaskForm = (projectId, taskId) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
// 确保 drawer 存在
|
const modal = document.getElementById("taskModal");
|
||||||
let drawer = document.querySelector(`#task-drawer-${projectId}`);
|
if (!modal) return;
|
||||||
if (!drawer) {
|
|
||||||
drawer = document.createElement("div");
|
|
||||||
drawer.id = `task-drawer-${projectId}`;
|
|
||||||
drawer.className = "task-drawer";
|
|
||||||
document.body.appendChild(drawer);
|
|
||||||
}
|
|
||||||
const tasks = (state.data.tasks || []).filter((t) => t.project_id === projectId);
|
|
||||||
const defaultPhases = ["商务洽谈", "系统上线", "团队分工", "项目交付", "上线推广", "结项验收"];
|
|
||||||
const customPhases = [...new Set(tasks.map(t => t.phase).filter(Boolean))];
|
|
||||||
const phases = [...new Set([...defaultPhases, ...customPhases])];
|
|
||||||
const task = taskId ? (state.data.tasks || []).find((t) => t.id === taskId) : null;
|
const task = taskId ? (state.data.tasks || []).find((t) => t.id === taskId) : null;
|
||||||
drawer.innerHTML = `<div class="task-drawer-hd"><span class="task-drawer-title">${task ? "编辑任务" : "新增任务"}</span><div class="flex items-center gap-2">${task ? `<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteTask(${projectId})"><i data-lucide="trash-2"></i>删除</button>` : ""}<button class="task-close" onclick="closeTaskDrawer(${projectId})"><i data-lucide="x"></i></button></div></div>
|
modal.className = "hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40";
|
||||||
<form class="task-drawer-form" onsubmit="submitTaskForm(event, ${projectId})">
|
modal.innerHTML = `<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
|
||||||
<input type="hidden" name="task_id" id="task-id-${projectId}" value="${task ? task.id : ''}">
|
<div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-6 py-4 flex items-center justify-between">
|
||||||
<div class="task-field-row">
|
<h3 class="text-lg font-bold text-slate-800">${task ? "编辑任务" : "新增任务"}</h3>
|
||||||
<label class="task-field"><span>任务名称</span><input name="task" required id="task-name-${projectId}" class="form-ctrl" value="${task ? esc(task.task) : ''}"></label>
|
<div class="flex items-center gap-2">
|
||||||
<label class="task-field"><span>任务分组</span><select name="phase" id="task-phase-${projectId}" class="form-ctrl">${phases.map((p) => `<option ${task && task.phase === p ? "selected" : ""}>${p}</option>`).join("")}</select></label>
|
${task ? '<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" id="taskDelBtn"><i data-lucide="trash-2"></i>删除</button>' : ""}
|
||||||
|
<button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" id="taskClsBtn"><i data-lucide="x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-field-row">
|
</div>
|
||||||
<label class="task-field"><span>优先级</span><select name="priority" id="task-priority-${projectId}" class="form-ctrl"><option ${task && task.priority === 'P0' ? 'selected' : ''}>P0</option><option ${task && task.priority === 'P1' ? 'selected' : ''}>P1</option><option ${(!task || task.priority === 'P2') ? 'selected' : ''}>P2</option><option ${task && task.priority === 'P3' ? 'selected' : ''}>P3</option></select></label>
|
<form class="px-6 py-4 flex flex-col gap-4" onsubmit="event.preventDefault()">
|
||||||
<label class="task-field"><span>状态</span><select name="status" id="task-status-${projectId}" class="form-ctrl"><option ${(!task || task.status === '未开始') ? 'selected' : ''}>未开始</option><option ${task && task.status === '进行中' ? 'selected' : ''}>进行中</option><option ${task && task.status === '已结束' ? 'selected' : ''}>已结束</option></select></label>
|
<input type="hidden" name="task_id" value="${task ? task.id : ''}">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="task-field"><span>任务名称</span><input name="task" required class="form-ctrl" value="${task ? esc(task.task) : ''}"></label>
|
||||||
|
<label class="task-field"><span>优先级</span><select name="priority" class="form-ctrl"><option ${task && task.priority === 'P0' ? 'selected' : ''}>P0</option><option ${task && task.priority === 'P1' ? 'selected' : ''}>P1</option><option ${(!task || task.priority === 'P2') ? 'selected' : ''}>P2</option><option ${task && task.priority === 'P3' ? 'selected' : ''}>P3</option></select></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-field-row">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<label class="task-field"><span>负责人</span><input name="owner" id="task-owner-${projectId}" class="form-ctrl" value="${task ? esc(task.owner) : ''}"></label>
|
<label class="task-field"><span>状态</span><select name="status" class="form-ctrl"><option ${(!task || task.status === '未开始') ? 'selected' : ''}>未开始</option><option ${task && task.status === '进行中' ? 'selected' : ''}>进行中</option><option ${task && task.status === '已结束' ? 'selected' : ''}>已结束</option></select></label>
|
||||||
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" id="task-due-${projectId}" class="form-ctrl" value="${task ? esc(task.due_date) : ''}"></label>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="task-field"><span>任务说明</span><textarea name="notes" rows="3" id="task-notes-${projectId}" class="form-ctrl">${task ? esc(task.notes) : ''}</textarea></label>
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" id="task-blockers-${projectId}" class="form-ctrl" placeholder="风险卡点、依赖项等">${task ? esc(task.blockers) : ''}</textarea></label>
|
<label class="task-field"><span>负责人</span><input name="owner" class="form-ctrl" value="${task ? esc(task.owner) : ''}"></label>
|
||||||
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
|
<label class="task-field"><span>开始时间</span><input name="start_date" type="date" class="form-ctrl" value="${task ? esc(task.start_date) : ''}"></label>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" onclick="closeTaskDrawer(${projectId})">取消</button>
|
<label class="task-field"><span>截止时间</span><input name="due_date" type="date" class="form-ctrl" value="${task ? esc(task.due_date) : ''}"></label>
|
||||||
<button type="submit" class="btn btn-primary btn-sm" id="task-submit-btn-${projectId}">${task ? "保存" : "确认新增"}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>`;
|
<label class="task-field"><span>任务进展</span><textarea name="notes" rows="3" class="form-ctrl">${task ? esc(task.notes) : ''}</textarea></label>
|
||||||
drawer.classList.add("open");
|
<label class="task-field"><span>卡点&备注</span><textarea name="blockers" rows="2" class="form-ctrl" placeholder="风险卡点、依赖项等">${task ? esc(task.blockers) : ''}</textarea></label>
|
||||||
|
<div class="flex justify-end gap-2 pt-3 border-t border-slate-100">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="taskCancelBtn">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" id="taskSubmitBtn">${task ? "保存" : "确认新增"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>`;
|
||||||
|
// 绑定事件
|
||||||
|
var delBtn = document.getElementById("taskDelBtn");
|
||||||
|
if (delBtn) delBtn.onclick = function() { deleteTask(projectId); };
|
||||||
|
var clsBtn = document.getElementById("taskClsBtn");
|
||||||
|
if (clsBtn) clsBtn.onclick = function() { closeTaskDrawer(projectId); };
|
||||||
|
var cancelBtn = document.getElementById("taskCancelBtn");
|
||||||
|
if (cancelBtn) cancelBtn.onclick = function() { closeTaskDrawer(projectId); };
|
||||||
|
var submitBtn = document.getElementById("taskSubmitBtn");
|
||||||
|
if (submitBtn) submitBtn.onclick = function(e) { e.preventDefault(); submitTaskForm(e, projectId); };
|
||||||
|
modal.classList.remove("hidden");
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.closeTaskDrawer = (projectId) => {
|
window.closeTaskDrawer = (projectId) => {
|
||||||
const drawer = document.querySelector(`#task-drawer-${projectId}`);
|
const modal = document.getElementById("taskModal");
|
||||||
if (drawer) drawer.classList.remove("open");
|
if (modal) modal.classList.add("hidden");
|
||||||
refreshTaskList(projectId);
|
refreshTaskList(projectId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -492,10 +481,34 @@ window.refreshTaskList = (projectId) => {
|
|||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
window.refreshProjectHeadCards = () => {
|
||||||
|
const container = document.querySelector("#projectHeadCards");
|
||||||
|
if (!container) return;
|
||||||
|
const items = state.data.operations || [];
|
||||||
|
const tasks = state.data.tasks || [];
|
||||||
|
const stats = {
|
||||||
|
total: tasks.length,
|
||||||
|
ongoing: tasks.filter(t => t.status === '进行中').length,
|
||||||
|
done: tasks.filter(t => t.status === '已结束').length,
|
||||||
|
pending: tasks.filter(t => t.status === '未开始').length,
|
||||||
|
};
|
||||||
|
const cards = [
|
||||||
|
["项目总数", items.length, "folder"],
|
||||||
|
["任务总数", stats.total, "list-checks"],
|
||||||
|
["进行中", stats.ongoing, "play-circle"],
|
||||||
|
["已结束", stats.done, "check-circle"],
|
||||||
|
["未开始", stats.pending, "circle"],
|
||||||
|
];
|
||||||
|
container.innerHTML = cards.map(([label, value, icon]) => `
|
||||||
|
<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></div>
|
||||||
|
`).join("");
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
};
|
||||||
|
|
||||||
window.submitTaskForm = async (event, projectId) => {
|
window.submitTaskForm = async (event, projectId) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
|
const form = document.getElementById("taskModal").querySelector("form");
|
||||||
|
const data = Object.fromEntries(new FormData(form).entries());
|
||||||
data.project_id = Number(projectId);
|
data.project_id = Number(projectId);
|
||||||
data.tenant = state.tenant;
|
data.tenant = state.tenant;
|
||||||
const taskId = data.task_id;
|
const taskId = data.task_id;
|
||||||
@@ -507,11 +520,14 @@ window.submitTaskForm = async (event, projectId) => {
|
|||||||
if (task) Object.assign(task, data);
|
if (task) Object.assign(task, data);
|
||||||
if (data.task) logActivity("task", taskId, "更新了任务「" + data.task + "」");
|
if (data.task) logActivity("task", taskId, "更新了任务「" + data.task + "」");
|
||||||
closeTaskDrawer(projectId);
|
closeTaskDrawer(projectId);
|
||||||
|
refreshProjectHeadCards();
|
||||||
} else {
|
} else {
|
||||||
const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) });
|
const result = await api("/api/tasks", { method: "POST", body: JSON.stringify({ data }) });
|
||||||
if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」");
|
if (result.id && data.task) logActivity("task", result.id, "创建了任务「" + data.task + "」");
|
||||||
|
if (!state.data.tasks) state.data.tasks = [];
|
||||||
|
state.data.tasks.push({ id: result.id, ...data });
|
||||||
closeTaskDrawer(projectId);
|
closeTaskDrawer(projectId);
|
||||||
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
refreshProjectHeadCards();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("保存失败:" + error.message, "error");
|
toast("保存失败:" + error.message, "error");
|
||||||
@@ -537,6 +553,7 @@ window.cycleTaskStatus = async (taskId, projectId) => {
|
|||||||
badge.className = "task-status-badge status-" + newStatus;
|
badge.className = "task-status-badge status-" + newStatus;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
refreshProjectHeadCards();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("更新失败:" + error.message, "error");
|
toast("更新失败:" + error.message, "error");
|
||||||
}
|
}
|
||||||
@@ -568,8 +585,25 @@ window.cycleTaskPriority = async (taskId, projectId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.toggleTaskExpand = (taskId) => {
|
||||||
|
const body = document.getElementById("tke_body_" + taskId);
|
||||||
|
const icon = document.getElementById("tke_icon_" + taskId);
|
||||||
|
if (!body || !icon) return;
|
||||||
|
const isHidden = body.classList.contains("hidden");
|
||||||
|
if (isHidden) {
|
||||||
|
body.classList.remove("hidden");
|
||||||
|
icon.style.transform = "rotate(180deg)";
|
||||||
|
} else {
|
||||||
|
body.classList.add("hidden");
|
||||||
|
icon.style.transform = "";
|
||||||
|
}
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
};
|
||||||
|
|
||||||
window.deleteTask = async (projectId) => {
|
window.deleteTask = async (projectId) => {
|
||||||
const taskId = document.querySelector(`#task-id-${projectId}`).value;
|
const modal = document.getElementById("taskModal");
|
||||||
|
if (!modal) return;
|
||||||
|
const taskId = modal.querySelector('input[name="task_id"]').value;
|
||||||
if (!taskId) return;
|
if (!taskId) return;
|
||||||
if (!confirm("确认删除该任务?此操作不可撤销。")) return;
|
if (!confirm("确认删除该任务?此操作不可撤销。")) return;
|
||||||
try {
|
try {
|
||||||
@@ -581,6 +615,7 @@ window.deleteTask = async (projectId) => {
|
|||||||
state.data.tasks = (state.data.tasks || []).filter(t => t.id !== parseInt(taskId));
|
state.data.tasks = (state.data.tasks || []).filter(t => t.id !== parseInt(taskId));
|
||||||
const row = document.querySelector(`.task-item[data-id="${taskId}"]`);
|
const row = document.querySelector(`.task-item[data-id="${taskId}"]`);
|
||||||
if (row) row.remove();
|
if (row) row.remove();
|
||||||
|
refreshProjectHeadCards();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("删除失败:" + error.message, "error");
|
toast("删除失败:" + error.message, "error");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function renderProposals() {
|
|||||||
<button class="finance-tab${!isStandard ? ' active' : ''}" style="font-size:14px;font-weight:600" onclick="switchProposalTab('other')">其他资料</button>
|
<button class="finance-tab${!isStandard ? ' active' : ''}" style="font-size:14px;font-weight:600" onclick="switchProposalTab('other')">其他资料</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">${!isStandard ? `<button class="btn btn-primary btn-sm" onclick="openProposalModal()"><i data-lucide="plus"></i>新增方案</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)}
|
${isStandard ? renderStandardTable(standardItems) : renderOtherTable(otherItems)}
|
||||||
</div>
|
</div>
|
||||||
<div id="proposalModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeProposalModal()">
|
<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>
|
</section>
|
||||||
${followupTarget ? `<section>
|
${followupTarget ? `<section>
|
||||||
<h3 class="drawer-section-title">活动 / 跟进</h3>
|
<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})">
|
<form class="comment-box mt-3" onsubmit="submitStandardComment(event, ${item.id})">
|
||||||
<div class="squire-toolbar">
|
<div class="squire-toolbar">
|
||||||
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
|
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// utils.js — 全局工具函数与共享状态
|
// utils.js — 全局工具函数与共享状态
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
active: "home",
|
active: "plan",
|
||||||
data: null,
|
data: null,
|
||||||
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
|
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
|
||||||
opFilter: "all",
|
opFilter: "all",
|
||||||
@@ -61,9 +61,8 @@ function toast(message, type = "info", duration = 3000) {
|
|||||||
window.toast = toast;
|
window.toast = toast;
|
||||||
|
|
||||||
function monthOptions(selected = '') {
|
function monthOptions(selected = '') {
|
||||||
const now = new Date();
|
const startYear = 2020;
|
||||||
const startYear = now.getFullYear() - 1;
|
const endYear = 2027;
|
||||||
const endYear = now.getFullYear() + 1;
|
|
||||||
let options = selected ? '' : '<option value="">选择月份</option>';
|
let options = selected ? '' : '<option value="">选择月份</option>';
|
||||||
for (let y = startYear; y <= endYear; y++) {
|
for (let y = startYear; y <= endYear; y++) {
|
||||||
for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) {
|
for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) {
|
||||||
@@ -121,7 +120,6 @@ function renderTable(headers, rows, rowClicks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _loadingSteps = [
|
var _loadingSteps = [
|
||||||
{ fn: 'renderHome', label: '正在加载首页仪表盘(指标卡片+图表)', skip: function() { return true; } },
|
|
||||||
{ fn: 'renderOverviewPlan', label: '正在加载预算总览', skip: function() { return state.tenant !== '总览'; } },
|
{ fn: 'renderOverviewPlan', label: '正在加载预算总览', skip: function() { return state.tenant !== '总览'; } },
|
||||||
{ fn: 'renderOverviewFinance', label: '正在加载发生总览', skip: function() { return state.tenant !== '总览'; } },
|
{ fn: 'renderOverviewFinance', label: '正在加载发生总览', skip: function() { return state.tenant !== '总览'; } },
|
||||||
{ fn: 'renderProjects', label: '正在加载业务机会列表' },
|
{ fn: 'renderProjects', label: '正在加载业务机会列表' },
|
||||||
@@ -136,7 +134,7 @@ function showLoadingOverlay(steps) {
|
|||||||
var existing = document.getElementById('_loadingOverlay');
|
var existing = document.getElementById('_loadingOverlay');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
var stepsHtml = steps.map(function(s, i) {
|
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 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>' +
|
'<span>' + s.label + '</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -148,7 +146,7 @@ function showLoadingOverlay(steps) {
|
|||||||
'<h3 class="text-base font-bold text-slate-800 mb-1">正在加载工作台</h3>' +
|
'<h3 class="text-base font-bold text-slate-800 mb-1">正在加载工作台</h3>' +
|
||||||
'<p class="text-xs text-slate-400 mb-4">共 ' + steps.length + ' 个模块,请稍候…</p>' +
|
'<p class="text-xs text-slate-400 mb-4">共 ' + steps.length + ' 个模块,请稍候…</p>' +
|
||||||
'<div id="_loadingSteps">' + stepsHtml + '</div>' +
|
'<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>';
|
'</div>';
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
}
|
}
|
||||||
@@ -161,7 +159,7 @@ function updateLoadingStep(idx, total) {
|
|||||||
}
|
}
|
||||||
var nextEl = document.getElementById('_loadingStep_' + (idx + 1));
|
var nextEl = document.getElementById('_loadingStep_' + (idx + 1));
|
||||||
if (nextEl) {
|
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>';
|
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');
|
var progress = document.getElementById('_loadingProgress');
|
||||||
@@ -236,7 +234,7 @@ var _tabLoaded = {};
|
|||||||
var _proposalsInitialized = false;
|
var _proposalsInitialized = false;
|
||||||
|
|
||||||
async function ensureTabData(tab) {
|
async function ensureTabData(tab) {
|
||||||
if (tab === 'home' || tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return;
|
if (tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return;
|
||||||
var resource = _tabResourceMap[tab];
|
var resource = _tabResourceMap[tab];
|
||||||
if (!resource) return;
|
if (!resource) return;
|
||||||
var dataKey = resource;
|
var dataKey = resource;
|
||||||
@@ -250,6 +248,8 @@ async function ensureTabData(tab) {
|
|||||||
try { state.data.expense = await api("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
try { state.data.expense = await api("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
try { state.data.finance = await api("/api/finance/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
try { state.data.finance = await api("/api/finance/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
|
} else if (tab === 'projects') {
|
||||||
|
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
} else if (tab === 'plan') {
|
} else if (tab === 'plan') {
|
||||||
try { state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
try { state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
|
||||||
}
|
}
|
||||||
@@ -266,7 +266,7 @@ function switchTab(tab) {
|
|||||||
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
|
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
|
||||||
|
|
||||||
// 更新顶部标题
|
// 更新顶部标题
|
||||||
const titles = { home: 'OPC 工作台', finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' };
|
const titles = { finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: '业务方案', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' };
|
||||||
const titleEl = document.querySelector('#workspaceTitle');
|
const titleEl = document.querySelector('#workspaceTitle');
|
||||||
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
|
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
|
||||||
|
|
||||||
@@ -284,28 +284,15 @@ function updateSidebarTabs() {
|
|||||||
if (isOverview) {
|
if (isOverview) {
|
||||||
btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "" : "none";
|
btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "" : "none";
|
||||||
} else {
|
} else {
|
||||||
btn.style.display = (tab === "overview_plan" || tab === "overview_finance" || tab === "home") ? "none" : "";
|
btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "none" : "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (!state.data) return;
|
|
||||||
if (state.tenant === '总览') renderHome();
|
|
||||||
renderProjects();
|
|
||||||
renderProposals();
|
|
||||||
renderProducts();
|
|
||||||
renderPerformance();
|
|
||||||
renderFinance();
|
|
||||||
renderPlan();
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActive() {
|
function renderActive() {
|
||||||
if (!state.data) return;
|
if (!state.data) return;
|
||||||
const tab = state.active;
|
const tab = state.active;
|
||||||
if (tab === "home") renderHome();
|
if (tab === "overview_plan") renderOverviewPlan();
|
||||||
else if (tab === "overview_plan") renderOverviewPlan();
|
|
||||||
else if (tab === "overview_finance") renderOverviewFinance();
|
else if (tab === "overview_finance") renderOverviewFinance();
|
||||||
else if (tab === "projects") renderProjects();
|
else if (tab === "projects") renderProjects();
|
||||||
else if (tab === "proposals") renderProposals();
|
else if (tab === "proposals") renderProposals();
|
||||||
@@ -404,11 +391,14 @@ window.switchTenant = async (tenant) => {
|
|||||||
localStorage.setItem("opc-active-tab", "overview_plan");
|
localStorage.setItem("opc-active-tab", "overview_plan");
|
||||||
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "overview_plan"));
|
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "overview_plan"));
|
||||||
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "overview_plan"));
|
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "overview_plan"));
|
||||||
} else if (state.active === 'home' || state.active === 'overview_plan' || state.active === 'overview_finance') {
|
} else if (state.active === 'overview_plan' || state.active === 'overview_finance') {
|
||||||
state.active = 'plan';
|
state.active = 'plan';
|
||||||
localStorage.setItem("opc-active-tab", "plan");
|
localStorage.setItem("opc-active-tab", "plan");
|
||||||
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan"));
|
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan"));
|
||||||
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan"));
|
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan"));
|
||||||
|
} else {
|
||||||
|
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === state.active));
|
||||||
|
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === state.active));
|
||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
};
|
};
|
||||||
@@ -425,3 +415,18 @@ window.doLogout = async () => {
|
|||||||
await api("/api/auth/logout", { method: "POST" });
|
await api("/api/auth/logout", { method: "POST" });
|
||||||
location.href = "/login";
|
location.href = "/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 图表工具函数(曾被 home.js 中 plan/finance 模块复用)
|
||||||
|
const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v;
|
||||||
|
const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月");
|
||||||
|
function chartOptions(yCallback) {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { font: { size: 10 } }, grid: { display: false } },
|
||||||
|
y: { ticks: { font: { size: 11 }, callback: yCallback } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
body {
|
||||||
min-width: 1400px;
|
min-width: 1400px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -29,7 +70,7 @@ body {
|
|||||||
background: rgba(255,255,255,0.08);
|
background: rgba(255,255,255,0.08);
|
||||||
}
|
}
|
||||||
.workspace-nav-item.active {
|
.workspace-nav-item.active {
|
||||||
color: #60a5fa;
|
color: var(--brand-400);
|
||||||
background: rgba(96,165,250,0.15);
|
background: rgba(96,165,250,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +79,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -52,11 +93,11 @@ body {
|
|||||||
|
|
||||||
.sidebar-tab.active {
|
.sidebar-tab.active {
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
color: #60a5fa;
|
color: var(--brand-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tab.active i {
|
.sidebar-tab.active i {
|
||||||
color: #60a5fa;
|
color: var(--brand-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -91,8 +132,8 @@ body {
|
|||||||
.finance-tab:hover { color: #1e293b; }
|
.finance-tab:hover { color: #1e293b; }
|
||||||
|
|
||||||
.finance-tab.active {
|
.finance-tab.active {
|
||||||
color: #1d4ed8;
|
color: var(--brand-700);
|
||||||
border-bottom-color: #1d4ed8;
|
border-bottom-color: var(--brand-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 详情弹窗固定高度,tab 切换不抖动 */
|
/* 详情弹窗固定高度,tab 切换不抖动 */
|
||||||
@@ -126,7 +167,7 @@ body {
|
|||||||
.project-board {
|
.project-board {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -151,7 +192,7 @@ body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -209,9 +250,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-tree-node.active {
|
.project-tree-node.active {
|
||||||
background: #eff6ff;
|
background: var(--brand-50);
|
||||||
border-left-color: #3b82f6;
|
border-left-color: var(--brand-500);
|
||||||
color: #1d4ed8;
|
color: var(--brand-700);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +284,7 @@ body {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
@@ -383,12 +424,12 @@ body {
|
|||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.status-未开始 { background: #f1f5f9; color: #64748b; }
|
.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: #fef3c7; color: #92400e; }
|
||||||
.status-已结束 { background: #dcfce7; color: #166534; }
|
.status-已结束 { background: #dcfce7; color: #166534; }
|
||||||
/* 产品版本状态 */
|
/* 产品版本状态 */
|
||||||
.status-规划中 { background: #f1f5f9; color: #64748b; }
|
.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: #fef3c7; color: #92400e; }
|
||||||
.status-已上线 { background: #dcfce7; color: #166534; }
|
.status-已上线 { background: #dcfce7; color: #166534; }
|
||||||
.status-已取消 { background: #f1f5f9; color: #94a3b8; }
|
.status-已取消 { background: #f1f5f9; color: #94a3b8; }
|
||||||
@@ -425,7 +466,7 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.task-check:hover {
|
.task-check:hover {
|
||||||
color: #3b82f6;
|
color: var(--brand-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-grip {
|
.task-grip {
|
||||||
@@ -496,6 +537,26 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-expand-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #94a3b8;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.task-expand-toggle:hover { color: #475569; background: #f1f5f9; }
|
||||||
|
.task-expand-toggle i { transition: transform 0.2s ease; }
|
||||||
|
|
||||||
|
.task-expand-body {
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
/* 业务方案列表项 */
|
/* 业务方案列表项 */
|
||||||
.proposal-item {
|
.proposal-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -539,7 +600,7 @@ body {
|
|||||||
}
|
}
|
||||||
.upload-task-fill {
|
.upload-task-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #3b82f6;
|
background: var(--brand-500);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: width 0.2s;
|
transition: width 0.2s;
|
||||||
}
|
}
|
||||||
@@ -591,20 +652,20 @@ body {
|
|||||||
.card {
|
.card {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card:hover {
|
.metric-card:hover {
|
||||||
border-color: #93c5fd;
|
border-color: var(--brand-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -616,15 +677,15 @@ body {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-red { background: #fef2f2; color: #b91c1c; }
|
.badge-red { background: var(--danger-bg); color: #b91c1c; }
|
||||||
.badge-amber { background: #fffbeb; color: #b45309; }
|
.badge-amber { background: var(--warning-bg); color: #b45309; }
|
||||||
.badge-green { background: #ecfdf5; color: #047857; }
|
.badge-green { background: var(--success-bg); color: #047857; }
|
||||||
.badge-blue { background: #eff6ff; color: #1d4ed8; }
|
.badge-blue { background: var(--brand-50); color: var(--brand-700); }
|
||||||
.badge-slate { background: #f1f5f9; color: #475569; }
|
.badge-slate { background: #f1f5f9; color: #475569; }
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -640,8 +701,8 @@ body {
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary { background: #1d4ed8; color: white; }
|
.btn-primary { background: var(--brand-700); color: white; }
|
||||||
.btn-primary:hover { background: #1e40af; }
|
.btn-primary:hover { background: var(--brand-800); }
|
||||||
.btn-ghost { background: white; border: 1px solid #e2e8f0; color: #334155; }
|
.btn-ghost { background: white; border: 1px solid #e2e8f0; color: #334155; }
|
||||||
.btn-ghost:hover { background: #f8fafc; }
|
.btn-ghost:hover { background: #f8fafc; }
|
||||||
|
|
||||||
@@ -650,7 +711,7 @@ input,
|
|||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
@@ -671,8 +732,8 @@ select {
|
|||||||
input:focus,
|
input:focus,
|
||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
border-color: #2563eb;
|
border-color: var(--brand-600);
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
|
box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.08);
|
||||||
}
|
}
|
||||||
input::placeholder,
|
input::placeholder,
|
||||||
textarea::placeholder { color: #cbd5e1; }
|
textarea::placeholder { color: #cbd5e1; }
|
||||||
@@ -688,7 +749,7 @@ textarea { min-height: 80px; height: auto; resize: vertical; }
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
@@ -697,8 +758,8 @@ textarea { min-height: 80px; height: auto; resize: vertical; }
|
|||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
.form-ctrl:focus {
|
.form-ctrl:focus {
|
||||||
border-color: #2563eb;
|
border-color: var(--brand-600);
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
|
box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.08);
|
||||||
}
|
}
|
||||||
.form-ctrl::placeholder { color: #cbd5e1; }
|
.form-ctrl::placeholder { color: #cbd5e1; }
|
||||||
.form-ctrl:disabled { background: #f8fafc; cursor: not-allowed; }
|
.form-ctrl:disabled { background: #f8fafc; cursor: not-allowed; }
|
||||||
@@ -726,7 +787,7 @@ textarea { min-height: 80px; height: auto; resize: vertical; }
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
.prod-date-input:focus {
|
.prod-date-input:focus {
|
||||||
border-color: #3b82f6;
|
border-color: var(--brand-500);
|
||||||
background: white;
|
background: white;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
@@ -777,14 +838,14 @@ td {
|
|||||||
|
|
||||||
.drawer-panel {
|
.drawer-panel {
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: -18px 0 45px rgba(15, 23, 42, 0.14);
|
box-shadow: var(--shadow-drawer);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: 720px;
|
width: 720px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-link {
|
.file-link {
|
||||||
color: #1d4ed8;
|
color: var(--brand-700);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -842,9 +903,9 @@ td {
|
|||||||
|
|
||||||
.activity-icon {
|
.activity-icon {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #dbeafe;
|
background: var(--brand-100);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #1d4ed8;
|
color: var(--brand-700);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -881,14 +942,14 @@ td {
|
|||||||
|
|
||||||
.comment-box {
|
.comment-box {
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-box:focus-within {
|
.comment-box:focus-within {
|
||||||
border-color: #93c5fd;
|
border-color: var(--brand-300);
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
|
box-shadow: 0 0 0 3px rgba(74, 108, 247, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Squire editor */
|
/* Squire editor */
|
||||||
@@ -1041,7 +1102,7 @@ td {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.activity-item .rich-content a {
|
.activity-item .rich-content a {
|
||||||
color: #1d4ed8;
|
color: var(--brand-700);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.activity-item .rich-content ul,
|
.activity-item .rich-content ul,
|
||||||
@@ -1068,16 +1129,14 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Task Modal — Plane style */
|
/* Task Modal — Plane style */
|
||||||
.task-modal { display: none; }
|
|
||||||
.task-modal.active { display: block; }
|
|
||||||
.task-overlay {
|
.task-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(15,23,42,0.35); z-index: 200;
|
position: fixed; inset: 0; background: rgba(15,23,42,0.35); z-index: 200;
|
||||||
display: flex; align-items: flex-start; justify-content: center;
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
padding-top: 48px; overflow-y: auto;
|
padding-top: 48px; overflow-y: auto;
|
||||||
}
|
}
|
||||||
.task-panel {
|
.task-panel {
|
||||||
background: #fff; border-radius: 12px; width: 1000px; max-width: 96vw;
|
background: #fff; border-radius: var(--radius-lg); width: 1000px; max-width: 96vw;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.12); margin-bottom: 48px;
|
box-shadow: var(--shadow-overlay); margin-bottom: 48px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.task-header {
|
.task-header {
|
||||||
@@ -1087,7 +1146,7 @@ td {
|
|||||||
.task-drawer .task-title { color: #1e293b; font-size: 15px; font-weight: 600; }
|
.task-drawer .task-title { color: #1e293b; font-size: 15px; font-weight: 600; }
|
||||||
.task-close {
|
.task-close {
|
||||||
color: #94a3b8; background: none; border: none; cursor: pointer;
|
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-close:hover { color: #475569; background: #f1f5f9; }
|
||||||
.task-body-wrap, .task-page-wrap { position: relative; overflow: hidden; }
|
.task-body-wrap, .task-page-wrap { position: relative; overflow: hidden; }
|
||||||
@@ -1106,7 +1165,7 @@ td {
|
|||||||
.task-drawer-title { font-size: 15px; font-weight: 600; color: #1e293b; }
|
.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-drawer-form { padding: 16px 20px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
|
||||||
.task-group {
|
.task-group {
|
||||||
background: #fff; border-radius: 8px;
|
background: #fff; border-radius: var(--radius-md);
|
||||||
border: 1px solid #e2e8f0; overflow: hidden;
|
border: 1px solid #e2e8f0; overflow: hidden;
|
||||||
}
|
}
|
||||||
.task-group-hd {
|
.task-group-hd {
|
||||||
@@ -1142,7 +1201,7 @@ td {
|
|||||||
.task-col-badge { color: #64748b; font-size: 12px; white-space: nowrap; width: 90px; text-align: right; }
|
.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-none { color: #94a3b8; font-size: 13px; padding: 12px 14px; text-align: center; border-top: 1px solid #f1f5f9; }
|
||||||
.task-form {
|
.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;
|
padding: 14px; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.task-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 4px; }
|
.task-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 4px; }
|
||||||
@@ -1179,14 +1238,14 @@ td {
|
|||||||
.toast {
|
.toast {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
display: flex; align-items: center; gap: 10px;
|
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;
|
font-size: 13px; font-weight: 500;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||||
animation: toastIn 0.2s ease-out; max-width: 380px;
|
animation: toastIn 0.2s ease-out; max-width: 380px;
|
||||||
}
|
}
|
||||||
.toast.toast-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
.toast.toast-success { background: var(--success-bg); color: #166534; border: 1px solid #bbf7d0; }
|
||||||
.toast.toast-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
.toast.toast-error { background: var(--danger-bg); color: #991b1b; border: 1px solid #fecaca; }
|
||||||
.toast.toast-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
|
.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; }
|
.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 toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
|
||||||
@keyframes toastOut { to { opacity: 0; transform: translateX(20px); } }
|
@keyframes toastOut { to { opacity: 0; transform: translateX(20px); } }
|
||||||
@@ -1216,4 +1275,4 @@ td {
|
|||||||
|
|
||||||
/* 项目树拖拽 */
|
/* 项目树拖拽 */
|
||||||
.project-tree-node.dragging { opacity: 0.4; }
|
.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: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: {
|
||||||
50: '#eff6ff',
|
50: '#f0f3ff',
|
||||||
600: '#2563eb',
|
100: '#dbe3fe',
|
||||||
700: '#1d4ed8'
|
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">
|
<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="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>
|
<span class="text-[10px] text-slate-400 mt-1.5 max-w-[64px] truncate text-center" id="userDisplayName" title=""></span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
@@ -41,10 +63,6 @@
|
|||||||
<div class="w-8 h-px bg-slate-700 my-2"></div>
|
<div class="w-8 h-px bg-slate-700 my-2"></div>
|
||||||
<!-- 导航 Tab 图标 -->
|
<!-- 导航 Tab 图标 -->
|
||||||
<div class="flex flex-col items-center gap-1 w-14" id="sidebarTabs">
|
<div class="flex flex-col items-center gap-1 w-14" id="sidebarTabs">
|
||||||
<div class="sidebar-tab active" data-tab="home" onclick="switchTab('home')" title="部门">
|
|
||||||
<i data-lucide="trending-up" style="width:20px;height:20px"></i>
|
|
||||||
<span class="text-[10px] mt-1">部门</span>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-tab" data-tab="plan" onclick="switchTab('plan')" title="预算">
|
<div class="sidebar-tab" data-tab="plan" onclick="switchTab('plan')" title="预算">
|
||||||
<i data-lucide="calendar-range" style="width:20px;height:20px"></i>
|
<i data-lucide="calendar-range" style="width:20px;height:20px"></i>
|
||||||
<span class="text-[10px] mt-1">预算</span>
|
<span class="text-[10px] mt-1">预算</span>
|
||||||
@@ -84,37 +102,35 @@
|
|||||||
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
|
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
|
||||||
<div class="flex items-center gap-3 w-full">
|
<div class="flex items-center gap-3 w-full">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.37</span></p>
|
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-brand-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">{{ app_version }}</span></p>
|
||||||
<div class="flex items-center gap-4 mt-1">
|
<div class="flex items-center gap-4 mt-1">
|
||||||
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
|
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
|
||||||
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100 rounded-full px-4 py-1.5">
|
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-brand-50 to-indigo-50 border border-brand-100 rounded-full px-4 py-1.5">
|
||||||
<i data-lucide="sparkles" class="text-blue-500" style="width:14px;height:14px"></i>
|
<i data-lucide="sparkles" class="text-brand-500" style="width:14px;height:14px"></i>
|
||||||
<span class="text-xs text-blue-700 font-medium tracking-wide">真看病,看真病,用学术会议,科普,科研,患者管理,帮助医生与患者提升临床治疗</span>
|
<span class="text-xs text-brand-700 font-medium tracking-wide">真看病,看真病,用学术会议,科普,科研,患者管理,帮助医生与患者提升临床治疗</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 来信">
|
<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-blue-500" style="width:18px;height:18px"></i>
|
<i data-lucide="mail" class="text-brand-500" style="width:18px;height:18px"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="px-8 py-6">
|
<main class="px-8 py-6">
|
||||||
<section id="home" class="panel active"></section>
|
|
||||||
<section id="overview_plan" class="panel"></section>
|
<section id="overview_plan" class="panel"></section>
|
||||||
<section id="overview_finance" class="panel"></section>
|
<section id="overview_finance" class="panel"></section>
|
||||||
<section id="projects" class="panel"></section>
|
<section id="projects" class="panel"></section>
|
||||||
<section id="proposals" class="panel"></section>
|
<section id="proposals" class="panel"></section>
|
||||||
<section id="products" class="panel"></section>
|
<section id="products" class="panel"></section>
|
||||||
<section id="performance" class="panel"></section>
|
<section id="performance" class="panel"></section>
|
||||||
<section id="performance" class="panel"></section>
|
|
||||||
<section id="finance" class="panel"></section>
|
<section id="finance" class="panel"></section>
|
||||||
<section id="plan" class="panel"></section>
|
<section id="plan" class="panel active"></section>
|
||||||
</main>
|
</main>
|
||||||
</div><!-- 关闭主内容区 -->
|
</div><!-- 关闭主内容区 -->
|
||||||
</div><!-- 关闭 flex 容器 -->
|
</div><!-- 关闭 flex 容器 -->
|
||||||
<aside id="drawer" class="drawer" aria-hidden="true"></aside>
|
<aside id="drawer" class="drawer" aria-hidden="true"></aside>
|
||||||
<div id="taskModal" class="task-modal"></div>
|
<div id="taskModal"></div>
|
||||||
<!-- 新增项目模态框 -->
|
<!-- 新增项目模态框 -->
|
||||||
<div id="newProjectModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeNewProjectModal()">
|
<div id="newProjectModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeNewProjectModal()">
|
||||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()">
|
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()">
|
||||||
@@ -133,7 +149,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ url_for('static', filename='modules/utils.js') }}"></script>
|
<script src="{{ url_for('static', filename='modules/utils.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='modules/home.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='modules/projects.js') }}"></script>
|
<script src="{{ url_for('static', filename='modules/projects.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='modules/proposals.js') }}"></script>
|
<script src="{{ url_for('static', filename='modules/proposals.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='modules/products.js') }}"></script>
|
<script src="{{ url_for('static', filename='modules/products.js') }}"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user