Compare commits

..

13 Commits

Author SHA1 Message Date
mac
89810942cd fix: P0 安全加固(移除硬编码弱默认 + 占位符替换修正)
- backend/db.py:db() 的 DB_PASSWORD 改用 _require_env 强制读取,删除弱默认兜底
- backend/db.py:新增 _convert_placeholders 状态机替换 ? 占位符(跳过字符串字面量),并校验占位符/参数数量,替换原 sql.replace 全局替换脆弱点
- backend/flask_app.py:secret_key 改用 _require_env 强制读取,删除可预测弱默认,防止 session 伪造
- .gitea/workflows/deploy.yml:新增 2.5 步,发布时若线上 shared/.env 缺 DB_PASSWORD/SECRET_KEY 则从 CI secrets 自动补齐(已存在则保留),确保 P0 改动上线后可正常启动
2026-07-13 14:57:41 +08:00
mac
6b3198d36f style: 预算模块表头改名+应付列顺序调整
- 当期成本→成本, 当期流出→支出
- 应付列移至成本/支出之后
- v1.2.0.41
2026-07-13 13:47:56 +08:00
mac
8e688efb62 fix: 修正表格列头与内容对齐问题
- plan.js应付列移到成本/支出之前与表头一致
- plan.js theadCols 14→15匹配实际列数
- plan.js/finance.js UNSIGNED_COLS清理残留client_name和多余逗号
- v1.2.0.40
2026-07-13 13:41:48 +08:00
mac
7c9ee77992 feat: 项目表格右键标红+去客户列+全年默认
- 移除客户列, 右键菜单标红/标黄/置顶/清除(最终仅保留标红)
- localStorage持久化标红状态, 刷新不丢失
- 默认全年视图+当前年份
- v1.2.0.39
2026-07-13 09:15:18 +08:00
mac
2340c69e26 feat: 预算模块新增应付列+全年视图+默认值
- 预算已签项目表新增应付列(当期成本-当期流出)
- 已发生/预算筛选区增加全年按钮, 移除已发生总视图
- 默认选中全年视图, 年份默认当前年份
- 版本号 v1.2.0.38
2026-07-12 21:48:12 +08:00
mac
983fd439d9 style: 汇总区卡片1x9横排三组+竖线分隔
- 9卡片flex横排, 分3组(淡蓝/淡黄/淡绿), 竖线分隔
- 卡片标题改为深灰色
- 版本号 v1.2.0.37
2026-07-12 17:23:28 +08:00
mac
cae6bcf0e1 style: 汇总区3x3卡片按行配色+单行展示
- 9卡片改为3x3网格: 签约/确收/毛利 | 回款/支出/现金流 | 应收/应付/往来余额
- 同卡改为单行显示(指标名+金额)
- 按行配色: 第1行淡蓝/第2行淡黄/第3行淡绿
- 版本号 v1.2.0.36
2026-07-12 17:13:06 +08:00
mac
ce7152ab6a feat: 项目管理重构汇总区+新增应付列+公式卡片
- 汇总区改为7指标卡片(签约/确收/毛利/应收/应付/现金流/往来余额)
- 表格新增应付列(成本-支出), 待回款→应收, 剩余应付→应付
- 每张卡片增加计算公式说明
- 汇总区与表格拆分为独立卡片
- 金额改为整数显示(不保留小数)
- 版本号 v1.2.0.35
2026-07-12 17:05:03 +08:00
mac
e7bf679255 feat: 项目管理汇总区改为7指标卡片
- 替换旧指标卡片为: 签约金额/确收金额/毛利/应收/应付/现金流/项目净资产
- 2×4网格布局(card样式), 含指标名称和金额
- 指标数值基于当前筛选结果实时计算
- 版本号 v1.2.0.34
2026-07-12 16:47:25 +08:00
mac
c5be3ce134 style: 总览模块所有表格指标列固定宽度100px
- thead和tbody中指标列添加min-width:100px
- 覆盖月报/季报/损益/现金流/项目5个模块
- 版本号 v1.2.0.33
2026-07-11 15:43:43 +08:00
mac
14368e83d0 fix: 月报/季报总览合同金额显示为0
- 后端新增sign_monthly[12]按月份拆分签单数据
- 月报/季报API均导出sign_monthly,季度转换包含sign_monthly
- 前端projVals首项改用sign_monthly替代硬编码0
- 版本号 v1.2.0.32
2026-07-11 15:39:35 +08:00
mac
63ddffb12a fix: 补全finance/plan筛选器控制函数
- 新增setFinYear/setPlanYear等年份/季度/月度/视图切换函数
- 新增switchFinFilter/switchPlanFilter tab切换函数
- 新增setPlanStatusFilter状态筛选函数
- 版本号 v1.2.0.31
2026-07-11 15:27:12 +08:00
mac
2c7cd21095 fix: 待回款列移至回款后面,正数显示+前缀
- finance.js/plan.js SIGNED_COLS: 待回款列移到回款与回款率之间
- 行渲染同步调序, 补回意外删除的cost/paid/cf列定义
- 正数待回款显示为+金额
- 版本号 v1.2.0.30
2026-07-11 15:23:16 +08:00
9 changed files with 335 additions and 142 deletions

View File

@@ -38,6 +38,28 @@ jobs:
--exclude='.gitea' \
"${CLONE_DIR}/" "${RELEASE_DIR}/"
echo "=== 2.5 Ensure .env secrets ==="
# .env 不随 git 走,线上凭据由服务器本地 shared/.env 提供。
# 若缺失则从 CI secrets 自动补齐;已存在的变量保留,避免覆盖线上配置/导致 session 失效。
mkdir -p "${DEPLOY_BASE}/shared"
SHARED_ENV="${DEPLOY_BASE}/shared/.env"
touch "${SHARED_ENV}"
ensure_env_var() {
key="$1"; val="$2"
if [ -z "$val" ]; then
echo " ! ${key} 未提供(CI secret 为空),跳过写入"
return
fi
if ! grep -q "^${key}=" "${SHARED_ENV}"; then
echo "${key}=${val}" >> "${SHARED_ENV}"
echo " + 已写入 ${key}"
else
echo " = ${key} 已存在,保留原值"
fi
}
ensure_env_var "DB_PASSWORD" "${{ secrets.OPC_DB_PASSWORD }}"
ensure_env_var "SECRET_KEY" "${{ secrets.OPC_SECRET_KEY }}"
echo "=== 3. Link shared resources ==="
mkdir -p "${RELEASE_DIR}/data"
# .env from shared dir (not in git)

View File

@@ -39,12 +39,22 @@ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# ---------- 数据库连接 ----------
def _require_env(name):
"""强制读取必需环境变量,缺失即抛出清晰错误(禁止弱默认降级)"""
val = os.environ.get(name)
if not val:
raise RuntimeError(
f"缺少必需的环境变量 {name},请在 .env 中显式配置(关键凭据禁止硬编码兜底)"
)
return val
def db():
return mysql.connector.connect(
host=os.environ.get("DB_HOST", "127.0.0.1"),
port=int(os.environ.get("DB_PORT", "3306")),
user=os.environ.get("DB_USER", "opc"),
password=os.environ.get("DB_PASSWORD", "opc123456"),
password=_require_env("DB_PASSWORD"),
database=os.environ.get("DB_NAME", "opc"),
charset="utf8mb4",
collation="utf8mb4_unicode_ci",
@@ -55,10 +65,44 @@ def now():
return datetime.utcnow().isoformat()
def _convert_placeholders(sql):
"""将 ? 占位符转为 MySQL 的 %s,仅替换字符串字面量之外的 ?。
避免 `sql.replace('?', '%s')` 的全局替换缺陷:当 SQL 字面量或 LIKE 模式
中恰好含 `?` 时会误替换、导致参数错位。这里用状态机跳过单/双引号内的内容。
"""
out = []
in_single = in_double = False
i, n = 0, len(sql)
while i < n:
ch = sql[i]
if ch == "\\" and i + 1 < n: # 跳过转义序列
out.append(ch)
out.append(sql[i + 1])
i += 2
continue
if ch == "'" and not in_double:
in_single = not in_single
elif ch == '"' and not in_single:
in_double = not in_double
out.append("%s" if (ch == "?" and not in_single and not in_double) else ch)
i += 1
return "".join(out)
def _exec(conn, sql, args=()):
"""执行 SQL自动将 ? 转为 MySQL 的 %s"""
"""执行 SQL将 ? 占位符安全地转为 MySQL 的 %s
占位符数量与参数数量必须一致,否则立即报错,防止静默错位。
"""
converted = _convert_placeholders(sql)
n_ph = converted.count("%s")
if n_ph != len(args):
raise ValueError(
f"SQL 占位符数量({n_ph})与参数数量({len(args)})不匹配,请检查 SQL 与传参: {sql}"
)
cur = conn.cursor(dictionary=True)
cur.execute(sql.replace("?", "%s"), args)
cur.execute(converted, args)
return cur

View File

@@ -19,7 +19,14 @@ app = Flask(
template_folder=str(ROOT / "templates"),
static_folder=str(ROOT / "static"),
)
app.secret_key = os.environ.get("SECRET_KEY", "opc-dev-secret-2026")
# secret_key 必须从环境变量注入,禁止硬编码兜底——否则 session 可被伪造。
_secret_key = os.environ.get("SECRET_KEY")
if not _secret_key:
raise RuntimeError(
"缺少必需的环境变量 SECRET_KEY请在 .env 中配置(建议: "
"python3 -c 'import secrets;print(secrets.token_hex(32))'"
)
app.secret_key = _secret_key
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.register_blueprint(bp)

View File

@@ -1093,6 +1093,7 @@ def _aggregate_monthly_from_json(conn, table, tenants):
_year = date.today().year
months = [f"{_year}-{m:02d}" for m in range(1, 13)]
rev, gross, payment, cost, paid = [0]*12, [0]*12, [0]*12, [0]*12, [0]*12
sign_monthly = [0]*12
sign_total = 0
sign_cnt = 0
@@ -1103,6 +1104,10 @@ def _aggregate_monthly_from_json(conn, table, tenants):
if sm.startswith(str(_year)):
sign_total += float(pf.get("sign_amount") or 0)
sign_cnt += 1
# 签单按月拆分
sm7 = sm[:7]
if sm7 in months:
sign_monthly[months.index(sm7)] += float(pf.get("sign_amount") or 0)
try:
bd = json.loads(pf.get("budget_data") or "[]")
except:
@@ -1130,6 +1135,7 @@ def _aggregate_monthly_from_json(conn, table, tenants):
"rev": rev, "gross": gross, "payment": payment, "cost": cost, "paid": paid,
"cashflow": [payment[i] - paid[i] for i in range(12)],
"sign_total": sign_total, "sign_cnt": sign_cnt,
"sign_monthly": sign_monthly,
}
@@ -1181,6 +1187,7 @@ def _build_overview_result(agg, exp_amount, exp_incurred):
# 项目经营详情
project = {
"sign": agg["sign_total"],
"sign_monthly": agg["sign_monthly"],
"revenue": rev,
"gross": gross,
"cost": cost,
@@ -1218,7 +1225,7 @@ def overview_plan_quarterly():
conn.close()
# Convert monthly to quarterly
result = _build_overview_result(agg, exp_amount, exp_incurred)
for key in ["revenue","gross","payment","cost","paid","cashflow"]:
for key in ["sign_monthly","revenue","gross","payment","cost","paid","cashflow"]:
result["project"][key] = _quarterly_from_monthly(result["project"][key])
result["dept"]["project_gross"] = _quarterly_from_monthly(result["dept"]["project_gross"])
result["dept"]["dept_expense"] = _quarterly_from_monthly(result["dept"]["dept_expense"])
@@ -1247,7 +1254,7 @@ def overview_finance_quarterly():
exp_amount, exp_incurred = _aggregate_expenses(conn, "expense_records", OVERVIEW_TENANTS)
conn.close()
result = _build_overview_result(agg, exp_amount, exp_incurred)
for key in ["revenue","gross","payment","cost","paid","cashflow"]:
for key in ["sign_monthly","revenue","gross","payment","cost","paid","cashflow"]:
result["project"][key] = _quarterly_from_monthly(result["project"][key])
result["dept"]["project_gross"] = _quarterly_from_monthly(result["dept"]["project_gross"])
result["dept"]["dept_expense"] = _quarterly_from_monthly(result["dept"]["dept_expense"])

View File

@@ -1,7 +1,7 @@
// finance.js — 经营管理(财务)模块
const moneyInt = (v) => `${Math.round(Number(v || 0)).toLocaleString("zh-CN")}`;
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(1)}`;
const moneyWan = (v) => `${(Number(v || 0) / 10000).toFixed(0)}`;
function renderFinance() {
const pfs = state.data.projectFinances || [];
@@ -98,20 +98,22 @@ function renderFinance() {
})();
const sm = pf.sign_month || "";
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); editPfSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm 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 ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle">${esc(pf.customer_name)}</td>${signMonthCell}<td class="px-2 py-px text-center align-middle text-sm">${money(pf.sign_amount)}</td>${mCols}${totalCol}</tr>`;
};
const now2 = new Date();
const defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
if (!state.finMonth) state.finMonth = defaultMonth2;
if (state.finQuarter === undefined) state.finQuarter = Math.floor(now2.getMonth() / 3);
if (!state.finYear) state.finYear = now2.getFullYear();
if (!state.finView) state.finView = 'annual';
const qLabels = ['Q1','Q2','Q3','Q4'];
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
const nowD = new Date();
const activeQ = state.finQuarter !== undefined ? state.finQuarter : Math.floor(nowD.getMonth() / 3);
const activeMonths = qMonths[activeQ];
const yearOpts = [2024,2025,2026,2027];
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="setFinView('overview')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='overview'?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">总视图</button><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">年份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 rounded text-sm font-medium border border-slate-200 bg-white">${yearOpts.map(y => `<option value="${y}" ${state.finYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">季度:</span>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">月度:</span>${activeMonths.map(m => { const ms = `${state.finYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
const finHeaderBase = `<div class="flex items-center gap-2"><span class="text-sm text-slate-500">年份:</span><select onchange="setFinYear(this.value)" class="px-2 py-0.5 rounded text-sm font-medium border border-slate-200 bg-white">${yearOpts.map(y => `<option value="${y}" ${state.finYear===y?'selected':''}>${y}</option>`).join('')}</select><span class="text-slate-300 mx-2">|</span><button onclick="setFinView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='annual'?'bg-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>${qLabels.map((q,i) => `<button onclick="setFinQuarter(${i})" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='quarterly'&&activeQ===i?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${q}</button>`).join('')}<span class="text-slate-300 mx-2">|</span><span class="text-sm text-slate-500">月度:</span>${activeMonths.map(m => { const ms = `${state.finYear}-${String(m).padStart(2,'0')}`; return `<button onclick="setFinMonth('${ms}')" class="px-3 py-0.5 rounded text-sm font-medium ${state.finView==='monthly'&&state.finMonth===ms?'bg-blue-600 text-white':'bg-slate-100 text-slate-600 hover:bg-slate-200'}">${m}月</button>`; }).join('')}</div>`;
const finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openFinanceModal()">新增财务项目</button>`;
const isOverviewMode = false;
@@ -124,7 +126,7 @@ function renderFinance() {
</div>`}
${finFilterTabs}
${state.finFilter !== 'expense' && state.finFilter !== 'overview' && state.finFilter !== 'quarterlyOverview' ? '' : ''}
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-600 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
<button class="finance-tab" data-tab="revpay" onclick="switchFinanceTab('revpay')"><i data-lucide="dollar-sign" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>确收与回款</button>
@@ -253,18 +255,6 @@ function renderFinance() {
return `<div id="financeExpense"></div>`;
}
const isUnsigned = false;
const METRIC_CARDS = [
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
{ label: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: v < 0 ? '超额回款 ' + moneyWan(Math.abs(v)) : moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
{ label: '成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
{ label: '支出', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
].filter(c => true);
// 列排序(默认按确收金额从高到低)
if (!state.finSort) state.finSort = { key: 'sig|rev', asc: false };
@@ -295,36 +285,45 @@ function renderFinance() {
};
const SIGNED_COLS = [
{ 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: 'payment', label: '回款' }, { key: 'pay_rate', label: '回款率' },
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'paid_rate', label: '付款率' },
{ key: 'payment', label: '回款' }, { key: 'pending', label: '应收' }, { key: 'pay_rate', label: '回款率' },
{ key: 'cost', label: '成本' }, { key: 'paid', label: '支出' }, { key: 'payable', label: '应付' }, { key: 'paid_rate', label: '付款率' },
{ key: 'cashflow', label: '现金流' },
{ key: 'pending', label: '待回款' },
];
const UNSIGNED_COLS = [
{ key: 'client_name', label: '客户' }, { key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
{ key: 'customer_name', label: '项目名称' }, { key: 'sign_amount', label: '签约金额' },
{ key: 'sign_month', label: '签约月份' }, { key: 'gross', label: '毛利' }, { key: 'cost', label: '成本' },
,
];
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt };
const cards = METRIC_CARDS.map(c => {
const v = c.value(ctx);
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
});
const receivable = sumRev - sumPay;
const payable = sumCost - sumPaid;
const cashflow = sumPay - sumPaid;
const netAsset = receivable - payable;
const m = moneyWan;
const rows_data = [
['签约金额', m(signTotal), '∑签约金额'],
['确收金额', m(sumRev), '∑确收金额'],
['毛利', m(sumGross), '∑毛利'],
['回款', m(sumPay), '∑回款金额'],
['支出', m(sumPaid), '∑支出金额'],
['现金流', m(cashflow), '回款 支出'],
['应收', m(receivable), '确收 回款'],
['应付', m(payable), '成本 支出'],
['往来余额', m(netAsset), '应收 应付'],
];
const cardHtml = (r, i) => '<div class="card px-2 py-1.5 text-center flex-1" style="background:' + (['#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 fs = '<div class="flex gap-1 items-stretch py-2">' + cardHtml(rows_data[0],0) + cardHtml(rows_data[1],1) + cardHtml(rows_data[2],2) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[3],3) + cardHtml(rows_data[4],4) + cardHtml(rows_data[5],5) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[6],6) + cardHtml(rows_data[7],7) + cardHtml(rows_data[8],8) + '</div>';
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
const theadCols = isUnsigned ? 6 : 12;
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
const theadCols = isUnsigned ? 5 : 14;
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-700">${emptyText}</td></tr>`;
const subtitle = isUnsigned
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流 → 执行率 → 毛利率 → 回款率 → 付款率';
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
`${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流';
return `${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
card(fs, "px-4 py-2") +
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-600 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
};
const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>';
@@ -335,12 +334,15 @@ function renderFinance() {
const payRCls = payRVal === null ? '' : payRVal < 30 ? 'text-red-600' : payRVal < 80 ? 'text-amber-600' : 'text-green-600';
const payR = payRVal !== null ? '<span class="' + payRCls + ' font-semibold">' + payRVal + '%</span>' : '<span class="text-slate-300">—</span>';
const paidR = cost && paid ? Math.round(paid / cost * 100) + '%' : '<span class="text-slate-300">—</span>';
const payable = cost - paid;
const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-700';
const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>';
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const pending = rev - payment;
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-400';
const pendingVal = pending ? money(pending) : '<span class="text-slate-300">—</span>';
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700';
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
var budgetArr = [];
var expenseArr = [];
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
@@ -361,13 +363,13 @@ function renderFinance() {
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
var bCf = bPay - ePaid;
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
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>';
}).join('');
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-center align-middle text-sm truncate" style="max-width:100px" title="${esc(pf.client_name || '')}">${esc(pf.client_name || "")}</td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-blue-700 align-middle">${fmtNoUnit(rev)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center align-middle text-sm">${grossR}</td><td class="px-2 py-px text-center text-amber-700 align-middle">${fmtNoUnit(payment)}</td><td class="px-2 py-px text-center align-middle text-sm">${payR}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm">${paidR}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="fin_expand_${pf.id}"><td colspan="14"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
return `<tr class="border-b ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();finToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="fin_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-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="14"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">支出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
return `<tr class="border-b ${state._fmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="finShowCtx(event, ${pf.id})" onclick="openPfEditModal(${pf.id})"><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center align-middle text-sm">${pf.sign_month || '—'}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${fmtNoUnit(gross)}</td><td class="px-2 py-px text-center text-rose-700 align-middle">${fmtNoUnit(cost)}</td></tr>`;
};
if (state.finView === 'monthly') {
@@ -615,7 +617,7 @@ window.addTaskRow = (taskMonth = '', taskType = '', taskCount = '', executedCoun
const isPreset = TASK_TYPES.includes(taskType);
const typeCell = isPreset || !taskType
? `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="onTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option ${t === taskType ? 'selected' : ''}>${t}</option>`).join("")}<option value="__custom__" ${!isPreset && taskType ? 'selected' : ''}>自定义...</option></select>`
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-700 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
row.innerHTML = `<td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full">${taskMonthOptions(taskMonth || defaultMonth)}</select></td>
<td class="flex items-center">${typeCell}</td>
<td><input name="task_count[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${taskCount}" oninput="updateTaskDiff(this)"></td>
@@ -677,7 +679,7 @@ window.onTaskTypeChange = (sel) => {
if (sel.value !== '__custom__') return;
const td = sel.parentElement;
const oldVal = sel.value;
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-700 p-0 w-5 h-5 ml-1" onclick="revertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
if (window.lucide) window.lucide.createIcons();
td.querySelector('input').focus();
};
@@ -741,7 +743,7 @@ async function loadFinFollowups(pfId) {
const fups = await api(`/api/followups/project_finance/${pfId}`);
list.innerHTML = fups.length
? fups.map(f => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" type="button" onclick="deleteFinFollowup(event, ${f.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")
: '<p class="text-sm text-slate-400 py-4 text-center">暂无跟进记录</p>';
: '<p class="text-sm text-slate-700 py-4 text-center">暂无跟进记录</p>';
if (window.lucide) window.lucide.createIcons();
list.querySelectorAll(".rich-content").forEach(el => {
const html = el.dataset.html;
@@ -955,7 +957,8 @@ window.createFinance = async (event) => {
document.querySelector("#financeModalTitle").textContent = "新增项目财务";
closeFinanceModal();
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
renderFinance();
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance();
} catch (error) {
toast("保存失败:" + error.message, "error");
}
@@ -971,7 +974,8 @@ window.deleteFinanceItem = async () => {
await api(`/api/projectFinances/${pfId}`, { method: "DELETE" });
closeFinanceModal();
state.data.projectFinances = await api("/api/projectFinances/list?tenant=" + encodeURIComponent(state.tenant));
renderFinance();
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance();
toast("已删除", "success");
} catch (error) {
toast("删除失败:" + error.message, "error");
@@ -1085,9 +1089,9 @@ function renderFinanceOverview() {
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') +
'<div class="grid grid-cols-3 gap-2.5">' +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartRev"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与支出</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartRev"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与支出</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="finChartCash"></canvas></div>', 'p-4') +
'</div></div>';
if (window.lucide) window.lucide.createIcons();
@@ -1126,9 +1130,9 @@ function renderFinanceQuarterlyOverview() {
}
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
thead += '</tr>';
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
var tbody = '';
@@ -1159,7 +1163,7 @@ function renderFinanceQuarterlyOverview() {
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
var grossAnnual = m.gross_annual || 0;
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-400 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>';
var deptRows = [
{ label: '项目毛利', vals: grossQ, annual: grossAnnual },
@@ -1249,3 +1253,53 @@ function renderOverviewCharts(data) {
state.finChart4 = new Chart(c4, Object.assign({}, iconf, { data: { labels: labels, datasets: [{ label: "利润", data: data.map(function(x) { return x.gross || 0; }), borderColor: "#6366f1", backgroundColor: "rgba(99,102,241,0.06)", fill: true, tension: 0.3 }] } }));
}
};
// ====== 筛选器控制函数 ======
window.switchFinFilter = (f) => { state.finFilter = f; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance(); };
window.setFinView = (v) => { state.finView = v; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance(); };
window.setFinYear = (y) => { state.finYear = parseInt(y); localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance(); };
window.setFinQuarter = (q) => { state.finView = 'quarterly'; state.finQuarter = q; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance(); };
window.setFinMonth = (m) => { state.finView = 'monthly'; state.finMonth = m; localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance(); };
// ---- 项目标记上下文菜单 ----
if (!state._fmMarks) state._fmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_fm_marks')) || { red: {} }; } catch(e) { return { red: {} }; } })();
window.finShowCtx = function(e, pfId) {
e.preventDefault();
var menu = document.getElementById('finCtxMenu');
if (!menu) {
menu = document.createElement('div');
menu.id = 'finCtxMenu';
menu.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.15);border:1px solid #e2e8f0;padding:4px 0;min-width:130px';
menu.innerHTML = '<div style="padding:4px 12px;font-size:11px;font-weight:600;color:#94a3b8">项目标记</div>' +
'<div data-act="red" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;margin-right:8px"></span>标红</div>';
menu.addEventListener('click', function(ev) {
ev.stopPropagation();
var id = menu.getAttribute('data-pfid');
menu.style.display = 'none';
if (state._fmMarks.red[id]) { delete state._fmMarks.red[id]; } else { state._fmMarks.red[id] = true; }
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance();
});
document.body.appendChild(menu);
}
menu.setAttribute('data-pfid', pfId);
menu.style.display = 'block';
menu.style.left = e.pageX + 'px';
menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px';
};
document.addEventListener('click', function() { var m = document.getElementById('finCtxMenu'); if (m) m.style.display = 'none'; });
window.finCtxMenu_act = function(e, type) {
e.stopPropagation();
var pfId = document.getElementById("finCtxMenu").getAttribute("data-pfid");
if (type === "clear") { delete state._fmMarks.red[pfId]; delete state._fmMarks.pin[pfId]; }
else { if (state._fmMarks[type][pfId]) { delete state._fmMarks[type][pfId]; } else { state._fmMarks[type][pfId] = true; } }
localStorage.setItem('opc_fm_marks', JSON.stringify(state._fmMarks));
renderFinance();
};

View File

@@ -57,11 +57,11 @@ function renderFinanceProjectMatrixData(data, container) {
var vBtns = financeBtnBar('project_matrix');
var cards = '';
for (var mi = 11; mi >= 0; mi--) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
if (isAllZero(data, tenants, keys.filter(function(k) { return k !== 'sign'; }), mi)) continue;
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
@@ -87,7 +87,7 @@ function financeProjectMatrixTbody(data, tenants, mi, keys, labels, mf) {
var tCls = 'text-slate-700';
if (ri === 2) tCls = 'text-green-600';
if (ri === keys.length - 1) tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
for (var ti = 0; ti < tenants.length; ti++) {
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
@@ -118,7 +118,7 @@ function renderOverviewFinanceData(data, view) {
btns.push('<button onclick="setOverviewFinanceView(\'project_matrix\')" class="finance-tab' + (view === 'project_matrix' ? ' active' : '') + '" style="font-size:14px;font-weight:600">项目经营详细报表</button>');
var viewBtns = '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</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" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var hi = 0; hi < cols; hi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[hi] + '</th>'; }
deptThead += '</tr>';
@@ -130,7 +130,7 @@ function renderOverviewFinanceData(data, view) {
var deptTbody = '';
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri], rowCls = dri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + 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" style="min-width:100px">' + dr.label + '</td>';
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
for (var i = 0; i < cols; i++) {
@@ -149,7 +149,7 @@ function renderOverviewFinanceData(data, view) {
var cfTbody = '';
for (var cfri = 0; cfri < 3; cfri++) {
var cfr = cfRows[cfri], cfRowCls = cfri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfr.label + '</td>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + cfr.label + '</td>';
var cAnnualCls = cfri === 2 ? (cfr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cAnnualCls + ' whitespace-nowrap">' + moneyIntL(cfr.annual) + '</td>';
for (var i = 0; i < cols; i++) {
@@ -161,17 +161,17 @@ function renderOverviewFinanceData(data, view) {
var cfCard = card('<h3 class="text-base font-bold text-slate-700 mb-3">部门现金流 <span class="text-sm text-slate-500 font-normal ml-3">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</span></h3><div class="overflow-x-auto"><table class="w-full text-sm"><thead>' + deptThead + '</thead><tbody>' + cfTbody + '</tbody></table></div>', 'p-4');
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
var projVals = [p.revenue, p.gross, p.cost, p.paid, p.payment, p.cashflow];
var projVals = [p.sign_monthly || [], p.revenue, p.gross, p.cost, p.paid, p.payment, p.cashflow];
var projAnnual = p.annual;
var projThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
var projThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var phi = 0; phi < cols; phi++) { projThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[phi] + '</th>'; }
projThead += '</tr>';
var projTbody = '';
for (var ri = 0; ri < 7; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + (ri === 0 ? moneyIntL(p.sign) : moneyIntL(projAnnual[ri])) + '</td>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap" style="min-width:100px">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(projAnnual[ri]) + '</td>';
for (var i = 0; i < cols; i++) {
var val = ri === 0 ? 0 : (projVals[ri - 1] ? projVals[ri - 1][i] : 0);
var val = projVals[ri] ? (projVals[ri][i] || 0) : 0;
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
@@ -206,7 +206,7 @@ function renderFinanceDeptMatrixData(data, container) {
var cards = '';
for (var mi = 11; mi >= 0; mi--) {
if (isAllZero(data, tenants, ['project_gross','dept_expense','profit'], mi)) continue;
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
var tb = financeMatrixTbody(data, tenants, mi, ['project_gross','dept_expense','profit'], ['项目毛利','部门费用','利润'], mf);
@@ -235,7 +235,7 @@ function renderFinanceCfMatrixData(data, container) {
var cards = '';
for (var mi = 11; mi >= 0; mi--) {
if (isAllZero(data, tenants, ['project_cf','dept_cf','bl_cf'], mi)) continue;
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
var tb = financeMatrixTbody(data, tenants, mi, ['project_cf','dept_cf','bl_cf'], ['项目现金流','部门现金流','经营现金流'], mf);
@@ -269,7 +269,7 @@ function financeMatrixTbody(data, tenants, mi, keys, labels, mf) {
vals.push(v);
}
var tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
for (var ti = 0; ti < tenants.length; ti++) {
var cls = vals[ti] >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(vals[ti]) + '</td>';

View File

@@ -45,12 +45,12 @@ function renderProjectMatrixData(data, container) {
var vBtns = btnBar('project_matrix');
var cards = '';
for (var mi = 11; mi >= 0; mi--) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
if (isAllZero(data, tenants, keys.filter(function(k) { return k !== 'sign'; }), mi)) continue;
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
@@ -76,7 +76,7 @@ function projectMatrixTbody(data, tenants, mi, keys, labels, mf) {
var tCls = 'text-slate-700';
if (ri === 2) tCls = 'text-green-600';
if (ri === keys.length - 1) tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
for (var ti = 0; ti < tenants.length; ti++) {
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
@@ -113,7 +113,7 @@ function renderOverviewPlanData(data, view) {
var viewBtns = '<div class="finance-tabs" style="padding:0;border-bottom:1px solid #e2e8f0;margin-bottom:12px">' + btns.join('') + '</div>';
// ====== 部门损益情况 ======
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</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" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var hi = 0; hi < cols; hi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[hi] + '</th>'; }
deptThead += '</tr>';
@@ -126,7 +126,7 @@ function renderOverviewPlanData(data, view) {
for (var dri = 0; dri < 3; dri++) {
var dr = deptRows[dri];
var rowCls = dri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + dr.label + '</td>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + dr.label + '</td>';
var annualCls = dri === 2 ? (dr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + rowCls + ' ' + annualCls + ' whitespace-nowrap">' + moneyIntL(dr.annual) + '</td>';
for (var i = 0; i < cols; i++) {
@@ -147,7 +147,7 @@ function renderOverviewPlanData(data, view) {
for (var cfri = 0; cfri < 3; cfri++) {
var cfr = cfRows[cfri];
var cfRowCls = cfri === 2 ? 'font-semibold' : '';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap">' + cfr.label + '</td>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + cfRowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + cfr.label + '</td>';
var cAnnualCls = cfri === 2 ? (cfr.annual >= 0 ? 'text-green-600' : 'text-red-600') : 'text-slate-700';
rowHtml += '<td class="py-1.5 text-right ' + cfRowCls + ' ' + cAnnualCls + ' whitespace-nowrap">' + moneyIntL(cfr.annual) + '</td>';
for (var i = 0; i < cols; i++) {
@@ -160,19 +160,19 @@ function renderOverviewPlanData(data, view) {
// ====== 项目经营详情 ======
var LABELS = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
var projVals = [p.revenue, p.gross, p.cost, p.paid, p.payment, p.cashflow];
var projVals = [p.sign_monthly || [], p.revenue, p.gross, p.cost, p.paid, p.payment, p.cashflow];
var projAnnual = p.annual;
var projThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
var projThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var phi = 0; phi < cols; phi++) { projThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + headers[phi] + '</th>'; }
projThead += '</tr>';
var projTbody = '';
for (var ri = 0; ri < 7; ri++) {
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + (ri === 0 ? moneyIntL(p.sign) : moneyIntL(projAnnual[ri])) + '</td>';
var rowHtml = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left font-semibold text-slate-700 whitespace-nowrap" style="min-width:100px">' + LABELS[ri] + '</td>';
rowHtml += '<td class="py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap">' + moneyIntL(projAnnual[ri]) + '</td>';
for (var i = 0; i < cols; i++) {
var val = ri === 0 ? 0 : (projVals[ri - 1] ? projVals[ri - 1][i] : 0);
var val = projVals[ri] ? (projVals[ri][i] || 0) : 0;
var cls = 'text-slate-700';
if (ri === 2) cls = 'text-green-600';
if (ri === 6) cls = val >= 0 ? 'text-green-600' : 'text-red-600';
@@ -208,7 +208,7 @@ function renderDeptMatrixData(data, container) {
var cards = '';
for (var mi = 11; mi >= 0; mi--) {
if (isAllZero(data, tenants, ['project_gross','dept_expense','profit'], mi)) continue;
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
@@ -238,12 +238,12 @@ function renderCfMatrixData(data, container) {
var vBtns = btnBar('cf_matrix');
var cards = '';
for (var mi = 11; mi >= 0; mi--) {
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
if (isAllZero(data, tenants, ['project_cf','dept_cf','bl_cf'], mi)) continue;
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
var th = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap" style="min-width:100px">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">月度合计</th>';
for (var ti = 0; ti < tenants.length; ti++) { th += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">' + sn[ti] + '</th>'; }
th += '</tr>';
@@ -277,7 +277,7 @@ function matrixTbody(data, tenants, mi, keys, labels, mf) {
vals.push(v);
}
var tCls = ts >= 0 ? 'text-green-600' : 'text-red-600';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
var rh = '<tr class="border-b border-slate-100 last:border-0"><td class="py-1.5 text-left ' + rowCls + ' text-slate-700 whitespace-nowrap" style="min-width:100px">' + labels[ri] + '</td><td class="py-1.5 text-right font-semibold text-sm whitespace-nowrap ' + tCls + '">' + mf(ts) + '</td>';
for (var ti = 0; ti < tenants.length; ti++) {
var cls = vals[ti] >= 0 ? 'text-green-600' : 'text-red-600';
rh += '<td class="py-1.5 text-right text-sm whitespace-nowrap ' + cls + '">' + mf(vals[ti]) + '</td>';

View File

@@ -96,20 +96,22 @@ function renderPlan() {
})();
const sm = pf.sign_month || "";
const signMonthCell = `<td class="px-2 py-px text-center align-middle text-sm"><span class="pf-sm-text cursor-pointer hover:text-blue-600" id="pf-sm-${pf.id}" onclick="event.stopPropagation(); planEditSignMonth(event, ${pf.id})">${sm || '—'}</span></td>`;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm 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 defaultMonth2 = now2.getFullYear() + "-" + String(now2.getMonth()+1).padStart(2,"0");
if (!state.planMonth) state.planMonth = defaultMonth2;
if (state.planQuarter === undefined) state.planQuarter = Math.floor(now2.getMonth() / 3);
if (!state.planYear) state.planYear = now2.getFullYear();
if (!state.planView) state.planView = 'annual';
const qLabels = ['Q1','Q2','Q3','Q4'];
const qMonths = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
const nowD = new Date();
const activeQ = state.planQuarter !== undefined ? state.planQuarter : Math.floor(nowD.getMonth() / 3);
const activeMonths = qMonths[activeQ];
const yearOpts = [2024,2025,2026,2027];
const finHeaderBase = `<div class="flex items-center gap-2"><button onclick="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"><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><button onclick="setPlanView('annual')" class="px-3 py-0.5 rounded text-sm font-medium ${state.planView==='annual'?'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>${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 finAddBtn = `<button class="btn btn-primary btn-sm" onclick="openPlanModal()">新增财务项目</button>`;
const isOverviewMode = false;
@@ -123,7 +125,7 @@ function renderPlan() {
${planFilterTabs}
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? '' : (state.planFilter === 'expense' ? '' : '')}
${state.planFilter !== 'expense' && state.planFilter !== 'overview' && state.planFilter !== 'quarterlyOverview' ? '' : ''}
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
<div id="planModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closePlanModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="planModalTitle">新增项目财务</h3><p class="text-xs text-slate-600 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="planDeleteBtn" onclick="deletePlanItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closePlanModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchPlanTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
<button class="finance-tab" data-tab="revpay" onclick="switchPlanTab('revpay')"><i data-lucide="dollar-sign" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>确收与回款</button>
@@ -252,18 +254,7 @@ function renderPlan() {
return `<div id="planExpenseModule"></div>`;
}
const isUnsigned = false;
const METRIC_CARDS = [
{ label: '签约金额', value: ctx => moneyWan(ctx.signTotal), color: 'text-slate-700' },
{ label: '已确收', hideUnsigned: true, value: ctx => moneyWan(ctx.sumRev), color: 'text-blue-700' },
{ label: '待回款', hideUnsigned: true, value: ctx => { const v = ctx.sumRev - ctx.sumPay; return { val: v < 0 ? '超额回款 ' + moneyWan(Math.abs(v)) : moneyWan(v), cls: v > 0 ? 'text-red-600' : v < 0 ? 'text-green-600' : 'text-slate-400' }; }, color: null },
{ label: '毛利', value: ctx => moneyWan(ctx.sumGross), color: 'text-green-700' },
{ label: '当期成本(不含平台费用)', value: ctx => moneyWan(ctx.sumCost), color: 'text-rose-700' },
{ label: '回款', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPay), color: 'text-amber-700' },
{ label: '当期流出', hideUnsigned: true, value: ctx => moneyWan(ctx.sumPaid), color: 'text-purple-700' },
{ label: '现金流(回款-支出)', hideUnsigned: true, value: ctx => { const v = ctx.sumPay - ctx.sumPaid; return { val: moneyWan(v), cls: v >= 0 ? 'text-green-600' : 'text-red-600' }; }, color: null },
{ label: '毛利率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumGross ? Math.round(ctx.sumGross / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
{ label: '回款率', hideUnsigned: true, value: ctx => ctx.sumRev && ctx.sumPay ? Math.round(ctx.sumPay / ctx.sumRev * 100) + '%' : '—', color: 'text-slate-500' },
].filter(c => true);
// 列排序(默认按确收金额从高到低)
if (!state.planSort) state.planSort = { key: 'sig|rev', asc: false };
@@ -297,7 +288,6 @@ function renderPlan() {
};
const SIGNED_COLS = [
{ key: '_expand', label: '' },
{ key: 'client_name', label: '客户' },
{ key: 'customer_name', label: '项目名称' },
{ key: 'weight', label: '加权' },
{ key: 'project_type', label: '项目类型' },
@@ -306,41 +296,52 @@ function renderPlan() {
{ key: 'gross', label: '毛利' },
{ key: 'gross_rate', label: '毛利率' },
{ key: 'payment', label: '回款' },
{ key: 'pending', label: '应收' },
{ key: 'pay_rate', label: '回款率' },
{ key: 'cost', label: '当期成本' },
{ key: 'paid', label: '当期流出' },
{ key: 'cost', label: '成本' },
{ key: 'paid', label: '出' },
{ key: 'payable', label: '应付' },
{ key: 'cashflow', label: '现金流' },
{ key: 'pending', label: '待回款' },
];
const UNSIGNED_COLS = [
{ key: 'client_name', label: '客户' },
{ key: 'customer_name', label: '项目名称' },
{ key: 'weight', label: '加权' },
{ key: 'project_type', label: '项目类型' },
{ key: 'sign_amount', label: '签约金额' },
{ key: 'sign_month', label: '签约月份' },
{ key: 'gross', label: '毛利' },
{ key: 'cost', label: '当期成本' },
{ key: 'cost', label: '成本' },
{ key: 'profit', label: '项目利润' },
];
const renderView = (rows, sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt, emptyText) => {
const ctx = { sumRev, sumPay, sumCost, sumPaid, sumGross, signTotal, signCnt };
const cards = METRIC_CARDS.map(c => {
const v = c.value(ctx);
return typeof v === 'object' ? [c.label, v.val, v.cls] : [c.label, v, c.color];
});
const receivable = sumRev - sumPay;
const payable = sumCost - sumPaid;
const cashflow = sumPay - sumPaid;
const netAsset = receivable - payable;
const m = moneyWan;
const rows_data = [
['签约金额', m(signTotal), '∑签约金额'],
['确收金额', m(sumRev), '∑确收金额'],
['毛利', m(sumGross), '∑毛利'],
['回款', m(sumPay), '∑回款金额'],
['支出', m(sumPaid), '∑支出金额'],
['现金流', m(cashflow), '回款 支出'],
['应收', m(receivable), '确收 回款'],
['应付', m(payable), '成本 支出'],
['往来余额', m(netAsset), '应收 应付'],
];
const cardHtml = (r, i) => '<div class="card px-2 py-1.5 text-center flex-1" style="background:' + (['#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 fs = '<div class="flex gap-1 items-stretch py-2">' + cardHtml(rows_data[0],0) + cardHtml(rows_data[1],1) + cardHtml(rows_data[2],2) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[3],3) + cardHtml(rows_data[4],4) + cardHtml(rows_data[5],5) + '<span class="w-px bg-slate-200 mx-1 self-stretch"></span>' + cardHtml(rows_data[6],6) + cardHtml(rows_data[7],7) + cardHtml(rows_data[8],8) + '</div>';
const thead = isUnsigned ? buildThead(UNSIGNED_COLS, 'uns') : buildThead(SIGNED_COLS, 'sig');
const theadCols = isUnsigned ? 9 : 13;
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-400">${emptyText}</td></tr>`;
const cardGrid = isUnsigned ? 'grid-cols-4' : 'grid-cols-5';
const theadCols = isUnsigned ? 8 : 15;
const tbody = rows.length ? `<tbody style="line-height:1.37">${rows.join("")}</tbody>` : `<tr><td colspan="${theadCols}" class="p-6 text-center text-slate-700">${emptyText}</td></tr>`;
const subtitle = isUnsigned
? '合同 → 毛利 → 成本 → 项目利润'
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 已付 → 现金流';
return `<div class="grid ${cardGrid} gap-1.5 mb-1.5">${cards.map(([l, v, c]) => '<div class="metric-card px-3 py-1.5"><span class="text-xs text-slate-500">' + l + '</span> <strong class="text-base ' + c + '">' + v + '</strong></div>').join("")}</div>` +
`${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-400 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
: '合同 → 确收 → 毛利 → 成本 → 项目利润 → 回款 → 支出 → 现金流';
return `${isUnsigned ? '' : `<div class="mb-1.5">${card(`<div class="flex justify-between items-center"><div class="flex items-center gap-2">${finHeaderBase}</div>${finAddBtn}</div>`, "py-2 px-4")}</div>`}` +
card(fs, "px-4 py-2") +
card(`<h3 class="text-sm font-bold text-slate-700">项目财务明细</h3><p class="text-xs text-slate-600 mb-3">${subtitle}</p><div class="overflow-x-auto"><table class="w-full text-sm">${thead}${tbody}</table></div>`, "p-4");
};
const fmtNoUnit = (v) => v ? `<span class="font-medium">${Number(v||0).toLocaleString('zh-CN')}</span>` : '<span class="text-slate-300">—</span>';
@@ -367,9 +368,12 @@ function renderPlan() {
const grossR = rev && gross ? Math.round(gross / rev * 100) + '%' : '<span class="text-slate-300">—</span>';
const cf = cashflow ? money(cashflow) : '<span class="text-slate-300">—</span>';
const cfCls = cashflow >= 0 ? 'text-green-600' : 'text-red-600';
const payable = cost - paid;
const payableCls = payable > 0 ? 'text-rose-600' : payable < 0 ? 'text-green-600' : 'text-slate-400';
const payableVal = payable ? (payable > 0 ? '+' : '') + money(payable) : '<span class="text-slate-300">—</span>';
const pending = rev - payment;
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-400';
const pendingVal = pending ? money(pending) : '<span class="text-slate-300">—</span>';
const pendingCls = pending > 0 ? 'text-red-600' : pending < 0 ? 'text-green-600' : 'text-slate-700';
const pendingVal = pending ? (pending > 0 ? '+' : '') + money(pending) : '<span class="text-slate-300">—</span>';
var budgetArr = [];
var expenseArr = [];
try { budgetArr = JSON.parse(pf.budget_data || "[]"); } catch(e) {}
@@ -390,16 +394,16 @@ function renderPlan() {
var bRev = parseFloat(b.rev || 0), bGross = parseFloat(b.gross || 0), bPay = parseFloat(b.payment || 0);
var eCost = parseFloat(exp.cost || 0), ePaid = parseFloat(exp.paid || 0);
var bCf = bPay - ePaid;
return '<tr class="bg-slate-50"><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-slate-400">' + m + '</td><td class="px-2 py-px text-center text-xs text-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><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-blue-600">' + (bRev ? money(bRev) : '—') + '</td><td class="px-2 py-px text-center text-xs text-green-600">' + (bGross ? money(bGross) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-amber-600">' + (bPay ? money(bPay) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs text-rose-600">' + (eCost ? money(eCost) : '—') + '</td><td class="px-2 py-px text-center text-xs text-purple-600">' + (ePaid ? money(ePaid) : '—') + '</td><td class="px-2 py-px"></td><td class="px-2 py-px text-center text-xs ' + (bCf >= 0 ? 'text-green-600' : 'text-red-600') + '">' + (bCf ? money(bCf) : '—') + '</td><td class="px-2 py-px text-center text-xs ' + (bRev - bPay > 0 ? 'text-red-600' : bRev - bPay < 0 ? 'text-green-600' : 'text-slate-300') + '">' + (bRev - bPay ? money(bRev - bPay) : '—') + '</td></tr>';
}).join('');
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="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">${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><td class="px-2 py-px text-center align-middle text-sm font-semibold ${pendingCls}">${pendingVal}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">月份</th><th class="px-2 py-1 text-center text-xs text-slate-400">确收</th><th class="px-2 py-1 text-center text-xs text-slate-400">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">当期成本</th><th class="px-2 py-1 text-center text-xs text-slate-400">当期流出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-400">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-400">待回款</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
return `<tr class="border-b ${state._pmMarks.red[pf.id] ? 'bg-red-50' : 'border-slate-100'} hover:bg-slate-50 cursor-pointer" oncontextmenu="planShowCtx(event, ${pf.id})" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation();planToggleExpand(${pf.id})"><i data-lucide="chevron-right" style="width:14px;height:14px;color:#94a3b8" id="plan_expand_icon_${pf.id}"></i></td><td class="px-2 py-px text-sm font-medium text-center align-middle truncate" style="min-width:300px;max-width:300px;width:300px" title="${esc(pf.customer_name)}">${esc(pf.customer_name)}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('weight', pf.id, 'weight', pf.weight || '20%', ['20%','40%','60%','80%','100%'])}</td><td class="px-2 py-px text-center align-middle text-sm" onclick="event.stopPropagation()">${planInlineSelect('ptype', pf.id, 'project_type', pf.project_type || '待签约', ['待签约','已签约'])}</td><td class="px-2 py-px text-center align-middle text-sm font-medium">${money(pf.sign_amount)}</td><td class="px-2 py-px text-center text-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 align-middle text-sm font-semibold ${payableCls}">${fmtNoUnit(cost)}</td><td class="px-2 py-px text-center text-purple-700 align-middle">${fmtNoUnit(paid)}</td><td class="px-2 py-px text-center align-middle text-sm font-semibold ${payableCls}">${payableVal}</td><td class="px-2 py-px text-center font-semibold align-middle ${cfCls}">${cf}</td></tr><tr class="hidden" id="plan_expand_${pf.id}"><td colspan="15"><table class="w-full"><thead><tr class="border-b border-slate-200"><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">月份</th><th class="px-2 py-1 text-center text-xs text-slate-600">确收</th><th class="px-2 py-1 text-center text-xs text-slate-600">毛利</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">回款</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">成本</th><th class="px-2 py-1 text-center text-xs text-slate-600">出</th><th class="px-2 py-1"></th><th class="px-2 py-1 text-center text-xs text-slate-600">现金流</th><th class="px-2 py-1 text-center text-xs text-slate-600">应收</th></tr></thead><tbody>${detailRows}</tbody></table></td></tr>`;
};
// 待签约简版行
const tdRowUnsigned = (pf, cost, gross) => {
const profit = gross;
const pfCls = profit >= 0 ? 'text-green-600' : 'text-red-600';
const pfVal = profit ? money(profit) : '<span class="text-slate-300">—</span>';
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="planOpenPfEditModal(${pf.id})"><td class="px-2 py-px text-center align-middle text-sm 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') {
@@ -659,7 +663,7 @@ window.planAddTaskRow = (taskMonth = '', taskType = '', taskCount = '', executed
const isPreset = TASK_TYPES.includes(taskType);
const typeCell = isPreset || !taskType
? `<select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="planOnTaskTypeChange(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option ${t === taskType ? 'selected' : ''}>${t}</option>`).join("")}<option value="__custom__" ${!isPreset && taskType ? 'selected' : ''}>自定义...</option></select>`
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
: `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" value="${esc(taskType)}" placeholder="输入自定义类型"><button type="button" class="btn btn-ghost btn-sm text-slate-700 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
row.innerHTML = `<td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full">${planTaskMonthOptions(taskMonth || defaultMonth)}</select></td>
<td class="flex items-center">${typeCell}</td>
<td><input name="task_count[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${taskCount}" oninput="planUpdateTaskDiff(this)"></td>
@@ -721,7 +725,7 @@ window.planOnTaskTypeChange = (sel) => {
if (sel.value !== '__custom__') return;
const td = sel.parentElement;
const oldVal = sel.value;
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-400 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
td.innerHTML = `<input name="task_type[]" class="form-ctrl form-ctrl-sm w-full" placeholder="输入自定义类型" autofocus><button type="button" class="btn btn-ghost btn-sm text-slate-700 p-0 w-5 h-5 ml-1" onclick="planRevertTaskType(this)" title="返回选择"><i data-lucide="rotate-ccw" style="width:12px;height:12px"></i></button>`;
if (window.lucide) window.lucide.createIcons();
td.querySelector('input').focus();
};
@@ -785,7 +789,7 @@ async function planLoadFollowups(pfId) {
const fups = await api(`/api/followups/project_finance/${pfId}`);
list.innerHTML = fups.length
? fups.map(f => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" type="button" onclick="planDeleteFollowup(event, ${f.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")
: '<p class="text-sm text-slate-400 py-4 text-center">暂无跟进记录</p>';
: '<p class="text-sm text-slate-700 py-4 text-center">暂无跟进记录</p>';
if (window.lucide) window.lucide.createIcons();
list.querySelectorAll(".rich-content").forEach(el => {
const html = el.dataset.html;
@@ -1006,7 +1010,8 @@ window.createPlanFinance = async (event) => {
document.querySelector("#planModalTitle").textContent = "新增项目财务";
closePlanModal();
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
renderPlan();
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan();
} catch (error) {
toast("刷新失败:" + error.message, "error");
}
@@ -1022,7 +1027,8 @@ window.deletePlanItem = async () => {
await api(`/api/planFinances/${pfId}`, { method: "DELETE" });
closePlanModal();
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
renderPlan();
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan();
toast("已删除", "success");
} catch (error) {
toast("删除失败:" + error.message, "error");
@@ -1173,11 +1179,11 @@ function renderPlanOverview() {
document.querySelector("#planOverview").innerHTML = '<div class="grid gap-2.5">' +
deptCard +
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">' +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-400">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度签约趋势</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartSign"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度确收与毛利</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartRev"></canvas></div>', 'p-4') +
card('<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">月度回款与已付</h2><span class="text-xs text-slate-600">2026</span></div><div style="position:relative;height:200px"><canvas id="planChartCash"></canvas></div>', 'p-4') +
'</div></div>';
if (window.lucide) window.lucide.createIcons();
@@ -1221,9 +1227,9 @@ function renderPlanQuarterlyOverview() {
}
var thead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q1</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-400 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q2</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q3</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
thead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q4</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">环比</th>';
thead += '</tr>';
var qoq = function(cur, prev) { if (!prev) return '<span class="text-slate-300">—</span>'; var pct = Math.round((cur - prev) / prev * 100); var cls = pct >= 0 ? 'text-green-600' : 'text-red-600'; var sign = pct >= 0 ? '+' : ''; return '<span class="' + cls + '">' + sign + pct + '%</span>'; };
var tbody = '';
@@ -1253,7 +1259,7 @@ function renderPlanQuarterlyOverview() {
var grossQ = [qData[2][0], qData[2][1], qData[2][2], qData[2][3]];
var grossAnnual = annualSums2.gross;
var deptThead = '<tr class="border-b border-slate-200"><th class="py-1.5 text-left text-slate-500 whitespace-nowrap">指标</th><th class="py-1.5 text-right font-semibold text-slate-700 whitespace-nowrap">年度累计</th>';
for (var qi = 0; qi < 4; qi++) { deptThead += '<th class="py-1.5 text-right font-semibold text-slate-600 whitespace-nowrap">Q' + (qi+1) + '</th>'; if (qi > 0) deptThead += '<th class="py-1.5 text-right font-semibold text-slate-400 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>';
var deptRows = [
{ label: '项目毛利', vals: grossQ, annual: grossAnnual },
@@ -1310,7 +1316,7 @@ function renderPlanQuarterlyOverview() {
document.querySelector("#planQuarterlyOverview").innerHTML = '<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">部门现金流=所有项目产生的现金流 - 部门一级因费用产生的现金流出或流入</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>';
if (window.lucide) window.lucide.createIcons();
}
@@ -1350,9 +1356,62 @@ window.planInlineUpdate = async (pfId, field, value) => {
try {
await api("/api/planFinances/" + pfId, { method: "PUT", body: JSON.stringify({ data: { [field]: value } }) });
state.data.planFinances = await api("/api/planFinances/list?tenant=" + encodeURIComponent(state.tenant));
renderPlan();
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan();
toast("已更新", "success");
} catch (error) {
toast("更新失败:" + error.message, "error");
}
};
// ====== 筛选器控制函数 ======
window.switchPlanFilter = (f) => { state.planFilter = f; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan(); };
window.setPlanView = (v) => { state.planView = v; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan(); };
window.setPlanYear = (y) => { state.planYear = parseInt(y); localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan(); };
window.setPlanQuarter = (q) => { state.planView = 'quarterly'; state.planQuarter = q; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan(); };
window.setPlanMonth = (m) => { state.planView = 'monthly'; state.planMonth = m; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan(); };
window.setPlanStatusFilter = (f) => { state.planStatusFilter = f; localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan(); };
// ---- 项目标记上下文菜单 ----
if (!state._pmMarks) state._pmMarks = (function() { try { return JSON.parse(localStorage.getItem('opc_pm_marks')) || { red: {} }; } catch(e) { return { red: {} }; } })();
window.planShowCtx = function(e, pfId) {
e.preventDefault();
var menu = document.getElementById('planCtxMenu');
if (!menu) {
menu = document.createElement('div');
menu.id = 'planCtxMenu';
menu.style.cssText = 'display:none;position:fixed;z-index:9999;background:#fff;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.15);border:1px solid #e2e8f0;padding:4px 0;min-width:130px';
menu.innerHTML = '<div style="padding:4px 12px;font-size:11px;font-weight:600;color:#94a3b8">项目标记</div>' +
'<div data-act="red" style="padding:6px 12px;font-size:13px;cursor:pointer;color:#334155"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;margin-right:8px"></span>标红</div>';
menu.addEventListener('click', function(ev) {
ev.stopPropagation();
var id = menu.getAttribute('data-pfid');
menu.style.display = 'none';
if (state._pmMarks.red[id]) { delete state._pmMarks.red[id]; } else { state._pmMarks.red[id] = true; }
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan();
});
document.body.appendChild(menu);
}
menu.setAttribute('data-pfid', pfId);
menu.style.display = 'block';
menu.style.left = e.pageX + 'px';
menu.style.top = Math.min(e.pageY, window.innerHeight - 200) + 'px';
};
document.addEventListener('click', function() { var m = document.getElementById('planCtxMenu'); if (m) m.style.display = 'none'; });
window.planCtxMenu_act = function(e, type) {
e.stopPropagation();
var pfId = document.getElementById("planCtxMenu").getAttribute("data-pfid");
if (type === "clear") { delete state._pmMarks.red[pfId]; delete state._pmMarks.pin[pfId]; }
else { if (state._pmMarks[type][pfId]) { delete state._pmMarks[type][pfId]; } else { state._pmMarks[type][pfId] = true; } }
localStorage.setItem('opc_pm_marks', JSON.stringify(state._pmMarks));
renderPlan();
};

View File

@@ -84,7 +84,7 @@
<header class="topbar border-b border-slate-200 bg-white px-8 py-5">
<div class="flex items-center gap-3 w-full">
<div class="flex-1">
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.29</span></p>
<p class="eyebrow text-xs font-semibold uppercase tracking-[0.18em] text-blue-700">OPC Manager <span class="ml-2 text-xs font-normal text-slate-400 tracking-normal">v1.2.0.41</span></p>
<div class="flex items-center gap-4 mt-1">
<h1 class="text-2xl font-semibold" id="workspaceTitle">科普 OPC 工作台</h1>
<div class="hidden sm:flex items-center gap-1.5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100 rounded-full px-4 py-1.5">